Update
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { safeStorage } = require('electron');
|
||||
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
schemaVersion: 5,
|
||||
setupComplete: false,
|
||||
appearance: 'dark',
|
||||
gitea: { baseUrl: '', user: null, encryptedToken: null },
|
||||
workspaceRoots: [],
|
||||
repositoryMappings: {},
|
||||
deploymentProfiles: {},
|
||||
deploymentStates: {},
|
||||
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
|
||||
},
|
||||
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);
|
||||
}
|
||||
|
||||
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 : {},
|
||||
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object' ? source.deploymentProfiles : {},
|
||||
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
|
||||
favorites: 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');
|
||||
this.data = this.migrate(JSON.parse(raw));
|
||||
await this.save();
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
await this.save();
|
||||
}
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
async save() {
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fs.writeFile(temporary, JSON.stringify(this.data, null, 2), { mode: 0o600 });
|
||||
await fs.rename(temporary, this.filePath);
|
||||
try { await fs.chmod(this.filePath, 0o600); } catch {}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 {
|
||||
return safeStorage.decryptString(Buffer.from(this.data.gitea.encryptedToken, 'base64'));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
encryptSecret(value) {
|
||||
const text = String(value || '');
|
||||
if (!text) return null;
|
||||
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 { 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 || 'privateKey');
|
||||
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 hostFingerprint = String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
|
||||
return {
|
||||
id: source.id || existing?.id || crypto.randomUUID(),
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
authType,
|
||||
basePath,
|
||||
privateKeyPath,
|
||||
hostFingerprint,
|
||||
encryptedPassword: existing?.encryptedPassword || null,
|
||||
encryptedPassphrase: 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);
|
||||
for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) {
|
||||
this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId);
|
||||
if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key];
|
||||
}
|
||||
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 updateGitea({ baseUrl, token, user }) {
|
||||
const tokenState = this.setToken(token, { preserveExisting: true });
|
||||
this.data.gitea = {
|
||||
...this.data.gitea,
|
||||
baseUrl,
|
||||
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,
|
||||
inputs: {}
|
||||
};
|
||||
if (provider === 'ssh-unraid') {
|
||||
const remoteFolder = String(profile.remoteFolder || '').trim();
|
||||
if (!remoteFolder || !/^[a-zA-Z0-9._-]+$/.test(remoteFolder)) throw new Error('Remote folder must contain only letters, numbers, dots, underscores and dashes.');
|
||||
const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml']));
|
||||
return {
|
||||
...common,
|
||||
serverId: String(profile.serverId || '').trim(),
|
||||
remoteFolder,
|
||||
composeFile: String(profile.composeFile || 'docker-compose.yml').trim(),
|
||||
composeService: String(profile.composeService || '').trim(),
|
||||
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' }),
|
||||
iconUrl: assertHttpUrl(profile.iconUrl, { optional: true, label: 'Icon URL' }),
|
||||
preservePaths,
|
||||
generatedCompose: profile.generatedCompose === true
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
next.fetchIntervalMinutes = Math.min(Math.max(Number(next.fetchIntervalMinutes) || 10, 0), 240);
|
||||
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);
|
||||
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),
|
||||
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,424 @@
|
||||
'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());
|
||||
}
|
||||
|
||||
function isQueuedStatus(value) {
|
||||
return ['pending', 'queued', 'waiting', 'blocked', 'requested'].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 results = [];
|
||||
for (const operation of active.slice(0, 20)) results.push(await this.refreshOperation(operation.id));
|
||||
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,356 @@
|
||||
'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, redactSecrets } = 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.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
|
||||
});
|
||||
const line = `${JSON.stringify(record)}\n`;
|
||||
this.writeChain = this.writeChain.then(async () => {
|
||||
try {
|
||||
if (!this.initialized) await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
|
||||
const target = await this.rotateIfNeeded(this.filePathForToday());
|
||||
await fs.appendFile(target, line, { encoding: 'utf8', mode: 0o600 });
|
||||
try { await fs.chmod(target, 0o600); } catch {}
|
||||
this.lastWriteError = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.lastWriteError = error.message;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return this.writeChain;
|
||||
}
|
||||
|
||||
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) {
|
||||
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(redactSecrets(slice.toString('utf8'), this.secretProvider?.() || []), '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,
|
||||
logs: operation.logs,
|
||||
failure: operation.failure,
|
||||
pollError: operation.pollError,
|
||||
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())
|
||||
];
|
||||
|
||||
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,298 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const { run } = require('./process-runner.cjs');
|
||||
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
|
||||
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
||||
const {
|
||||
assertSafeRepositoryPath,
|
||||
assertRepositoryRelativePath,
|
||||
assertRepositoryRelativePaths,
|
||||
assertCommitMessage,
|
||||
assertFullCommitSha,
|
||||
assertCloneRemote
|
||||
} = require('../shared/validation.cjs');
|
||||
|
||||
class GitService {
|
||||
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.');
|
||||
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);
|
||||
const result = await run('git', ['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])
|
||||
});
|
||||
}
|
||||
|
||||
async getRemoteUrl(repoPath, remote = 'origin') {
|
||||
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
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 content = 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 stage(repoPath, files) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const selected = assertRepositoryRelativePaths(files);
|
||||
await run('git', selected.length ? ['add', '--', ...selected] : ['add', '--all'], { cwd: root, timeout: 60_000 });
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async unstage(repoPath, files) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const selected = assertRepositoryRelativePaths(files);
|
||||
const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] });
|
||||
if (hasHead.exitCode === 0) {
|
||||
await run('git', selected.length ? ['restore', '--staged', '--', ...selected] : ['restore', '--staged', '.'], { cwd: root });
|
||||
} else {
|
||||
await run('git', selected.length ? ['rm', '--cached', '--', ...selected] : ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] });
|
||||
}
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async prepareSelectedStage(root, files) {
|
||||
const selected = assertRepositoryRelativePaths(files);
|
||||
if (selected.length) {
|
||||
const stagedBefore = await run('git', ['diff', '--cached', '--name-only', '-z'], { cwd: root });
|
||||
const alreadyStaged = stagedBefore.stdout.split('\0').filter(Boolean);
|
||||
const excludedStaged = alreadyStaged.filter((file) => !selected.includes(file));
|
||||
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.stage(root, selected);
|
||||
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 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.');
|
||||
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');
|
||||
return { ref, sha, shortSha: sha.slice(0, 7), date, subject };
|
||||
});
|
||||
}
|
||||
|
||||
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 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 };
|
||||
@@ -0,0 +1,277 @@
|
||||
'use strict';
|
||||
|
||||
const { normalizeBaseUrl } = 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 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 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) {
|
||||
if (target.origin !== base.origin) throw new Error('Refusing to send the Gitea token to a different origin.');
|
||||
const response = await fetch(target, {
|
||||
headers: { 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 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,409 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const { fileURLToPath } = require('node:url');
|
||||
const { ipcMain, dialog, shell, app } = require('electron');
|
||||
const { matchRemoteToRepository } = require('../shared/repository-match.cjs');
|
||||
const { cloneDirectoryName, resolveCloneTarget } = require('../shared/clone-target.cjs');
|
||||
|
||||
let diagnosticsService = null;
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
function register(channel, handler) {
|
||||
ipcMain.handle(channel, async (event, payload) => {
|
||||
const started = Date.now();
|
||||
try {
|
||||
assertTrustedSender(event);
|
||||
const data = await handler(payload || {}, event);
|
||||
await diagnosticsService?.debug('ipc.completed', { channel, durationMs: Date.now() - started });
|
||||
return { ok: true, data };
|
||||
} catch (error) {
|
||||
await diagnosticsService?.error('ipc.failed', {
|
||||
channel,
|
||||
durationMs: Date.now() - started,
|
||||
error: { name: error?.name, message: error?.message, code: error?.code, status: error?.status, stack: error?.stack }
|
||||
});
|
||||
console.error(`[${channel}]`, error);
|
||||
return { ok: false, error: toErrorPayload(error) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) {
|
||||
diagnosticsService = diagnostics;
|
||||
const withRepositoryPause = async (localPath, action) => {
|
||||
monitor?.pause(localPath);
|
||||
try { return await action(); }
|
||||
finally { monitor?.resume(localPath); }
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
const canonicalKnown = await Promise.all(knownPaths.map(canonicalPath));
|
||||
if (!canonicalKnown.some((known) => known === candidate)) 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.refresh()).find((item) => item.fullName === 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()
|
||||
}));
|
||||
|
||||
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('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 effectiveToken = String(token || '').trim() || store.getToken();
|
||||
const validation = await gitea.validateConnection(baseUrl, 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();
|
||||
await diagnostics.info('settings.preferences.updated', { preferences: state.preferences });
|
||||
return state;
|
||||
});
|
||||
|
||||
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();
|
||||
setTimeout(() => app.quit(), 650).unref?.();
|
||||
return result;
|
||||
});
|
||||
|
||||
register('server:save', async ({ 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 }) => {
|
||||
const server = store.getServer(serverId);
|
||||
if (!server) throw new Error('The configured server no longer exists.');
|
||||
const result = await ssh.test(serverId, { trustOnFirstUse: !server.hostFingerprint });
|
||||
if (!server.hostFingerprint) {
|
||||
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('repositories:refresh', async () => {
|
||||
const result = await repositories.refresh();
|
||||
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:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stage(safePath, files)); });
|
||||
register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.unstage(safePath, files)); });
|
||||
register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commit(safePath, message, files)); });
|
||||
register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commitAndPush(safePath, message, files)); });
|
||||
register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.push(safePath)); });
|
||||
register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.fetch(safePath)); });
|
||||
register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.pullFastForward(safePath)); });
|
||||
register('repository:history', async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit));
|
||||
register('repository:branches', async ({ localPath }) => git.branches(await assertKnownRepositoryPath(localPath)));
|
||||
register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.checkoutBranch(safePath, branch)); });
|
||||
register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.createBranch(safePath, branch)); });
|
||||
register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(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 withRepositoryPause(safePath, () => git.popStash(safePath, ref)); });
|
||||
|
||||
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('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('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:dispatch', async ({ repository, profileId, sha }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider === 'ssh-unraid') return unraid.deploy({ repository: current, profileId, sha });
|
||||
return deployments.deploy({ repository: current, profileId, sha });
|
||||
});
|
||||
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:health', ({ url }) => deployments.checkHealth(url));
|
||||
register('deployment:profile-state', ({ fullName, profileId }) => {
|
||||
const profile = store.getDeploymentProfile(fullName, profileId);
|
||||
if (profile?.provider === 'ssh-unraid') return unraid.refreshProfileState(fullName, profileId);
|
||||
return deployments.refreshProfileState(fullName, profileId);
|
||||
});
|
||||
register('operations:refresh', async ({ operationId }) => {
|
||||
if (operationId) {
|
||||
const operation = store.getOperation(operationId);
|
||||
if (operation?.provider === 'ssh-unraid') return operation;
|
||||
return deployments.refreshOperation(operationId);
|
||||
}
|
||||
return deployments.refreshActiveOperations();
|
||||
});
|
||||
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 = { registerIpc, cloneDirectoryName, assertTrustedSender, toErrorPayload };
|
||||
@@ -0,0 +1,93 @@
|
||||
'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 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 });
|
||||
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 && ['fullName', 'repository', 'owner', 'user', 'login', 'email'].includes(key)) {
|
||||
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, 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,37 @@
|
||||
'use strict';
|
||||
|
||||
const { execFile } = require('node:child_process');
|
||||
|
||||
function run(command, args = [], options = {}) {
|
||||
const {
|
||||
cwd,
|
||||
timeout = 60_000,
|
||||
maxBuffer = 8 * 1024 * 1024,
|
||||
env,
|
||||
allowExitCodes = []
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, {
|
||||
cwd,
|
||||
timeout,
|
||||
maxBuffer,
|
||||
windowsHide: true,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...(env || {}) }
|
||||
}, (error, stdout, stderr) => {
|
||||
if (error && !allowExitCodes.includes(error.code)) {
|
||||
const wrapped = new Error((stderr || stdout || error.message).trim());
|
||||
wrapped.code = error.code;
|
||||
wrapped.stdout = stdout;
|
||||
wrapped.stderr = stderr;
|
||||
wrapped.command = `${command} ${args.join(' ')}`;
|
||||
reject(wrapped);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: stdout || '', stderr: stderr || '', exitCode: error?.code || 0 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
setPaths(paths) {
|
||||
this.paths = [...new Set((paths || []).filter(Boolean))];
|
||||
for (const existing of [...this.fingerprints.keys()]) {
|
||||
if (!this.paths.includes(existing)) this.fingerprints.delete(existing);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
async tick() {
|
||||
if (this.running || !this.paths.length) return;
|
||||
this.running = true;
|
||||
try {
|
||||
for (const localPath of this.paths) {
|
||||
if (this.paused.has(localPath)) continue;
|
||||
try {
|
||||
const status = await this.git.status(localPath);
|
||||
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 });
|
||||
this.onChange?.({ localPath, status, reason: '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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { RepositoryMonitor };
|
||||
@@ -0,0 +1,193 @@
|
||||
'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 = [];
|
||||
}
|
||||
|
||||
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; }
|
||||
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 refresh() {
|
||||
const started = Date.now();
|
||||
const remoteRepositories = this.store.data.gitea.baseUrl && this.store.getToken()
|
||||
? await this.gitea.listRepositories()
|
||||
: [];
|
||||
|
||||
const discoveredPaths = await this.discoverAll(this.store.data.workspaceRoots);
|
||||
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));
|
||||
}
|
||||
|
||||
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,
|
||||
discoveredCount: discoveredPaths.length,
|
||||
linkedCount: sorted.filter((item) => item.localPath).length,
|
||||
attentionCount: sorted.filter((item) => item.attention).length,
|
||||
readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length
|
||||
});
|
||||
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 readyToDeploy = Boolean(profileForBranch && status?.head && status?.branch.upstream && !hasChanges && ahead === 0 && behind === 0);
|
||||
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,146 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
function loadSshClient() {
|
||||
try { return require('ssh2').Client; }
|
||||
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 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, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
class SshService {
|
||||
constructor({ store, diagnostics }) {
|
||||
this.store = store;
|
||||
this.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
async connectionOptions(server, { trustOnFirstUse = false } = {}) {
|
||||
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);
|
||||
return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint);
|
||||
}
|
||||
};
|
||||
if (server.authType === 'password') {
|
||||
options.password = credentials.password;
|
||||
} else {
|
||||
options.privateKey = await fs.readFile(server.privateKeyPath);
|
||||
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.');
|
||||
const Client = loadSshClient();
|
||||
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;
|
||||
try { client.end(); } catch {}
|
||||
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); }
|
||||
});
|
||||
client.once('error', async (error) => {
|
||||
const wrapped = new Error(`SSH connection failed: ${error.message}`);
|
||||
wrapped.code = error.code || 'SSH_CONNECTION_FAILED';
|
||||
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) => {
|
||||
const timer = setTimeout(() => reject(new Error('The SSH command timed out.')), timeout);
|
||||
client.exec(command, (error, stream) => {
|
||||
if (error) {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
stream.on('data', (chunk) => { if (stdout.length < maxOutput) stdout += chunk.toString(); });
|
||||
stream.stderr.on('data', (chunk) => { if (stderr.length < maxOutput) stderr += chunk.toString(); });
|
||||
stream.on('close', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async test(serverId, { trustOnFirstUse = true } = {}) {
|
||||
return this.withClient(serverId, async (client, server, fingerprint) => {
|
||||
const result = await this.execClient(client, 'uname -srm && command -v git && (docker compose version || docker-compose version)', { timeout: 30_000 });
|
||||
return {
|
||||
connected: true,
|
||||
fingerprint,
|
||||
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
|
||||
output: result.stdout.trim()
|
||||
};
|
||||
}, { trustOnFirstUse });
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -0,0 +1,595 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path').posix;
|
||||
const nativePath = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { shellQuote } = require('./ssh-service.cjs');
|
||||
const { assertFullCommitSha } = require('../shared/validation.cjs');
|
||||
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
||||
|
||||
function safeRemoteFolder(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(text) || text === '.' || text === '..') throw new Error('Remote folder contains unsupported characters.');
|
||||
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\nexport GIT_TERMINAL_PROMPT=0\nexport GIT_SSH_COMMAND='ssh -o BatchMode=yes'\n${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)
|
||||
};
|
||||
}
|
||||
|
||||
class UnraidDeploymentService {
|
||||
constructor({ store, ssh, git, diagnostics }) {
|
||||
this.store = store;
|
||||
this.ssh = ssh;
|
||||
this.git = git;
|
||||
this.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
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.');
|
||||
return { profile, server, remoteFolder, remotePath };
|
||||
}
|
||||
|
||||
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 5 -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 targetSha = assertFullCommitSha(sha || repository.localStatus?.head);
|
||||
const checks = [];
|
||||
let inspection = null;
|
||||
|
||||
if (!repository.localPath) {
|
||||
checks.push({ id: 'local-repository', label: 'Local repository', status: 'fail', detail: 'Link or clone the repository locally before deploying.' });
|
||||
} 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' : 'fail', detail: `Current: ${localStatus.branch.head || 'detached'}; required: ${profile.branch}.` });
|
||||
checks.push({ id: 'local-clean', label: 'Clean local working tree', status: localStatus.clean ? 'pass' : 'fail', detail: localStatus.clean ? 'No uncommitted changes.' : `${localStatus.counts.changed} changed file(s) remain.` });
|
||||
checks.push({ id: 'local-upstream', label: 'Published upstream', status: localStatus.branch.upstream ? 'pass' : 'fail', detail: localStatus.branch.upstream || 'No upstream branch is configured.' });
|
||||
checks.push({ id: 'local-sync', label: 'Local and Gitea synchronized', status: !localStatus.branch.ahead && !localStatus.branch.behind ? 'pass' : 'fail', detail: `${localStatus.branch.ahead || 0} ahead, ${localStatus.branch.behind || 0} behind.` });
|
||||
checks.push({ id: 'local-target-sha', label: 'Selected deployment commit', status: localStatus.head === targetSha ? 'pass' : 'fail', detail: localStatus.head === targetSha ? targetSha : `Local HEAD is ${localStatus.head || 'unknown'}, but deployment requested ${targetSha}.` });
|
||||
try {
|
||||
await this.git.verifyCommitOnRemoteBranch(repository.localPath, targetSha, profile.branch);
|
||||
checks.push({ id: 'remote-target-sha', label: 'Exact commit on Gitea branch', status: 'pass', detail: `${targetSha.slice(0, 7)} exists on origin/${profile.branch}.` });
|
||||
} catch (error) {
|
||||
checks.push({ id: 'remote-target-sha', label: 'Exact commit on Gitea branch', status: 'fail', detail: error.message });
|
||||
}
|
||||
|
||||
const localDeploymentFile = profile.generatedCompose
|
||||
? nativePath.join(repository.localPath, 'Dockerfile')
|
||||
: nativePath.join(repository.localPath, safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml'));
|
||||
const localDeploymentFileExists = Boolean((await fs.stat(localDeploymentFile).catch(() => null))?.isFile());
|
||||
checks.push({
|
||||
id: 'local-deployment-file',
|
||||
label: profile.generatedCompose ? 'Dockerfile in repository' : 'Compose file in repository',
|
||||
status: localDeploymentFileExists ? 'pass' : 'fail',
|
||||
detail: localDeploymentFileExists ? localDeploymentFile : `${localDeploymentFile} was not found in the exact local checkout.`
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({ id: 'local-repository', label: 'Local repository', status: 'fail', detail: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const connection = await this.ssh.test(server.id, { trustOnFirstUse: false });
|
||||
checks.push({ id: 'ssh', label: 'SSH connection', status: 'pass', detail: `${server.username}@${server.host}:${server.port}` });
|
||||
if (!/docker compose|docker-compose/i.test(connection.output)) {
|
||||
checks.push({ id: 'compose-command', label: 'Docker Compose', status: 'fail', detail: 'Docker Compose was not detected on the server.' });
|
||||
} else checks.push({ id: 'compose-command', label: 'Docker Compose', status: 'pass', detail: 'Docker Compose is available.' });
|
||||
} catch (error) {
|
||||
checks.push({ id: 'ssh', label: 'SSH connection', 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 });
|
||||
|
||||
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 if (!inspection.rootGit) {
|
||||
checks.push({ id: 'remote-folder', label: 'Remote project folder', status: 'fail', detail: `${remotePath} exists but is not a Git working tree. Adopt or migrate it before deployment.` });
|
||||
} else {
|
||||
checks.push({ id: 'remote-folder', label: 'Remote Git working tree', status: 'pass', detail: `${remotePath} at ${String(inspection.head || '').slice(0, 7) || 'unknown'}.` });
|
||||
}
|
||||
if (inspection.trackedChanges.length) {
|
||||
checks.push({ id: 'tracked-changes', label: 'Server-side tracked changes', status: 'fail', detail: `${inspection.trackedChanges.length} tracked change(s) would be overwritten. Commit, revert or migrate them first.` });
|
||||
} else if (inspection.rootGit) checks.push({ id: 'tracked-changes', label: 'Server-side tracked changes', status: 'pass', detail: 'No tracked server-only edits detected.' });
|
||||
|
||||
if (inspection.rootGit && profile.cloneUrl && inspection.remote) {
|
||||
const expectedRemote = normalizeRemoteUrl(profile.cloneUrl);
|
||||
const currentRemote = normalizeRemoteUrl(inspection.remote);
|
||||
const matches = Boolean(expectedRemote && currentRemote && expectedRemote.host === currentRemote.host && expectedRemote.path === currentRemote.path);
|
||||
if (!matches && profile.alignRemote) {
|
||||
checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'warning', detail: `Origin will be aligned from ${inspection.remote} to the configured clone URL before fetch.` });
|
||||
} else if (!matches) {
|
||||
checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'fail', detail: `Current origin ${inspection.remote} does not match the configured clone URL. Enable controlled origin alignment or correct the profile.` });
|
||||
} else {
|
||||
checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'pass', detail: inspection.remote });
|
||||
}
|
||||
}
|
||||
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 composeFile = safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
|
||||
if (inspection.exists && !inspection.composeFiles.includes(composeFile) && !profile.generatedCompose) {
|
||||
checks.push({ id: 'compose-file', label: 'Compose configuration', status: 'fail', detail: `${composeFile} was not found. Select an existing file or enable generated Compose.` });
|
||||
} else {
|
||||
checks.push({ id: 'compose-file', label: 'Compose configuration', status: 'pass', detail: profile.generatedCompose ? 'ForgeFlow will generate an isolated Compose file.' : composeFile });
|
||||
}
|
||||
} catch (error) {
|
||||
checks.push({ id: 'inspection', label: 'Server project inspection', status: 'fail', detail: error.message });
|
||||
}
|
||||
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,
|
||||
checks,
|
||||
summary: checksSummary(checks)
|
||||
};
|
||||
}
|
||||
|
||||
generatedCompose(profile, repository) {
|
||||
const service = String(profile.composeService || repository.name || 'app').toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'app';
|
||||
if (!profile.hostPort || !profile.containerPort) throw new Error('Host and container ports are required for generated Compose.');
|
||||
const labels = [
|
||||
'net.unraid.docker.managed=dockerman',
|
||||
profile.webUiUrl ? `net.unraid.docker.webui=${profile.webUiUrl}` : '',
|
||||
profile.iconUrl ? `net.unraid.docker.icon=${profile.iconUrl}` : ''
|
||||
].filter(Boolean);
|
||||
return [
|
||||
'services:',
|
||||
` ${service}:`,
|
||||
' build:',
|
||||
' context: ..',
|
||||
` container_name: ${service}`,
|
||||
' restart: unless-stopped',
|
||||
' ports:',
|
||||
` - "${profile.hostPort}:${profile.containerPort}"`,
|
||||
...(labels.length ? [' labels:', ...labels.map((label) => ` - ${JSON.stringify(label)}`)] : [])
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async deploy({ repository, profileId, sha }) {
|
||||
const targetSha = assertFullCommitSha(sha);
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
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 operation = await this.store.addOperation({
|
||||
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: ['SSH connection verified.', `Deploying exact commit ${targetSha}.`]
|
||||
});
|
||||
|
||||
const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || '').trim();
|
||||
if (!cloneUrl) throw new Error('No server-usable Git clone URL is configured.');
|
||||
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
|
||||
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : '';
|
||||
const branch = String(profile.branch || 'main');
|
||||
const statusJson = 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),
|
||||
deployed_at: new Date().toISOString()
|
||||
});
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
parent=$(dirname "$root")
|
||||
mkdir -p "$parent"
|
||||
if [ ! -d "$root" ]; then
|
||||
git clone --branch ${shellQuote(branch)} --single-branch ${shellQuote(cloneUrl)} "$root"
|
||||
fi
|
||||
test -d "$root/.git" || { echo "Existing folder is not a Git working tree" >&2; exit 32; }
|
||||
${profile.alignRemote ? `git -C "$root" remote set-url origin ${shellQuote(cloneUrl)}` : ''}
|
||||
changes=$(git -C "$root" status --porcelain --untracked-files=no)
|
||||
test -z "$changes" || { echo "Tracked server-side changes block deployment" >&2; printf '%s\\n' "$changes" >&2; exit 33; }
|
||||
git -C "$root" fetch --prune origin ${shellQuote(branch)}
|
||||
git -C "$root" cat-file -e ${shellQuote(`${targetSha}^{commit}`)}
|
||||
git -C "$root" merge-base --is-ancestor ${shellQuote(targetSha)} ${shellQuote(`origin/${branch}`)}
|
||||
previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
|
||||
git -C "$root" checkout -B ${shellQuote(branch)} ${shellQuote(`origin/${branch}`)}
|
||||
git -C "$root" reset --hard ${shellQuote(targetSha)}
|
||||
mkdir -p "$root/.forgeflow"
|
||||
printf '%s' "$previous" > "$root/.forgeflow/previous-sha"
|
||||
printf '%s' ${shellQuote(targetSha)} > "$root/.forgeflow/current-sha"
|
||||
${profile.generatedCompose ? `cat > "$root/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'\n${generated}FORGEFLOW_COMPOSE` : ''}
|
||||
cd "$root"
|
||||
docker compose -f ${shellQuote(composeFile)} config >/dev/null
|
||||
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
|
||||
cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS'
|
||||
${statusJson}
|
||||
FORGEFLOW_STATUS
|
||||
`;
|
||||
try {
|
||||
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
|
||||
const health = await this.checkHealth(profile.healthcheckUrl);
|
||||
const finalStatus = health.healthy === false ? 'failed' : 'success';
|
||||
const finalLogs = [
|
||||
...operation.logs,
|
||||
...result.stdout.trim().split('\n').filter(Boolean).slice(-60),
|
||||
'Docker Compose deployment completed.',
|
||||
health.configured ? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.` : 'No desktop healthcheck URL configured.'
|
||||
];
|
||||
const completed = await this.store.addOperation({
|
||||
...operation,
|
||||
status: finalStatus,
|
||||
previousSha: preflight.inspection?.head || null,
|
||||
health,
|
||||
logs: finalLogs,
|
||||
error: health.healthy === false ? 'The application healthcheck did not pass after deployment.' : null
|
||||
});
|
||||
await this.store.saveDeploymentState(profileId, {
|
||||
liveSha: targetSha,
|
||||
previousSha: preflight.inspection?.head || null,
|
||||
healthy: health.healthy,
|
||||
healthStatus: health.status,
|
||||
healthLatencyMs: health.latencyMs,
|
||||
requestId,
|
||||
remotePath,
|
||||
provider: 'ssh-unraid'
|
||||
});
|
||||
await this.diagnostics?.info('unraid.deployment.completed', {
|
||||
requestId,
|
||||
repository: repository.fullName,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
sha: targetSha,
|
||||
healthy: health.healthy,
|
||||
healthStatus: health.status
|
||||
});
|
||||
if (health.healthy === false) {
|
||||
const error = new Error('Deployment completed, but the configured healthcheck failed. The previous SHA remains available for rollback.');
|
||||
error.code = 'DEPLOYMENT_HEALTHCHECK_FAILED';
|
||||
error.operationId = completed.id;
|
||||
throw error;
|
||||
}
|
||||
return completed;
|
||||
} catch (error) {
|
||||
if (error.code !== 'DEPLOYMENT_HEALTHCHECK_FAILED') {
|
||||
await this.store.addOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] });
|
||||
}
|
||||
await this.diagnostics?.error('unraid.deployment.failed', {
|
||||
requestId,
|
||||
repository: repository.fullName,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
sha: targetSha,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (!repository.localPath) throw new Error('A linked local repository is required for rollback verification.');
|
||||
await this.git.verifyCommitOnRemoteBranch(repository.localPath, target, profile.branch);
|
||||
const inspection = await this.inspect({ repository, profileId });
|
||||
if (!inspection.rootGit) throw new Error('The configured server project is not a root Git working tree.');
|
||||
if (inspection.trackedChanges.length) throw new Error('Tracked server-side changes block rollback. Commit, revert or migrate them first.');
|
||||
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
|
||||
const requestId = crypto.randomUUID();
|
||||
const operation = await this.store.addOperation({
|
||||
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 statusJson = JSON.stringify({
|
||||
repository: repository.fullName,
|
||||
environment: profile.environment,
|
||||
requested_sha: target,
|
||||
live_sha: target,
|
||||
request_id: requestId,
|
||||
healthy: null,
|
||||
healthcheck_url_configured: Boolean(profile.healthcheckUrl),
|
||||
rollback: true,
|
||||
deployed_at: new Date().toISOString()
|
||||
});
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
test -d "$root/.git"
|
||||
git -C "$root" fetch --prune origin ${shellQuote(profile.branch)}
|
||||
git -C "$root" cat-file -e ${shellQuote(`${target}^{commit}`)}
|
||||
current=$(git -C "$root" rev-parse HEAD)
|
||||
git -C "$root" reset --hard ${shellQuote(target)}
|
||||
cd "$root"
|
||||
docker compose -f ${shellQuote(composeFile)} config >/dev/null
|
||||
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
|
||||
printf '%s' "$current" > "$root/.forgeflow/previous-sha"
|
||||
printf '%s' ${shellQuote(target)} > "$root/.forgeflow/current-sha"
|
||||
cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS'
|
||||
${statusJson}
|
||||
FORGEFLOW_STATUS
|
||||
`;
|
||||
try {
|
||||
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
|
||||
const health = await this.checkHealth(profile.healthcheckUrl);
|
||||
const finalStatus = health.healthy === false ? 'failed' : 'rolled-back';
|
||||
const completed = await this.store.addOperation({
|
||||
...operation,
|
||||
status: finalStatus,
|
||||
previousSha: deploymentState.liveSha || inspection.head || 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(-60),
|
||||
'Rollback completed.',
|
||||
health.configured ? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.` : 'No desktop healthcheck URL configured.'
|
||||
]
|
||||
});
|
||||
await this.store.saveDeploymentState(profileId, {
|
||||
liveSha: target,
|
||||
previousSha: deploymentState.liveSha || inspection.head || null,
|
||||
healthy: health.healthy,
|
||||
healthStatus: health.status,
|
||||
healthLatencyMs: health.latencyMs,
|
||||
requestId,
|
||||
remotePath,
|
||||
provider: 'ssh-unraid'
|
||||
});
|
||||
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.store.addOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshProfileState(fullName, profileId) {
|
||||
const repository = { fullName, name: fullName.split('/').pop() };
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
live=""; previous=""; status=""
|
||||
[ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha")
|
||||
[ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha")
|
||||
[ -f "$root/.forgeflow/status.json" ] && status=$(base64 "$root/.forgeflow/status.json" | tr -d '\\r\\n')
|
||||
printf '__FORGEFLOW_JSON__\\n{"liveSha":"%s","previousSha":"%s","statusBase64":"%s"}\\n' "$live" "$previous" "$status"
|
||||
`;
|
||||
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30_000 });
|
||||
const raw = parseInspection(result.stdout);
|
||||
let remoteStatus = null;
|
||||
try { remoteStatus = raw.statusBase64 ? JSON.parse(Buffer.from(raw.statusBase64, 'base64').toString('utf8')) : null; } catch {}
|
||||
const existing = this.store.getDeploymentState(profile.id) || {};
|
||||
return this.store.saveDeploymentState(profile.id, {
|
||||
liveSha: /^[0-9a-f]{40}$/i.test(raw.liveSha || '') ? raw.liveSha : null,
|
||||
previousSha: /^[0-9a-f]{40}$/i.test(raw.previousSha || '') ? raw.previousSha : null,
|
||||
healthy: remoteStatus?.healthy ?? existing.healthy ?? null,
|
||||
healthStatus: existing.healthStatus ?? null,
|
||||
healthLatencyMs: existing.healthLatencyMs ?? null,
|
||||
requestId: remoteStatus?.request_id || existing.requestId || null,
|
||||
remotePath,
|
||||
provider: 'ssh-unraid'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UnraidDeploymentService,
|
||||
safeRemoteFolder,
|
||||
safeRelativeRemoteFile,
|
||||
parseInspection,
|
||||
dockerIgnoreHasPath,
|
||||
checksSummary,
|
||||
bash
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
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;
|
||||
}
|
||||
|
||||
class UpdateService {
|
||||
constructor({ store, gitea, diagnostics, appInfo, sourcePath, userDataPath }) {
|
||||
this.store = store;
|
||||
this.gitea = gitea;
|
||||
this.diagnostics = diagnostics;
|
||||
this.appInfo = appInfo;
|
||||
this.sourcePath = sourcePath;
|
||||
this.updateDirectory = path.join(userDataPath, 'updates');
|
||||
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) {
|
||||
const error = new Error('This developer release uses source updates. Install a signed packaged release before using binary auto-update.');
|
||||
error.code = 'PACKAGED_UPDATE_NOT_CONFIGURED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
async apply(staged = null) {
|
||||
const update = staged?.archivePath ? staged : this.staged;
|
||||
if (!update?.archivePath) throw new Error('Download an update before applying it.');
|
||||
if (process.platform !== 'win32') throw new Error('The integrated source updater currently supports Windows only.');
|
||||
const stat = await fs.stat(update.archivePath).catch(() => null);
|
||||
if (!stat?.isFile()) throw new Error('The staged update archive is no longer available.');
|
||||
|
||||
const scriptPath = path.join(this.sourcePath, 'scripts', 'apply-source-update.ps1');
|
||||
const scriptStat = await fs.stat(scriptPath).catch(() => null);
|
||||
if (!scriptStat?.isFile()) throw new Error('The source update helper is missing.');
|
||||
const logPath = path.join(this.updateDirectory, `apply-${Date.now()}.log`);
|
||||
const args = [
|
||||
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
|
||||
'-SourcePath', this.sourcePath,
|
||||
'-ArchivePath', update.archivePath,
|
||||
'-ExpectedVersion', update.remoteVersion,
|
||||
'-ExpectedSha256', update.sha256,
|
||||
'-ParentPid', String(process.pid),
|
||||
'-LogPath', logPath
|
||||
];
|
||||
const child = spawn('powershell.exe', args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false,
|
||||
cwd: this.sourcePath
|
||||
});
|
||||
child.unref();
|
||||
await this.diagnostics?.info('updates.apply-launched', {
|
||||
remoteVersion: update.remoteVersion,
|
||||
remoteSha: update.remoteSha,
|
||||
logPath
|
||||
});
|
||||
return { launched: true, version: update.remoteVersion, logPath };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UpdateService, safeRepositoryPart };
|
||||
Reference in New Issue
Block a user