This commit is contained in:
NuklearRabbit
2026-07-24 20:29:23 +02:00
commit 66060348da
107 changed files with 14771 additions and 0 deletions
+424
View File
@@ -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 };
+424
View File
@@ -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 };
+356
View File
@@ -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 };
+298
View File
@@ -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 };
+277
View File
@@ -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 };
+409
View File
@@ -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 };
+93
View File
@@ -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 };
+208
View File
@@ -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 };
+37
View File
@@ -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 };
+70
View File
@@ -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 };
+193
View File
@@ -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 };
+146
View File
@@ -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 };
+595
View File
@@ -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
};
+135
View File
@@ -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 };