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 };
+1055
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>ForgeFlow</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="app" aria-live="polite">
<div class="boot-screen">
<div class="brand-mark">F</div>
<strong>Starting ForgeFlow</strong>
<span>Checking Git and local configuration…</span>
</div>
</div>
<div id="toast-root" class="toast-root" aria-live="assertive"></div>
<script src="mock-bridge.js"></script>
<script defer src="app.js"></script>
</body>
</html>
+350
View File
@@ -0,0 +1,350 @@
(() => {
if (window.forgeflow) return;
const wait = (ms = 180) => new Promise((resolve) => setTimeout(resolve, ms));
const clone = (value) => JSON.parse(JSON.stringify(value));
const iso = (offset = 0) => new Date(Date.now() + offset).toISOString();
const storage = {
get(key) { try { return localStorage.getItem(key); } catch { return null; } },
set(key, value) { try { localStorage.setItem(key, value); } catch {} }
};
const repositoryListeners = new Set();
const operationListeners = new Set();
const updateListeners = new Set();
const emitRepositories = () => repositoryListeners.forEach((listener) => listener({ reason: 'demo-change' }));
const emitOperations = (operations) => operationListeners.forEach((listener) => listener({ operations: clone(operations) }));
const randomSha = () => `${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`.padEnd(40, 'a').slice(0, 40);
const makeStatus = ({ head, branch = 'main', ahead = 0, behind = 0, upstream = `origin/${branch}`, files = [] }) => ({
branch: { oid: head, head: branch, upstream, ahead, behind },
files,
counts: {
changed: files.length,
staged: files.filter((item) => item.staged).length,
unstaged: files.filter((item) => item.unstaged).length,
conflicts: files.filter((item) => item.conflict).length,
untracked: files.filter((item) => item.untracked).length
},
clean: files.length === 0,
root: '',
remoteUrl: '',
head,
shortHead: head.slice(0, 7),
fingerprint: `${head}:${branch}:${ahead}:${behind}:${files.map((item) => `${item.path}:${item.indexCode}${item.worktreeCode}`).join('|')}`
});
const makeFile = (path, status = 'modified', options = {}) => ({
path,
originalPath: options.originalPath || null,
indexCode: options.staged ? (status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M') : '.',
worktreeCode: options.staged ? '.' : status === 'untracked' ? '?' : status === 'deleted' ? 'D' : status === 'conflict' ? 'U' : 'M',
staged: Boolean(options.staged),
unstaged: !options.staged,
untracked: status === 'untracked',
conflict: status === 'conflict',
status
});
const profile = (id, name, environment, options = {}) => ({
id,
name,
environment,
provider: 'gitea-actions',
branch: options.branch || 'main',
workflowFile: options.workflowFile || 'deploy.yml',
rollbackWorkflowFile: options.rollbackWorkflowFile ?? 'rollback.yml',
healthcheckUrl: options.healthcheckUrl || `https://${environment}.internal/health`,
statusUrl: options.statusUrl || `https://${environment}.internal/.well-known/forgeflow`,
confirmationRequired: options.confirmationRequired !== false,
inputs: {},
state: {
liveSha: options.liveSha || null,
previousSha: options.previousSha || null,
healthy: options.healthy ?? null,
healthConfigured: true,
statusConfigured: true,
healthStatus: options.healthy === false ? 503 : 200,
healthLatencyMs: 42,
checkedAt: options.checkedAt || iso(-120000)
}
});
const now = iso();
const defaultPreferences = {
autoRefresh: true,
repositoryPollSeconds: 4,
operationPollSeconds: 5,
fetchIntervalMinutes: 10,
preferredCloneProtocol: 'https',
diagnosticsEnabled: true,
diagnosticLevel: 'info',
logRetentionDays: 14,
maxLogFileMb: 8
};
let state = {
schemaVersion: 5,
setupComplete: storage.get('forgeflow-demo-setup') !== 'false',
appearance: storage.get('forgeflow-theme') || 'dark',
gitea: { baseUrl: 'https://gitea.internal', user: { login: 'jens', full_name: 'Jens' }, hasToken: true },
workspaceRoots: ['C:\\Development'],
repositoryMappings: {},
deploymentProfiles: {},
deploymentStates: {},
favorites: ['jens/microsoft-cloud-operations-platform', 'jens/unraid-appops-gateway'],
updates: { owner: 'Jens', repo: 'ForgeFlow', branch: 'main', autoCheck: true, lastCheckedAt: null },
servers: [{ id: 'server-unraid', name: 'Unraid', host: '192.168.1.10', port: 22, username: 'root', authType: 'privateKey', basePath: '/mnt/user/appdata', privateKeyPath: 'C:\\Users\\Jens\\.ssh\\id_ed25519', hostFingerprint: 'SHA256:demo', hasPassword: false, hasPassphrase: false }],
preferences: { ...defaultPreferences },
operations: [
{
id: 'op-success', type: 'deployment', action: 'deploy', status: 'success',
repository: 'jens/microsoft-cloud-operations-platform', profileId: 'profile-mcop-prod', profileName: 'Production',
environment: 'production', workflowFile: 'deploy.yml', branch: 'main',
sha: 'b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0', shortSha: 'b82f91a', createdAt: now, updatedAt: now,
stages: [
{ id: 'requested', label: 'Requested', status: 'complete' }, { id: 'verified', label: 'Verified', status: 'complete' },
{ id: 'queued', label: 'Workflow queued', status: 'complete' }, { id: 'runner', label: 'Runner execution', status: 'complete' },
{ id: 'healthcheck', label: 'Healthcheck', status: 'complete' }, { id: 'complete', label: 'Complete', status: 'complete' }
],
logs: ['[info] Exact commit verified.', '[job] deploy: success', '[ok] Server reports b82f91a and healthcheck returned 200.'],
run: { id: 48, runNumber: 48, status: 'completed', conclusion: 'success', name: 'ForgeFlow deployment' },
runUrl: 'https://gitea.internal/jens/microsoft-cloud-operations-platform/actions/runs/48'
},
{
id: 'op-failed', type: 'deployment', action: 'deploy', status: 'failed',
repository: 'jens/portfolio', profileId: 'profile-portfolio', profileName: 'Production', environment: 'production',
workflowFile: 'deploy.yml', branch: 'main', sha: 'a7f2e1c1bb6147fc8b6633d2b08500c93402a719', shortSha: 'a7f2e1c',
createdAt: iso(-86400000), updatedAt: iso(-86300000),
failure: { stage: 'healthcheck', message: 'Healthcheck returned 502.' },
stages: [
{ id: 'requested', label: 'Requested', status: 'complete' }, { id: 'verified', label: 'Verified', status: 'complete' },
{ id: 'queued', label: 'Workflow queued', status: 'complete' }, { id: 'runner', label: 'Runner execution', status: 'complete' },
{ id: 'healthcheck', label: 'Healthcheck', status: 'failed' }, { id: 'complete', label: 'Complete', status: 'failed' }
],
logs: ['[job] deploy: success', '[error] Healthcheck returned 502.']
}
]
};
let repositories = [
{
id: 1, name: 'microsoft-cloud-operations-platform', fullName: 'jens/microsoft-cloud-operations-platform', owner: { login: 'jens' },
description: 'Tenant-aware Microsoft cloud operations console.', private: true, defaultBranch: 'main',
htmlUrl: 'https://gitea.internal/jens/microsoft-cloud-operations-platform', cloneUrl: 'https://gitea.internal/jens/microsoft-cloud-operations-platform.git', sshUrl: 'git@gitea.internal:jens/microsoft-cloud-operations-platform.git', updatedAt: now,
localPath: 'C:\\Development\\Microsoft-Cloud-Operations-Platform',
localStatus: makeStatus({ head: 'b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0' }), linkState: 'linked',
deploymentProfiles: [
profile('profile-mcop-prod', 'Production', 'production', { liveSha: '72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0', previousSha: '6ac991ab0173cd4346ca0f0f7dcc3e8182cc8fd0', healthy: true }),
profile('profile-mcop-stage', 'Staging', 'staging', { liveSha: 'b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0', previousSha: '72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0', healthy: true, confirmationRequired: false })
]
},
{
id: 2, name: 'vacancyradar', fullName: 'jens/vacancyradar', owner: { login: 'jens' }, description: 'Local-first vacancy intelligence cockpit.', private: true, defaultBranch: 'main',
htmlUrl: 'https://gitea.internal/jens/vacancyradar', cloneUrl: 'https://gitea.internal/jens/vacancyradar.git', sshUrl: 'git@gitea.internal:jens/vacancyradar.git', updatedAt: now,
localPath: 'C:\\Development\\VacancyRadar',
localStatus: makeStatus({ head: 'c9182d0d28318c8cf0af109edc054732426aadf1', branch: 'feature/deployment-api', files: [makeFile('src/api/deploy.ts', 'added', { staged: true }), makeFile('src/main.tsx'), makeFile('src/components/Sidebar.tsx')] }),
linkState: 'linked', deploymentProfiles: [profile('profile-vr', 'Production', 'production', { liveSha: 'c117ab9d28318c8cf0af109edc054732426aadf1', previousSha: 'b1f57aad28318c8cf0af109edc054732426aadf1', healthy: true })]
},
{
id: 3, name: 'unraid-appops-gateway', fullName: 'jens/unraid-appops-gateway', owner: { login: 'jens' }, description: 'Safe operations gateway for Unraid and Portainer.', private: true, defaultBranch: 'main',
htmlUrl: 'https://gitea.internal/jens/unraid-appops-gateway', cloneUrl: 'https://gitea.internal/jens/unraid-appops-gateway.git', sshUrl: 'git@gitea.internal:jens/unraid-appops-gateway.git', updatedAt: now,
localPath: 'C:\\Development\\Unraid-AppOps-Gateway', localStatus: makeStatus({ head: 'f2d1e0a1bb6147fc8b6633d2b08500c93402a719', ahead: 2 }), linkState: 'linked',
deploymentProfiles: [profile('profile-appops', 'Production', 'production', { liveSha: '8ac731b1bb6147fc8b6633d2b08500c93402a719', previousSha: '7bc198a1bb6147fc8b6633d2b08500c93402a719', healthy: true })]
},
{
id: 4, name: 'support-bundle-collector', fullName: 'jens/support-bundle-collector', owner: { login: 'jens' }, description: 'Privacy-aware Windows support bundle collector.', private: true, defaultBranch: 'main',
htmlUrl: 'https://gitea.internal/jens/support-bundle-collector', cloneUrl: 'https://gitea.internal/jens/support-bundle-collector.git', sshUrl: 'git@gitea.internal:jens/support-bundle-collector.git', updatedAt: now,
localPath: null, localStatus: null, linkState: 'remote-only', deploymentProfiles: []
},
{
id: 5, name: 'portfolio', fullName: 'jens/portfolio', owner: { login: 'jens' }, description: 'Professional infrastructure and automation portfolio.', private: false, defaultBranch: 'main',
htmlUrl: 'https://gitea.internal/jens/portfolio', cloneUrl: 'https://gitea.internal/jens/portfolio.git', sshUrl: 'git@gitea.internal:jens/portfolio.git', updatedAt: now,
localPath: 'C:\\Development\\portfolio', localStatus: makeStatus({ head: 'a7f2e1c1bb6147fc8b6633d2b08500c93402a719', behind: 1 }), linkState: 'linked',
deploymentProfiles: [profile('profile-portfolio', 'Production', 'production', { liveSha: '4c20dd11bb6147fc8b6633d2b08500c93402a719', previousSha: '31adfe11bb6147fc8b6633d2b08500c93402a719', healthy: false })]
}
];
const diffs = {
'src/api/deploy.ts': `diff --git a/src/api/deploy.ts b/src/api/deploy.ts\nnew file mode 100644\n--- /dev/null\n+++ b/src/api/deploy.ts\n@@ -0,0 +1,18 @@\n+export interface DeploymentRequest {\n+ environment: 'staging' | 'production';\n+ commitSha: string;\n+}\n+\n+export async function deploy(request: DeploymentRequest) {\n+ return api.post('/deployments', request);\n+}`,
'src/main.tsx': `diff --git a/src/main.tsx b/src/main.tsx\nindex 45ad1a2..939fc17 100644\n--- a/src/main.tsx\n+++ b/src/main.tsx\n@@ -24,8 +24,9 @@ import { Router } from './routes';\n-const API_ENDPOINT = 'http://localhost:3000';\n+const API_ENDPOINT = process.env.VITE_API_URL || '/api';\n+const DEPLOY_VERSION = '1.0.4-rc1';`,
'src/components/Sidebar.tsx': `diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx\nindex a7bbd82..bf21e90 100644\n--- a/src/components/Sidebar.tsx\n+++ b/src/components/Sidebar.tsx\n@@ -31,6 +31,7 @@ export function Sidebar() {\n+ <NavItem to="/deployments">Deployments</NavItem>`
};
const findRepo = (localPath) => repositories.find((item) => item.localPath === localPath);
const findProfileRepo = (profileId) => repositories.find((item) => item.deploymentProfiles.some((entry) => entry.id === profileId));
const syncState = () => {
state.deploymentProfiles = {};
state.deploymentStates = {};
state.repositoryMappings = {};
for (const repository of repositories) {
if (repository.localPath) state.repositoryMappings[repository.fullName.toLowerCase()] = repository.localPath;
state.deploymentProfiles[repository.fullName.toLowerCase()] = repository.deploymentProfiles.map(({ state: profileState, ...entry }) => entry);
for (const entry of repository.deploymentProfiles) if (entry.state) state.deploymentStates[entry.id] = clone(entry.state);
}
};
const recompute = (repository) => {
const status = repository.localStatus;
if (status) {
status.counts = {
changed: status.files.length,
staged: status.files.filter((item) => item.staged).length,
unstaged: status.files.filter((item) => item.unstaged).length,
conflicts: status.files.filter((item) => item.conflict).length,
untracked: status.files.filter((item) => item.untracked).length
};
status.clean = status.files.length === 0;
status.shortHead = status.head.slice(0, 7);
status.branch.oid = status.head;
}
repository.favorite = state.favorites.includes(repository.fullName.toLowerCase());
repository.readyToDeploy = Boolean(repository.localPath && status?.clean && status.branch.upstream && status.branch.ahead === 0 && status.branch.behind === 0 && repository.deploymentProfiles.some((entry) => entry.branch === status.branch.head));
repository.attention = !repository.localPath || Boolean(status?.counts.conflicts || status?.branch.behind || status?.branch.ahead || status?.counts.changed);
repository.attentionReason = !repository.localPath ? 'No local folder linked' : status?.counts.conflicts ? `${status.counts.conflicts} conflict(s)` : status?.counts.changed ? `${status.counts.changed} local change(s)` : status?.branch.behind ? `${status.branch.behind} commit(s) behind remote` : status?.branch.ahead ? `${status.branch.ahead} unpushed commit(s)` : null;
repository.preferredCloneUrl = state.preferences.preferredCloneProtocol === 'ssh' ? repository.sshUrl : repository.cloneUrl;
};
const snapshot = () => { repositories.forEach(recompute); syncState(); return clone(repositories); };
syncState();
const commitHistory = [
{ sha: 'c9182d0d28318c8cf0af109edc054732426aadf1', shortSha: 'c9182d0', author: 'Jens', date: now, subject: 'feat: add deployment provider contract' },
{ sha: '1fa7399d28318c8cf0af109edc054732426aadf1', shortSha: '1fa7399', author: 'Jens', date: iso(-86400000), subject: 'refactor: consolidate repository state' },
{ sha: 'a251a11d28318c8cf0af109edc054732426aadf1', shortSha: 'a251a11', author: 'Jens', date: iso(-172800000), subject: 'docs: define deployment safety gates' }
];
const branchesByRepo = new Map();
const stashesByRepo = new Map();
function updateOperation(operation) {
state.operations = [clone(operation), ...state.operations.filter((item) => item.id !== operation.id)].slice(0, 250);
emitOperations([operation]);
return clone(operation);
}
function advanceOperation(operation) {
if (!operation || ['success', 'failed', 'cancelled', 'rolled-back'].includes(operation.status)) return operation;
operation.demoPolls = (operation.demoPolls || 0) + 1;
if (operation.demoPolls === 1) {
operation.status = 'running';
operation.run = { id: 81, runNumber: 81, status: 'running', conclusion: null, name: operation.action === 'rollback' ? 'ForgeFlow rollback' : 'ForgeFlow deployment' };
operation.runUrl = `https://gitea.internal/${operation.repository}/actions/runs/81`;
operation.stages.find((item) => item.id === 'queued').status = 'complete';
operation.stages.find((item) => item.id === 'runner').status = 'active';
operation.jobs = [{ id: 201, name: operation.action === 'rollback' ? 'rollback' : 'deploy', status: 'running', conclusion: null }];
operation.logs.push(`[job] ${operation.jobs[0].name}: running`);
} else if (operation.demoPolls >= 2) {
operation.status = operation.action === 'rollback' ? 'rolled-back' : 'success';
operation.stages.forEach((item) => { item.status = 'complete'; });
operation.jobs = [{ id: 201, name: operation.action === 'rollback' ? 'rollback' : 'deploy', status: 'completed', conclusion: 'success' }];
operation.logs.push('[ok] Runner completed successfully.', `[ok] Server status endpoint confirms ${operation.shortSha}.`);
const repository = repositories.find((item) => item.fullName === operation.repository);
const targetProfile = repository?.deploymentProfiles.find((item) => item.id === operation.profileId);
if (targetProfile) {
const oldLive = targetProfile.state.liveSha;
targetProfile.state.previousSha = oldLive;
targetProfile.state.liveSha = operation.sha;
targetProfile.state.healthy = true;
targetProfile.state.checkedAt = iso();
}
}
operation.updatedAt = iso();
return operation;
}
window.forgeflow = Object.freeze({
async bootstrap() { await wait(80); snapshot(); return { appVersion: '0.4.0-demo', platform: 'win32', state: clone(state), git: { available: true, version: 'git version 2.47.3' }, diagnostics: { enabled: true, level: state.preferences.diagnosticLevel, retentionDays: state.preferences.logRetentionDays, maxFileMb: state.preferences.maxLogFileMb, directory: '<HOME>/AppData/Roaming/ForgeFlow/diagnostics', fileCount: 2, totalBytes: 18432, totalSize: '18.0 KB', latestAt: iso(-2000), lastWriteError: null } }; },
async selectDirectory() { await wait(); return 'C:\\Development'; },
async selectKeyFile() { await wait(); return 'C:\\Users\\Jens\\.ssh\\id_ed25519'; },
async setupPreflight({ baseUrl, token, roots = [] }) { await wait(240); const checks = [
{ id: 'git.available', label: 'Git command line', status: 'pass', detail: 'git version 2.47.3', required: true },
{ id: 'git.identity', label: 'Git author identity', status: 'pass', detail: 'Jens <jens@example.invalid>', required: false },
{ id: 'storage.userdata', label: 'Application data storage', status: 'pass', detail: 'ForgeFlow can write its local configuration.', required: true },
{ id: 'storage.diagnostics', label: 'Diagnostic log storage', status: 'pass', detail: 'The diagnostic directory is writable.', required: true },
{ id: 'storage.credentials', label: 'Protected credential storage', status: 'pass', detail: 'The operating system can encrypt the Gitea token at rest.', required: false },
{ id: 'workspace.roots', label: 'Development folders', status: roots.length ? 'pass' : 'warning', detail: roots.length ? `${roots.length} folder(s) selected.` : 'No development folder selected yet.', required: false },
{ id: 'gitea.connection', label: 'Gitea connection', status: baseUrl && token ? 'pass' : 'warning', detail: baseUrl && token ? 'Connection parameters are ready for validation.' : 'Enter the Gitea URL and token.', required: false }
]; return { kind: 'system', startedAt: iso(-100), completedAt: iso(), checks, summary: { counts: { pass: checks.filter(i=>i.status==='pass').length, warning: checks.filter(i=>i.status==='warning').length, fail: 0, skipped: 0 }, blocking: [], ready: true } }; },
async validateGitea({ baseUrl, token }) { await wait(320); if (!baseUrl || !token) throw new Error('Enter an instance URL and access token.'); return { baseUrl: baseUrl.replace(/\/$/, ''), user: { login: 'jens', full_name: 'Jens' }, repositoryCount: repositories.length, version: '1.26.0' }; },
async completeSetup(payload) { await wait(300); state.setupComplete = true; state.gitea = { baseUrl: payload.baseUrl, user: payload.user, hasToken: true }; state.workspaceRoots = payload.workspaceRoots; storage.set('forgeflow-demo-setup', 'true'); return { state: clone(state), tokenState: { persistent: true } }; },
async updateGitea(payload) { const validation = await this.validateGitea({ ...payload, token: payload.token || 'preserved-demo-token' }); state.gitea = { baseUrl: validation.baseUrl, user: validation.user, hasToken: true }; return { validation, tokenState: { persistent: true, preserved: !payload.token }, state: clone(state) }; },
async setWorkspaceRoots(roots) { state.workspaceRoots = [...new Set(roots)]; return clone(state); },
async setAppearance(appearance) { state.appearance = appearance; storage.set('forgeflow-theme', appearance); return clone(state); },
async setPreferences(preferences) { state.preferences = { ...state.preferences, ...preferences }; snapshot(); return clone(state); },
async setUpdatePreferences(updates) { state.updates = { ...state.updates, ...updates }; return clone(state); },
async checkForUpdates() { await wait(300); return { checkedAt: iso(), owner: state.updates.owner, repo: state.updates.repo, branch: state.updates.branch, currentVersion: '0.4.0', remoteVersion: '0.4.1', remoteSha: 'a'.repeat(40), shortSha: 'aaaaaaa', available: true, mode: 'source' }; },
async downloadUpdate() { await wait(500); return { ...(await this.checkForUpdates()), downloaded: true, archivePath: 'C:\\Temp\\ForgeFlow-0.4.1.zip', sha256: 'b'.repeat(64) }; },
async applyUpdate() { await wait(200); return { launched: true, version: '0.4.1' }; },
async saveServer(server) { const saved = { ...server, id: server.id || `server-${Date.now()}`, hasPassword: server.authType === 'password', hasPassphrase: false }; state.servers = [saved, ...state.servers.filter((item) => item.id !== saved.id)]; return { server: clone(saved), state: clone(state) }; },
async deleteServer(serverId) { state.servers = state.servers.filter((item) => item.id !== serverId); return clone(state); },
async testServer(serverId) { const server = state.servers.find((item) => item.id === serverId); server.hostFingerprint = server.hostFingerprint || 'SHA256:demo'; return { connected: true, fingerprint: server.hostFingerprint, server: clone(server), output: 'Linux\n/usr/bin/git\nDocker Compose version v2', state: clone(state) }; },
async inspectServerProject() { return { exists: true, rootGit: true, head: 'd42d4a7'.padEnd(40,'0'), branch: 'main', trackedChanges: [], composeFiles: ['docker-compose.yml'], nestedGit: ['source'], dockerfile: true }; },
async refreshRepositories() { await wait(260); return snapshot(); },
async discoverRepositories() { await wait(360); return snapshot().filter((repo) => repo.localPath).map((repo) => ({ localPath: repo.localPath, remoteUrl: repo.cloneUrl, status: repo.localStatus })); },
async favoriteRepository(fullName, favorite) { const key = fullName.toLowerCase(); state.favorites = favorite ? [...new Set([...state.favorites, key])] : state.favorites.filter((item) => item !== key); snapshot(); return clone(state); },
async linkRepository(fullName, localPath) { const repo = repositories.find((item) => item.fullName === fullName); repo.localPath = localPath; repo.linkState = 'linked'; repo.localStatus = makeStatus({ head: randomSha() }); emitRepositories(); return snapshot(); },
async unlinkRepository(fullName) { const repo = repositories.find((item) => item.fullName === fullName); repo.localPath = null; repo.localStatus = null; repo.linkState = 'remote-only'; emitRepositories(); return snapshot(); },
async repositoryStatus(localPath) { return clone(findRepo(localPath)?.localStatus); },
async repositoryDiff(localPath, filePath) { await wait(80); return diffs[filePath] || `diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n@@ -1 +1 @@\n-old\n+new`; },
async stageFiles(localPath, files) { const repo = findRepo(localPath); repo.localStatus.files.forEach((item) => { if (!files?.length || files.includes(item.path)) { item.staged = true; item.unstaged = false; item.indexCode = item.untracked ? 'A' : 'M'; item.worktreeCode = '.'; } }); recompute(repo); emitRepositories(); return clone(repo.localStatus); },
async unstageFiles(localPath, files) { const repo = findRepo(localPath); repo.localStatus.files.forEach((item) => { if (!files?.length || files.includes(item.path)) { item.staged = false; item.unstaged = true; item.indexCode = '.'; item.worktreeCode = item.untracked ? '?' : 'M'; } }); recompute(repo); emitRepositories(); return clone(repo.localStatus); },
async commit(localPath, message, files) { await wait(520); if (!message?.trim()) throw new Error('Enter a commit message.'); const repo = findRepo(localPath); repo.localStatus.files = repo.localStatus.files.filter((item) => !files?.includes(item.path)); repo.localStatus.head = randomSha(); repo.localStatus.branch.ahead += 1; recompute(repo); emitRepositories(); return { commitOutput: `[${repo.localStatus.branch.head} ${repo.localStatus.shortHead}] ${message}`, commitSha: repo.localStatus.head, status: clone(repo.localStatus) }; },
async commitAndPush(localPath, message, files) { const result = await this.commit(localPath, message, files); const repo = findRepo(localPath); await wait(240); repo.localStatus.branch.ahead = 0; recompute(repo); emitRepositories(); return { ...result, pushOutput: 'Push completed.', status: clone(repo.localStatus) }; },
async push(localPath) { await wait(360); const repo = findRepo(localPath); repo.localStatus.branch.ahead = 0; recompute(repo); emitRepositories(); return { output: 'Push completed.', status: clone(repo.localStatus) }; },
async fetch() { await wait(260); return { output: 'Fetch completed.' }; },
async pull(localPath) { await wait(380); const repo = findRepo(localPath); repo.localStatus.branch.behind = 0; recompute(repo); emitRepositories(); return { output: 'Fast-forwarded.', status: clone(repo.localStatus) }; },
async history() { await wait(100); return clone(commitHistory); },
async branches(localPath) { const repo = findRepo(localPath); if (!branchesByRepo.has(localPath)) branchesByRepo.set(localPath, [{ name: repo.localStatus.branch.head, current: true, sha: repo.localStatus.head, shortSha: repo.localStatus.shortHead, upstream: repo.localStatus.branch.upstream }, { name: 'main', current: repo.localStatus.branch.head === 'main', sha: repo.localStatus.head, shortSha: repo.localStatus.shortHead, upstream: 'origin/main' }]); return clone(branchesByRepo.get(localPath)); },
async checkoutBranch(localPath, branch) { const repo = findRepo(localPath); if (!repo.localStatus.clean) throw new Error('Commit or stash local changes before switching branches.'); const list = await this.branches(localPath); list.forEach((item) => { item.current = item.name === branch; }); branchesByRepo.set(localPath, list); repo.localStatus.branch.head = branch; repo.localStatus.branch.upstream = `origin/${branch}`; recompute(repo); emitRepositories(); return { status: clone(repo.localStatus), branches: clone(list) }; },
async createBranch(localPath, branch) { const repo = findRepo(localPath); const list = await this.branches(localPath); list.forEach((item) => { item.current = false; }); list.unshift({ name: branch, current: true, sha: repo.localStatus.head, shortSha: repo.localStatus.shortHead, upstream: null }); branchesByRepo.set(localPath, list); repo.localStatus.branch.head = branch; repo.localStatus.branch.upstream = null; recompute(repo); emitRepositories(); return { status: clone(repo.localStatus), branches: clone(list) }; },
async stash(localPath, message) { const repo = findRepo(localPath); const list = stashesByRepo.get(localPath) || []; list.unshift({ ref: `stash@{${list.length}}`, subject: message || 'ForgeFlow stash', date: iso() }); stashesByRepo.set(localPath, list); repo.localStatus.files = []; recompute(repo); emitRepositories(); return { output: 'Saved working directory and index state.', status: clone(repo.localStatus), stashes: clone(list) }; },
async stashList(localPath) { return clone(stashesByRepo.get(localPath) || []); },
async popStash(localPath, ref) { const repo = findRepo(localPath); const list = stashesByRepo.get(localPath) || []; const index = list.findIndex((item) => item.ref === ref); if (index < 0) throw new Error('Stash not found.'); list.splice(index, 1); stashesByRepo.set(localPath, list); repo.localStatus.files = [makeFile('src/restored-from-stash.ts')]; recompute(repo); emitRepositories(); return { output: 'Stash applied.', status: clone(repo.localStatus), stashes: clone(list) }; },
async cloneRepository(fullName, mode = 'default') {
await wait(620);
const repository = repositories.find((item) => item.fullName === fullName);
if (!repository) throw new Error('Repository not found.');
if (repository.localPath) throw new Error('This repository already has a linked local folder.');
const root = mode === 'custom' ? 'D:\\OtherProjects' : state.workspaceRoots[0];
if (!root) return { cancelled: true };
const target = `${root.replace(/[\\/]+$/, '')}\\${repository.name}`;
const head = randomSha();
repository.localPath = target;
repository.localStatus = makeStatus({ head, branch: repository.defaultBranch || 'main' });
repository.localStatus.root = target;
repository.localStatus.remoteUrl = repository.preferredCloneUrl || repository.cloneUrl;
repository.linkState = 'linked';
recompute(repository);
const current = snapshot();
emitRepositories();
return { target, status: clone(repository.localStatus), reused: false, repositories: current, state: clone(state) };
},
async openPath() { return true; },
async openExternal() { return true; },
async saveDeploymentProfile(fullName, input) { const repo = repositories.find((item) => item.fullName === fullName); const existing = repo.deploymentProfiles.find((item) => item.id === input.id); const saved = { ...(existing || profile(input.id || `profile-${Date.now()}`, input.name || input.environment, input.environment || 'production')), ...input, id: input.id || `profile-${Date.now()}`, provider: 'gitea-actions', inputs: existing?.inputs || {}, state: existing?.state || { liveSha: null, previousSha: null, healthy: null, healthConfigured: Boolean(input.healthcheckUrl), statusConfigured: Boolean(input.statusUrl), checkedAt: null } }; repo.deploymentProfiles = [...repo.deploymentProfiles.filter((item) => item.id !== saved.id), saved]; snapshot(); return { profile: clone(saved), state: clone(state) }; },
async deleteDeploymentProfile(fullName, profileId) { const repo = repositories.find((item) => item.fullName === fullName); repo.deploymentProfiles = repo.deploymentProfiles.filter((item) => item.id !== profileId); snapshot(); return { profiles: clone(repo.deploymentProfiles), state: clone(state) }; },
async deploymentPreflight(repository, profileId) { await wait(280); const profile = repository.deploymentProfiles.find((item) => item.id === profileId); const status = repository.localStatus; const checks = [
{ id: 'repository.linked', label: 'Local repository link', status: repository.localPath ? 'pass' : 'fail', detail: repository.localPath || 'No local folder linked.', required: true },
{ id: 'git.branch', label: 'Allowed branch', status: status?.branch.head === profile?.branch ? 'pass' : 'fail', detail: `Current: ${status?.branch.head || 'unknown'}; required: ${profile?.branch || 'unknown'}.`, required: true },
{ id: 'git.clean', label: 'Clean working tree', status: status?.clean ? 'pass' : 'fail', detail: status?.clean ? 'No uncommitted changes.' : `${status?.counts.changed || 0} changed file(s).`, required: true },
{ id: 'git.sync', label: 'Local and Gitea synchronized', status: !status?.branch.ahead && !status?.branch.behind ? 'pass' : 'fail', detail: `${status?.branch.ahead || 0} ahead, ${status?.branch.behind || 0} behind.`, required: true },
{ id: 'workflow.deploy.remote', label: 'Deploy workflow on Gitea branch', status: 'pass', detail: `${profile?.workflowFile || 'deploy.yml'} exists on ${profile?.branch || 'main'}.`, required: true },
{ id: 'gitea.actions', label: 'Gitea Actions API', status: 'pass', detail: 'The Actions runs endpoint is accessible.', required: true },
{ id: 'server.status', label: 'Server version endpoint', status: profile?.statusUrl ? 'pass' : 'warning', detail: profile?.statusUrl ? `Endpoint reachable; live ${profile.state?.liveSha?.slice(0,7) || 'unknown'}.` : 'No status URL configured.', required: false },
{ id: 'server.health', label: 'Application healthcheck', status: profile?.healthcheckUrl ? 'pass' : 'warning', detail: profile?.healthcheckUrl ? 'HTTP 200 in 42 ms.' : 'No healthcheck URL configured.', required: false }
]; const blocking = checks.filter(i=>i.required && i.status==='fail').map(i=>i.id); return { kind: 'deployment', repository: repository.fullName, profileId, startedAt: iso(-100), completedAt: iso(), checks, summary: { counts: { pass: checks.filter(i=>i.status==='pass').length, warning: checks.filter(i=>i.status==='warning').length, fail: checks.filter(i=>i.status==='fail').length, skipped: 0 }, blocking, ready: blocking.length===0 }, head: status?.head || null }; },
async deploy(repository, profileId, sha) { await wait(320); const selected = repository.deploymentProfiles.find((item) => item.id === profileId); const operation = { id: `deploy-${Date.now()}`, type: 'deployment', action: 'deploy', status: 'queued', repository: repository.fullName, profileId, profileName: selected.name, environment: selected.environment, workflowFile: selected.workflowFile, branch: selected.branch, sha, shortSha: sha.slice(0, 7), dispatchedAt: iso(), createdAt: iso(), updatedAt: iso(), demoPolls: 0, stages: [{ id: 'requested', label: 'Requested', status: 'complete' }, { id: 'verified', label: 'Verified', status: 'complete' }, { id: 'queued', label: 'Workflow queued', status: 'active' }, { id: 'runner', label: 'Runner execution', status: 'pending' }, { id: 'healthcheck', label: 'Healthcheck', status: 'pending' }, { id: 'complete', label: 'Complete', status: 'pending' }], logs: [`[info] Verified clean ${selected.branch} at ${sha}`, `[ok] Gitea accepted ${selected.workflowFile}.`] }; return updateOperation(operation); },
async rollback(repository, profileId, targetSha) { await wait(320); const selected = repository.deploymentProfiles.find((item) => item.id === profileId); const operation = { id: `rollback-${Date.now()}`, type: 'deployment', action: 'rollback', status: 'queued', repository: repository.fullName, profileId, profileName: selected.name, environment: selected.environment, workflowFile: selected.rollbackWorkflowFile, branch: selected.branch, sha: targetSha, shortSha: targetSha.slice(0, 7), dispatchedAt: iso(), createdAt: iso(), updatedAt: iso(), demoPolls: 0, stages: [{ id: 'requested', label: 'Requested', status: 'complete' }, { id: 'verified', label: 'Verified', status: 'complete' }, { id: 'queued', label: 'Workflow queued', status: 'active' }, { id: 'runner', label: 'Runner execution', status: 'pending' }, { id: 'healthcheck', label: 'Healthcheck', status: 'pending' }, { id: 'complete', label: 'Complete', status: 'pending' }], logs: [`[warning] Rollback target verified: ${targetSha}`, `[ok] Gitea accepted ${selected.rollbackWorkflowFile}.`] }; return updateOperation(operation); },
async healthcheck() { await wait(160); return { configured: true, healthy: true, status: 200, latencyMs: 42 }; },
async refreshProfileState(fullName, profileId) { await wait(240); const repo = repositories.find((item) => item.fullName === fullName) || findProfileRepo(profileId); const target = repo?.deploymentProfiles.find((item) => item.id === profileId); if (!target) throw new Error('Deployment profile not found.'); target.state = { ...target.state, checkedAt: iso(), healthy: target.state.healthy !== false, healthConfigured: Boolean(target.healthcheckUrl), statusConfigured: Boolean(target.statusUrl) }; syncState(); return clone(target.state); },
async refreshOperations(operationId = null) { await wait(300); if (operationId) { const operation = state.operations.find((item) => item.id === operationId); if (!operation) throw new Error('Operation not found.'); return updateOperation(advanceOperation(operation)); } const active = state.operations.filter((item) => !['success', 'failed', 'cancelled', 'rolled-back'].includes(item.status)).map(advanceOperation); if (active.length) emitOperations(active); state.operations = state.operations.map((item) => active.find((entry) => entry.id === item.id) || item); return clone(active); },
async getOperation(operationId) { return clone(state.operations.find((item) => item.id === operationId) || null); },
async diagnosticsStatus() { return { enabled: state.preferences.diagnosticsEnabled !== false, level: state.preferences.diagnosticLevel, retentionDays: state.preferences.logRetentionDays, maxFileMb: state.preferences.maxLogFileMb, directory: '<HOME>/AppData/Roaming/ForgeFlow/diagnostics', fileCount: 2, totalBytes: 18432, totalSize: '18.0 KB', latestAt: iso(-2000), lastWriteError: null }; },
async clearDiagnostics() { return { enabled: true, level: state.preferences.diagnosticLevel, retentionDays: state.preferences.logRetentionDays, maxFileMb: state.preferences.maxLogFileMb, directory: '<HOME>/AppData/Roaming/ForgeFlow/diagnostics', fileCount: 1, totalBytes: 256, totalSize: '256 B', latestAt: iso(), lastWriteError: null }; },
async openDiagnosticsFolder() { return true; },
async exportDiagnostics(privacyMode = 'standard') { await wait(500); return { path: `C:\Users\Jens\Downloads\ForgeFlow-Diagnostics-demo.zip`, bytes: 38221, size: '37.3 KB', sha256: 'b'.repeat(64), privacyMode, generatedAt: iso() }; },
async showDiagnosticBundle() { return true; },
async reportRendererEvent() { return true; },
onRepositoriesChanged(listener) { repositoryListeners.add(listener); return () => repositoryListeners.delete(listener); },
onOperationsChanged(listener) { operationListeners.add(listener); return () => operationListeners.delete(listener); },
onUpdatesChanged(listener) { updateListeners.add(listener); return () => updateListeners.delete(listener); },
async reset() { state.setupComplete = false; storage.set('forgeflow-demo-setup', 'false'); return clone(state); }
});
})();
+535
View File
@@ -0,0 +1,535 @@
:root {
color-scheme: dark;
--bg: #0b0e14;
--surface-0: #0f131b;
--surface-1: #151a24;
--surface-2: #1b2130;
--surface-3: #252c3a;
--surface-hover: #202838;
--line: #30394a;
--line-soft: #222a38;
--text: #e7ebf4;
--text-muted: #9aa4b6;
--text-faint: #6f7a8d;
--primary: #8fb4ff;
--primary-strong: #5b8ff9;
--primary-soft: rgba(91, 143, 249, .15);
--success: #54ddb0;
--success-soft: rgba(84, 221, 176, .12);
--warning: #f2ba63;
--warning-soft: rgba(242, 186, 99, .13);
--danger: #ff817a;
--danger-soft: rgba(255, 129, 122, .13);
--shadow: 0 18px 70px rgba(0,0,0,.32);
--radius: 7px;
--sidebar: 286px;
--action-panel: 352px;
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: "Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
html[data-theme="light"] {
color-scheme: light;
--bg: #eef2f7;
--surface-0: #f7f9fc;
--surface-1: #ffffff;
--surface-2: #f3f6fa;
--surface-3: #e8edf4;
--surface-hover: #edf2f8;
--line: #cdd5e1;
--line-soft: #e1e6ee;
--text: #172033;
--text-muted: #526078;
--text-faint: #7b879a;
--primary: #295fca;
--primary-strong: #326ee0;
--primary-soft: rgba(50, 110, 224, .10);
--success: #087a57;
--success-soft: rgba(8, 122, 87, .10);
--warning: #9b5b00;
--warning-soft: rgba(155, 91, 0, .10);
--danger: #c73737;
--danger-soft: rgba(199, 55, 55, .10);
--shadow: 0 18px 70px rgba(43, 55, 77, .15);
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body { background: var(--bg); color: var(--text); font-family: var(--font-ui); font-size: 13px; }
button, input, textarea, select { font: inherit; color: inherit; }
button { border: 0; }
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
::selection { background: rgba(91, 143, 249, .35); }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--text-faint) 38%, transparent); border: 3px solid transparent; background-clip: padding-box; border-radius: 20px; }
.boot-screen { height: 100vh; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--text-muted); }
.boot-screen strong { color: var(--text); font-size: 16px; }
.brand-mark { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 10px; background: linear-gradient(145deg, var(--primary), var(--primary-strong)); color: #07152e; font-weight: 800; font-size: 20px; box-shadow: 0 8px 30px rgba(91,143,249,.25); }
.app-shell { height: 100vh; display: grid; grid-template-rows: 48px minmax(0,1fr) 25px; background: var(--bg); }
.titlebar { display: flex; align-items: center; justify-content: space-between; padding: 0 10px 0 14px; border-bottom: 1px solid var(--line); background: var(--surface-1); -webkit-app-region: drag; }
.titlebar-left, .titlebar-right { display: flex; align-items: center; gap: 9px; min-width: 0; }
.titlebar button, .titlebar input { -webkit-app-region: no-drag; }
.wordmark { display: flex; align-items: center; gap: 9px; font-size: 15px; font-weight: 720; letter-spacing: -.02em; }
.wordmark .brand-mark { width: 25px; height: 25px; border-radius: 6px; font-size: 13px; box-shadow: none; }
.brand-logo { width: 32px; height: 25px; object-fit: contain; display: block; }
.wordmark small { color: var(--text-faint); font-size: 9px; font-weight: 620; letter-spacing: .01em; margin-left: -4px; }
.workspace-name { color: var(--text-muted); border-left: 1px solid var(--line); padding-left: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 280px; }
.connection-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 11px; font-weight: 650; padding: 5px 8px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-0); }
.connection-chip .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); }
.global-search { width: clamp(180px, 23vw, 330px); height: 30px; padding: 0 10px 0 31px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); color: var(--text); }
.search-wrap { position: relative; }
.search-wrap .icon { position: absolute; left: 9px; top: 7px; color: var(--text-faint); pointer-events: none; }
.app-body { display: grid; grid-template-columns: var(--sidebar) minmax(0,1fr); min-height: 0; }
.sidebar { min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--line); background: var(--surface-1); overflow: hidden; }
.primary-nav { padding: 10px 8px 8px; border-bottom: 1px solid var(--line-soft); }
.nav-button { width: 100%; height: 34px; display: flex; align-items: center; gap: 10px; padding: 0 10px; border-radius: 5px; background: transparent; color: var(--text-muted); cursor: pointer; text-align: left; font-weight: 560; }
.nav-button:hover { background: var(--surface-hover); color: var(--text); }
.nav-button.active { color: var(--primary); background: var(--primary-soft); }
.nav-button .nav-count { margin-left: auto; min-width: 20px; text-align: center; font-family: var(--font-mono); color: var(--text-faint); font-size: 10px; }
.sidebar-section { display: flex; align-items: center; justify-content: space-between; padding: 13px 12px 7px; color: var(--text-faint); font-size: 10px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
.sidebar-section button { background: none; color: inherit; cursor: pointer; padding: 2px; }
.repo-filter { margin: 0 9px 8px; width: calc(100% - 18px); height: 29px; border: 1px solid var(--line-soft); border-radius: 5px; background: var(--surface-0); padding: 0 9px; }
.repo-list { min-height: 0; overflow: auto; padding: 0 6px 10px; }
.repo-row { width: 100%; display: grid; grid-template-columns: 18px minmax(0,1fr) auto; gap: 8px; align-items: center; min-height: 44px; padding: 6px 8px; background: transparent; border-radius: 5px; color: var(--text-muted); cursor: pointer; text-align: left; border: 1px solid transparent; }
.repo-row:hover { background: var(--surface-hover); color: var(--text); }
.repo-row.active { background: var(--primary-soft); border-color: color-mix(in srgb, var(--primary) 26%, transparent); color: var(--text); }
.repo-row.attention .repo-icon { color: var(--warning); }
.repo-main { min-width: 0; }
.repo-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 620; }
.repo-sub { display: flex; gap: 6px; margin-top: 3px; color: var(--text-faint); font-family: var(--font-mono); font-size: 10px; overflow: hidden; }
.repo-sub span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.repo-badges { display: flex; gap: 3px; align-items: center; }
.mini-badge { min-width: 18px; height: 18px; display: inline-grid; place-items: center; padding: 0 5px; border-radius: 9px; font-family: var(--font-mono); font-size: 9px; font-weight: 700; color: var(--text-muted); background: var(--surface-3); }
.mini-badge.warning { background: var(--warning-soft); color: var(--warning); }
.mini-badge.success { background: var(--success-soft); color: var(--success); }
.mini-badge.danger { background: var(--danger-soft); color: var(--danger); }
.sidebar-footer { margin-top: auto; border-top: 1px solid var(--line-soft); padding: 10px 11px; }
.sidebar-diagnostic-state { display: grid; grid-template-columns: 9px minmax(0,1fr); gap: 9px; align-items: start; color: var(--text-muted); }
.sidebar-diagnostic-state .state-dot { margin-top: 4px; }
.sidebar-diagnostic-state strong, .sidebar-diagnostic-state span { display: block; }
.sidebar-diagnostic-state strong { color: var(--text); font-size: 10px; font-weight: 650; }
.sidebar-diagnostic-state span { margin-top: 2px; color: var(--text-faint); font-size: 9px; line-height: 1.35; }
.workspace { min-width: 0; min-height: 0; display: grid; background: var(--surface-0); }
.workspace.with-panel { grid-template-columns: minmax(0,1fr) var(--action-panel); }
.main-canvas { min-width: 0; min-height: 0; overflow: auto; }
.main-canvas.repository-canvas { overflow: hidden; height: 100%; }
.action-panel { min-width: 0; border-left: 1px solid var(--line); background: var(--surface-1); overflow: auto; }
.page { min-height: 100%; padding: 22px 24px 40px; }
.page.nopad { padding: 0; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 22px; }
.page-header h1, .repo-heading h1 { margin: 0; font-size: 20px; line-height: 1.3; letter-spacing: -.025em; }
.page-header p, .repo-heading p { margin: 5px 0 0; color: var(--text-muted); max-width: 720px; }
.eyebrow { color: var(--text-faint); font-size: 10px; font-weight: 760; letter-spacing: .09em; text-transform: uppercase; }
.button { min-height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 0 11px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-2); color: var(--text); cursor: pointer; font-weight: 620; white-space: nowrap; }
.button:hover { background: var(--surface-3); }
.button.primary { background: var(--primary-strong); border-color: var(--primary-strong); color: #fff; }
.button.primary:hover { filter: brightness(1.07); }
.button.success { background: var(--success); border-color: var(--success); color: #06251b; }
.button.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); background: var(--danger-soft); }
.button.ghost { background: transparent; border-color: transparent; color: var(--text-muted); }
.button.ghost:hover { color: var(--text); background: var(--surface-hover); }
.button.block { width: 100%; min-height: 38px; }
.button:disabled { opacity: .45; cursor: not-allowed; }
.icon-button { width: 30px; height: 30px; display: inline-grid; place-items: center; border-radius: 5px; border: 1px solid transparent; background: transparent; color: var(--text-muted); cursor: pointer; }
.icon-button:hover { color: var(--text); background: var(--surface-hover); border-color: var(--line-soft); }
.icon { width: 16px; height: 16px; display: inline-block; flex: 0 0 auto; }
.icon svg { width: 100%; height: 100%; display: block; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.summary-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--surface-1); }
.summary-card { min-height: 118px; padding: 15px; border-right: 1px solid var(--line); position: relative; }
.summary-card:last-child { border-right: 0; }
.summary-value { margin-top: 18px; font-size: 28px; font-weight: 720; letter-spacing: -.04em; }
.summary-label { color: var(--text-muted); margin-top: 2px; }
.summary-card .icon { position: absolute; top: 14px; right: 14px; color: var(--text-faint); }
.summary-card.warning .summary-value, .summary-card.warning .icon { color: var(--warning); }
.summary-card.success .summary-value, .summary-card.success .icon { color: var(--success); }
.summary-card.danger .summary-value, .summary-card.danger .icon { color: var(--danger); }
.section-block { margin-top: 24px; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
.section-heading h2 { margin: 0; font-size: 13px; letter-spacing: -.01em; }
.section-heading .meta { color: var(--text-faint); font-size: 11px; }
.action-queue { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--surface-1); }
.queue-row { display: grid; grid-template-columns: 28px minmax(160px,1.2fr) minmax(230px,2fr) auto; gap: 12px; align-items: center; min-height: 62px; padding: 10px 13px; border-bottom: 1px solid var(--line-soft); }
.queue-row:last-child { border-bottom: 0; }
.queue-row:hover { background: var(--surface-hover); }
.queue-icon { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 6px; background: var(--surface-2); color: var(--text-muted); }
.queue-icon.warning { background: var(--warning-soft); color: var(--warning); }
.queue-icon.success { background: var(--success-soft); color: var(--success); }
.queue-icon.danger { background: var(--danger-soft); color: var(--danger); }
.queue-title { font-weight: 640; }
.queue-sub { color: var(--text-faint); margin-top: 3px; font-size: 11px; font-family: var(--font-mono); }
.queue-reason { color: var(--text-muted); }
.queue-reason strong { color: var(--text); display: block; font-weight: 600; }
.two-column { display: grid; grid-template-columns: minmax(0,1.5fr) minmax(280px,1fr); gap: 16px; }
.panel { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-1); }
.panel-header { min-height: 42px; display: flex; align-items: center; justify-content: space-between; padding: 0 13px; border-bottom: 1px solid var(--line-soft); }
.panel-header h2, .panel-header h3 { font-size: 12px; margin: 0; }
.panel-body { padding: 14px; }
.activity-list { padding: 3px 0; }
.activity-item { display: grid; grid-template-columns: 10px minmax(0,1fr) auto; gap: 10px; padding: 10px 13px; align-items: start; }
.activity-dot { width: 7px; height: 7px; margin-top: 5px; border-radius: 50%; background: var(--text-faint); }
.activity-dot.success { background: var(--success); }
.activity-dot.warning { background: var(--warning); }
.activity-dot.danger { background: var(--danger); }
.activity-title { font-weight: 590; }
.activity-sub, .activity-time { color: var(--text-faint); font-size: 11px; }
.repo-workspace { height: 100%; min-height: 0; display: grid; grid-template-rows: auto auto 39px minmax(0,1fr); }
.repo-header { padding: 16px 18px 13px; background: var(--surface-1); border-bottom: 1px solid var(--line); display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.repo-heading { min-width: 0; }
.repo-heading h1 { display: flex; align-items: center; gap: 9px; font-size: 17px; }
.repo-heading p { font-family: var(--font-mono); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.repo-header-actions { display: flex; gap: 7px; }
.release-rail { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); background: var(--surface-0); border-bottom: 1px solid var(--line); }
.release-node { min-width: 0; padding: 10px 16px 11px; border-right: 1px solid var(--line-soft); position: relative; }
.release-node:last-child { border-right: 0; }
.release-node:not(:last-child)::after { content: ''; position: absolute; right: -6px; top: 19px; z-index: 2; width: 12px; height: 12px; display: grid; place-items: center; border-radius: 50%; background: var(--surface-0); color: var(--text-faint); }
.release-label { color: var(--text-faint); font-size: 9px; font-weight: 760; letter-spacing: .08em; text-transform: uppercase; }
.release-value { display: flex; align-items: center; gap: 7px; margin-top: 4px; min-width: 0; }
.release-value strong { font-family: var(--font-mono); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.release-value span { color: var(--text-muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.state-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-faint); flex: 0 0 auto; }
.state-dot.success { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); }
.state-dot.warning { background: var(--warning); box-shadow: 0 0 0 3px var(--warning-soft); }
.state-dot.danger { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
.tabs { display: flex; align-items: flex-end; gap: 2px; padding: 0 12px; border-bottom: 1px solid var(--line); background: var(--surface-1); }
.tab { height: 38px; padding: 0 12px; background: transparent; color: var(--text-muted); border-bottom: 2px solid transparent; cursor: pointer; }
.tab:hover { color: var(--text); }
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
.repo-content { min-height: 0; overflow: hidden; }
.changes-layout { height: 100%; min-height: 0; display: grid; grid-template-columns: 290px minmax(0,1fr); }
.file-panel { min-width: 0; min-height: 0; overflow: hidden; border-right: 1px solid var(--line); background: var(--surface-1); display: flex; flex-direction: column; }
.file-panel-tools { min-height: 38px; padding: 0 9px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line-soft); }
.file-list { flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; min-height: 0; padding: 5px; overscroll-behavior: contain; scrollbar-gutter: stable; }
.file-row { width: 100%; min-height: 34px; display: grid; grid-template-columns: 17px 17px minmax(0,1fr) 16px; gap: 7px; align-items: center; padding: 3px 6px; border-radius: 4px; background: transparent; color: var(--text-muted); cursor: pointer; text-align: left; }
.file-row:hover { background: var(--surface-hover); color: var(--text); }
.file-row.active { background: var(--primary-soft); color: var(--text); }
.file-row input { margin: 0; accent-color: var(--primary-strong); }
.file-path { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: var(--font-mono); font-size: 11px; }
.file-status { font-family: var(--font-mono); font-size: 10px; font-weight: 750; color: var(--warning); }
.file-status.added, .file-status.untracked { color: var(--success); }
.file-status.deleted, .file-status.conflict { color: var(--danger); }
.diff-panel { min-width: 0; min-height: 0; display: grid; grid-template-rows: 38px minmax(0,1fr); background: var(--bg); }
.diff-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 11px; border-bottom: 1px solid var(--line-soft); background: var(--surface-0); }
.diff-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); color: var(--text-muted); }
.diff-view { overflow: auto; padding: 8px 0 36px; font-family: var(--font-mono); font-size: 11px; line-height: 19px; white-space: pre; tab-size: 2; }
.diff-line { display: block; min-height: 19px; padding: 0 14px; }
.diff-line.add { background: rgba(38, 166, 115, .14); color: #8ef0c6; }
.diff-line.remove { background: rgba(229, 83, 75, .14); color: #ffaaa5; }
html[data-theme="light"] .diff-line.add { color: #006642; }
html[data-theme="light"] .diff-line.remove { color: #a31f1f; }
.diff-line.meta { color: var(--primary); }
.diff-line.hunk { color: #caa7ff; background: rgba(148, 97, 214, .08); }
.empty-state { height: 100%; min-height: 260px; display: grid; place-content: center; justify-items: center; text-align: center; padding: 30px; color: var(--text-muted); }
.empty-state .large-icon { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 12px; background: var(--surface-2); color: var(--text-faint); margin-bottom: 12px; }
.empty-state h3 { margin: 0 0 6px; color: var(--text); font-size: 14px; }
.empty-state p { margin: 0; max-width: 420px; line-height: 1.55; }
.inspector { padding: 16px; }
.inspector-header { margin-bottom: 16px; }
.inspector-header h2 { margin: 3px 0 0; font-size: 15px; }
.inspector-section { padding: 15px 0; border-top: 1px solid var(--line-soft); }
.inspector-section:first-of-type { border-top: 0; padding-top: 0; }
.inspector-label { color: var(--text-faint); font-size: 9px; font-weight: 760; letter-spacing: .09em; text-transform: uppercase; margin-bottom: 8px; }
.textarea, .input, .select { width: 100%; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-0); color: var(--text); }
.input, .select { height: 33px; padding: 0 9px; }
.textarea { min-height: 98px; resize: vertical; padding: 9px 10px; line-height: 1.45; }
.field-hint { display: flex; justify-content: space-between; gap: 10px; margin-top: 6px; color: var(--text-faint); font-size: 10px; }
.context-summary { padding: 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); }
.context-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 5px 0; color: var(--text-muted); }
.context-row strong { color: var(--text); font-family: var(--font-mono); font-size: 11px; text-align: right; overflow: hidden; text-overflow: ellipsis; }
.notice { display: flex; align-items: flex-start; gap: 9px; padding: 10px 11px; border-radius: 5px; border: 1px solid var(--line); background: var(--surface-0); color: var(--text-muted); line-height: 1.45; }
.notice.warning { border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); background: var(--warning-soft); color: var(--warning); }
.notice.danger { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); background: var(--danger-soft); color: var(--danger); }
.notice.success { border-color: color-mix(in srgb, var(--success) 35%, var(--line)); background: var(--success-soft); color: var(--success); }
.stack { display: grid; gap: 8px; }
.divider-text { display: flex; align-items: center; gap: 9px; color: var(--text-faint); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
.divider-text::before, .divider-text::after { content: ''; height: 1px; background: var(--line-soft); flex: 1; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th { height: 34px; padding: 0 11px; color: var(--text-faint); text-align: left; font-size: 9px; letter-spacing: .07em; text-transform: uppercase; border-bottom: 1px solid var(--line); }
.data-table td { padding: 10px 11px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.data-table tr:last-child td { border-bottom: 0; }
.data-table tbody tr:hover { background: var(--surface-hover); }
.mono { font-family: var(--font-mono); }
.status-pill { display: inline-flex; align-items: center; gap: 6px; min-height: 22px; padding: 0 8px; border-radius: 11px; background: var(--surface-3); color: var(--text-muted); font-size: 10px; font-weight: 680; }
.status-pill.success { background: var(--success-soft); color: var(--success); }
.status-pill.warning { background: var(--warning-soft); color: var(--warning); }
.status-pill.danger { background: var(--danger-soft); color: var(--danger); }
.settings-layout { display: grid; grid-template-columns: 210px minmax(0,1fr); min-height: 100%; }
.settings-nav { padding: 15px 8px; border-right: 1px solid var(--line); background: var(--surface-1); }
.settings-content { padding: 25px 30px 60px; overflow: auto; }
.settings-group { max-width: 860px; margin-bottom: 28px; }
.settings-group > h2 { font-size: 12px; margin: 0 0 12px; }
.form-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 14px; }
.field { display: grid; gap: 6px; }
.field label { color: var(--text-muted); font-size: 11px; font-weight: 650; }
.field.full { grid-column: 1 / -1; }
.connection-card { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 13px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-1); }
.root-row { display: flex; align-items: center; gap: 8px; }
.root-row .input { flex: 1; font-family: var(--font-mono); font-size: 11px; }
.deploy-card-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(310px,1fr)); gap: 12px; }
.deploy-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-1); overflow: hidden; }
.deploy-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 14px; border-bottom: 1px solid var(--line-soft); }
.deploy-card-body { padding: 13px 14px; }
.deploy-metadata { display: grid; grid-template-columns: 1fr auto; gap: 7px 15px; color: var(--text-muted); }
.deploy-metadata strong { font-family: var(--font-mono); font-size: 11px; color: var(--text); }
.deployment-view { min-height: 100%; padding: 22px; }
.pipeline-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-1); overflow: hidden; }
.pipeline-head { display: flex; justify-content: space-between; gap: 15px; padding: 16px; border-bottom: 1px solid var(--line-soft); }
.pipeline-head h2 { margin: 0; font-size: 16px; }
.pipeline-head p { margin: 5px 0 0; color: var(--text-muted); font-family: var(--font-mono); font-size: 11px; }
.pipeline-stages { display: grid; grid-template-columns: repeat(5,1fr); padding: 25px 20px 20px; }
.pipeline-stage { position: relative; display: grid; justify-items: center; gap: 8px; color: var(--text-faint); text-align: center; font-size: 10px; font-weight: 680; }
.pipeline-stage::before { content: ''; position: absolute; height: 2px; left: -50%; right: 50%; top: 15px; background: var(--line); }
.pipeline-stage:first-child::before { display: none; }
.pipeline-stage.complete::before, .pipeline-stage.active::before { background: var(--success); }
.stage-icon { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 9px; background: var(--surface-3); border: 1px solid var(--line); z-index: 1; }
.pipeline-stage.complete { color: var(--success); }
.pipeline-stage.complete .stage-icon { background: var(--success); border-color: var(--success); color: #06251b; }
.pipeline-stage.active { color: var(--primary); }
.pipeline-stage.active .stage-icon { background: var(--primary-strong); border-color: var(--primary); color: #fff; box-shadow: 0 0 0 5px var(--primary-soft); }
.log-view { margin-top: 14px; border: 1px solid var(--line); border-radius: var(--radius); background: #080b11; overflow: hidden; }
.log-toolbar { height: 35px; display: flex; align-items: center; justify-content: space-between; padding: 0 11px; border-bottom: 1px solid #262d3b; color: #9aa4b6; }
.log-lines { min-height: 290px; max-height: 500px; overflow: auto; padding: 12px 14px; font: 11px/19px var(--font-mono); color: #c3cada; white-space: pre-wrap; }
.log-lines .ok { color: #54ddb0; }
.log-lines .warn { color: #f2ba63; }
.log-lines .err { color: #ff817a; }
.setup-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 25px; background: rgba(4,7,12,.75); backdrop-filter: blur(8px); }
.setup-window { width: min(920px,96vw); min-height: 590px; max-height: 92vh; display: grid; grid-template-columns: 230px minmax(0,1fr); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; background: var(--surface-1); box-shadow: var(--shadow); }
.setup-sidebar { padding: 24px 17px; border-right: 1px solid var(--line); background: var(--surface-0); }
.setup-sidebar h2 { margin: 16px 0 5px; font-size: 18px; }
.setup-sidebar p { margin: 0 0 22px; color: var(--text-muted); line-height: 1.5; }
.setup-step { min-height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 9px; border-radius: 5px; color: var(--text-faint); margin-bottom: 3px; }
.setup-step .step-number { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 50%; border: 1px solid var(--line); font-family: var(--font-mono); font-size: 9px; }
.setup-step.active { background: var(--primary-soft); color: var(--primary); }
.setup-step.complete { color: var(--success); }
.setup-content { min-width: 0; padding: 34px 38px 24px; display: grid; grid-template-rows: minmax(0,1fr) auto; overflow: auto; }
.setup-body h1 { margin: 0; font-size: 22px; }
.setup-body > p { color: var(--text-muted); line-height: 1.55; max-width: 620px; }
.setup-actions { display: flex; justify-content: space-between; gap: 10px; padding-top: 22px; border-top: 1px solid var(--line-soft); }
.discovery-list { border: 1px solid var(--line); border-radius: 6px; overflow: hidden; max-height: 260px; overflow-y: auto; }
.discovery-row { min-height: 45px; display: grid; grid-template-columns: 20px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 7px 10px; border-bottom: 1px solid var(--line-soft); }
.discovery-row:last-child { border-bottom: 0; }
.discovery-row strong { display: block; font-size: 12px; }
.discovery-row span { display: block; margin-top: 2px; color: var(--text-faint); font: 10px var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: rgba(4,7,12,.7); backdrop-filter: blur(4px); }
.modal { width: min(520px,94vw); border: 1px solid var(--line); border-radius: 9px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 17px; border-bottom: 1px solid var(--line); }
.modal-header h2 { margin: 0; font-size: 15px; }
.modal-body { padding: 17px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 17px; border-top: 1px solid var(--line-soft); background: var(--surface-0); }
.statusbar { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 0 9px; border-top: 1px solid var(--line); background: var(--surface-1); color: var(--text-faint); font-size: 10px; font-weight: 620; }
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 13px; min-width: 0; }
.statusbar-item { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.statusbar .success { color: var(--success); }
.statusbar .warning { color: var(--warning); }
.statusbar .danger { color: var(--danger); }
.toast-root { position: fixed; right: 15px; bottom: 38px; z-index: 90; display: grid; gap: 8px; pointer-events: none; }
.toast { width: min(380px,calc(100vw - 30px)); display: grid; grid-template-columns: 20px minmax(0,1fr); gap: 9px; padding: 11px 12px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-2); box-shadow: var(--shadow); pointer-events: auto; animation: toast-in .18s ease-out; }
.toast.success { border-color: color-mix(in srgb, var(--success) 35%, var(--line)); }
.toast.error { border-color: color-mix(in srgb, var(--danger) 40%, var(--line)); }
.toast strong { display: block; margin-bottom: 2px; }
.toast span { color: var(--text-muted); line-height: 1.4; }
@keyframes toast-in { from { transform: translateY(8px); opacity: 0; } }
.loading-overlay { position: absolute; inset: 0; z-index: 20; display: grid; place-items: center; background: color-mix(in srgb, var(--surface-0) 72%, transparent); backdrop-filter: blur(2px); }
.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--primary); border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 1250px) {
:root { --sidebar: 250px; --action-panel: 320px; }
.summary-grid { grid-template-columns: repeat(2,1fr); }
.summary-card:nth-child(2) { border-right: 0; }
.summary-card:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
.queue-row { grid-template-columns: 28px minmax(130px,1fr) minmax(180px,1.5fr) auto; }
}
@media (max-width: 1120px) {
:root { --sidebar: 220px; --action-panel: 300px; }
.global-search { width: 190px; }
.changes-layout { grid-template-columns: 245px minmax(0,1fr); }
.page { padding-left: 18px; padding-right: 18px; }
}
/* ForgeFlow v0.2 interaction and workflow refinements */
.command-trigger { height: 30px; display: inline-flex; align-items: center; gap: 7px; padding: 0 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); color: var(--text-muted); cursor: pointer; -webkit-app-region: no-drag; }
.command-trigger:hover { color: var(--text); background: var(--surface-hover); }
kbd { min-width: 24px; padding: 2px 5px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; background: var(--surface-2); color: var(--text-faint); font: 9px var(--font-mono); text-align: center; }
.repo-group-label { padding: 10px 8px 4px; color: var(--text-faint); font-size: 9px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.favorite-button { width: 25px; height: 25px; display: inline-grid; place-items: center; margin-left: -5px; border-radius: 5px; background: transparent; color: var(--text-faint); cursor: pointer; }
.favorite-button:hover, .favorite-button.active { color: var(--warning); background: var(--warning-soft); }
.favorite-button.active svg { fill: currentColor; }
.repo-row .repo-icon .icon { width: 15px; height: 15px; }
.repo-row .repo-icon:has(svg path[d^="m12 3"]) { color: var(--warning); }
.button.small { min-height: 25px; padding: 0 7px; font-size: 10px; }
.stack.horizontal.compact { gap: 5px; }
.empty-state.compact { min-height: 105px; padding: 16px; }
.empty-state.full { height: 100%; min-height: 320px; }
.readiness-list { display: grid; gap: 3px; }
.readiness-row { display: grid; grid-template-columns: 12px minmax(0,1fr); gap: 9px; align-items: start; padding: 9px 4px; border-bottom: 1px solid var(--line-soft); }
.readiness-row:last-child { border-bottom: 0; }
.readiness-row strong { display: block; font-size: 11px; }
.readiness-row span:not(.state-dot) { display: block; margin-top: 2px; color: var(--text-faint); font-size: 10px; }
.action-panel-head { padding: 18px 17px 14px; border-bottom: 1px solid var(--line); }
.action-panel-head h2 { margin: 5px 0 5px; font-size: 16px; }
.action-panel-head p { margin: 0; color: var(--text-muted); line-height: 1.45; }
.action-panel-body { padding: 15px 16px; }
.action-panel-footer { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin: auto 10px 10px; padding-top: 10px; border-top: 1px solid var(--line-soft); }
.action-panel { display: flex; flex-direction: column; }
.panel-callout { display: grid; gap: 9px; }
.panel-callout h2 { margin: 3px 0 0; font-size: 15px; }
.panel-callout p { margin: 0 0 5px; color: var(--text-muted); line-height: 1.5; }
.callout-icon { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 9px; background: var(--primary-soft); color: var(--primary); }
.callout-icon.success { background: var(--success-soft); color: var(--success); }
.callout-icon.warning { background: var(--warning-soft); color: var(--warning); }
.callout-icon.danger { background: var(--danger-soft); color: var(--danger); }
.field-hint { display: flex; justify-content: space-between; gap: 10px; margin-top: 6px; color: var(--text-faint); font-size: 10px; }
.field-label { display: block; margin-top: 8px; color: var(--text-muted); font-size: 10px; font-weight: 650; }
.textarea { width: 100%; min-height: 92px; resize: vertical; padding: 9px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); line-height: 1.45; }
.input, .select { width: 100%; min-height: 34px; padding: 0 10px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-0); color: var(--text); }
.select { cursor: pointer; }
.stack { display: grid; gap: 8px; }
.stack.horizontal { display: flex; flex-wrap: wrap; align-items: center; }
.deploy-proof { display: grid; grid-template-columns: 1fr auto; gap: 7px 12px; margin: 7px 0 5px; padding: 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); }
.deploy-proof span { color: var(--text-faint); }
.deploy-proof strong { font: 11px var(--font-mono); }
.tab-page { min-height: 100%; padding: 18px 19px 42px; overflow: auto; }
.git-tools-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 15px; align-items: start; }
.inline-form { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; margin-bottom: 13px; }
.tool-list { display: grid; border: 1px solid var(--line-soft); border-radius: 6px; overflow: hidden; }
.tool-row { min-height: 51px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border-bottom: 1px solid var(--line-soft); }
.tool-row:last-child { border-bottom: 0; }
.tool-row:hover { background: var(--surface-hover); }
.tool-row strong { display: block; }
.tool-row span { display: block; margin-top: 3px; color: var(--text-faint); font: 10px var(--font-mono); }
.deploy-card-header h3 { margin: 4px 0 3px; font-size: 14px; }
.deploy-card-header p { margin: 0; color: var(--text-faint); font: 10px var(--font-mono); }
.card-actions { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line-soft); }
.compact-card .deploy-card-body { padding-bottom: 11px; }
.check-field { display: flex; align-items: center; gap: 9px; padding: 8px 0; color: var(--text-muted); }
.check-field input { accent-color: var(--primary-strong); }
.wide-modal { width: min(650px,95vw); }
.modal-spacer { flex: 1; }
.confirm-hero { display: flex; align-items: center; gap: 12px; padding: 12px; border: 1px solid color-mix(in srgb, var(--success) 30%, var(--line)); border-radius: 7px; background: var(--success-soft); }
.confirm-hero.danger { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); background: var(--danger-soft); }
.confirm-hero > .icon { width: 27px; height: 27px; color: var(--success); }
.confirm-hero.danger > .icon { color: var(--danger); }
.confirm-hero strong, .confirm-hero span { display: block; }
.confirm-hero span { margin-top: 3px; color: var(--text-muted); }
.confirm-grid { display: grid; grid-template-columns: 120px minmax(0,1fr); gap: 9px 14px; margin-top: 16px; }
.confirm-grid span { color: var(--text-faint); }
.confirm-grid strong { overflow-wrap: anywhere; }
.notice.danger { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); background: var(--danger-soft); color: var(--danger); }
.notice.warning { border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); background: var(--warning-soft); color: var(--warning); }
.danger-zone { padding: 15px; border: 1px solid color-mix(in srgb, var(--danger) 25%, var(--line)); border-radius: 7px; background: var(--danger-soft); }
.danger-zone p { color: var(--text-muted); line-height: 1.5; }
.pipeline-stages { grid-template-columns: repeat(6,1fr); }
.pipeline-stage.failed { color: var(--danger); }
.pipeline-stage.failed .stage-icon { background: var(--danger); border-color: var(--danger); color: #fff; }
.pipeline-stage.cancelled, .pipeline-stage.skipped { color: var(--text-faint); }
.log-lines { word-break: break-word; }
.palette-backdrop { align-items: start; padding-top: 12vh; }
.command-palette { width: min(650px,94vw); border: 1px solid var(--line); border-radius: 10px; background: var(--surface-1); box-shadow: var(--shadow); overflow: hidden; }
.palette-search { height: 54px; display: grid; grid-template-columns: 20px minmax(0,1fr); gap: 9px; align-items: center; padding: 0 15px; border-bottom: 1px solid var(--line); }
.palette-search input { height: 100%; border: 0; outline: 0; background: transparent; font-size: 15px; }
.palette-list { max-height: 380px; overflow: auto; padding: 6px; }
.palette-row { width: 100%; min-height: 52px; display: grid; grid-template-columns: 22px minmax(0,1fr) auto; gap: 10px; align-items: center; padding: 7px 10px; border-radius: 6px; background: transparent; color: var(--text-muted); text-align: left; cursor: pointer; }
.palette-row:hover, .palette-row:focus-visible { background: var(--primary-soft); color: var(--text); }
.palette-row:disabled { opacity: .4; cursor: not-allowed; }
.palette-row strong, .palette-row small { display: block; }
.palette-row small { margin-top: 3px; color: var(--text-faint); }
.palette-footer { padding: 8px 13px; border-top: 1px solid var(--line-soft); color: var(--text-faint); font-size: 10px; }
.discovery-progress { min-height: 260px; display: grid; place-content: center; justify-items: center; gap: 13px; color: var(--text-muted); }
@media (max-width: 1240px) {
.command-trigger span { display: none; }
.command-trigger kbd { display: none; }
.git-tools-grid { grid-template-columns: 1fr; }
.pipeline-stages { grid-template-columns: repeat(3,1fr); row-gap: 18px; }
.pipeline-stage:nth-child(4)::before { display: none; }
}
/* v0.3 diagnostics and preflight */
.diagnostics-page { display: grid; gap: 18px; padding: 20px 22px 44px; overflow: auto; }
.diagnostic-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; align-items: start; }
.diagnostic-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--line-soft); border-radius: 7px; overflow: hidden; }
.diagnostic-metrics > div { min-height: 68px; display: grid; align-content: center; gap: 5px; padding: 11px 12px; border-right: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); background: var(--surface-0); }
.diagnostic-metrics > div:nth-child(2n) { border-right: 0; }
.diagnostic-metrics > div:nth-last-child(-n + 2) { border-bottom: 0; }
.diagnostic-metrics span { color: var(--text-faint); font-size: 9px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
.diagnostic-metrics strong { overflow-wrap: anywhere; font-size: 12px; }
.preflight-summary { min-height: 48px; display: flex; flex-wrap: wrap; align-items: center; gap: 9px; padding: 11px 13px; border-bottom: 1px solid var(--line-soft); color: var(--text-muted); }
.preflight-list { display: grid; }
.preflight-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 10px; align-items: start; padding: 12px 13px; border-bottom: 1px solid var(--line-soft); }
.preflight-row:last-child { border-bottom: 0; }
.preflight-row > div { min-width: 0; }
.preflight-row strong { display: block; margin-top: 1px; font-size: 11px; }
.preflight-row span:not(.preflight-state):not(.status-pill), .preflight-row small { display: block; margin-top: 3px; color: var(--text-faint); line-height: 1.4; overflow-wrap: anywhere; }
.preflight-row small { color: var(--text-muted); }
.preflight-state { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 50%; background: var(--surface-2); color: var(--text-faint); }
.preflight-state .icon { width: 14px; height: 14px; }
.preflight-state.success { background: var(--success-soft); color: var(--success); }
.preflight-state.warning { background: var(--warning-soft); color: var(--warning); }
.preflight-state.danger { background: var(--danger-soft); color: var(--danger); }
.setup-preflight { max-height: 330px; margin-top: 17px; border: 1px solid var(--line); border-radius: 8px; overflow: auto; background: var(--surface-0); }
.setup-summary { display: grid; margin: 18px 0; border: 1px solid var(--line); border-radius: 8px; padding: 0 12px; background: var(--surface-0); }
.setup-support-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.diagnostics-page .panel-body { padding: 14px; }
.diagnostics-page .section-block { margin: 0; }
@media (max-width: 1100px) {
.diagnostic-grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.diagnostics-page { padding: 15px; }
.diagnostic-metrics { grid-template-columns: 1fr; }
.diagnostic-metrics > div { border-right: 0; }
.diagnostic-metrics > div:nth-last-child(-n + 2) { border-bottom: 1px solid var(--line-soft); }
.diagnostic-metrics > div:last-child { border-bottom: 0; }
.preflight-row { grid-template-columns: 28px minmax(0, 1fr); }
.preflight-row > .status-pill { grid-column: 2; justify-self: start; }
}
.required-mark { color: var(--warning); font-size: 10px; font-weight: 700; text-transform: uppercase; margin-left: 5px; }
.commit-readiness { margin-top: 9px; padding: 8px 9px; display: flex; align-items: flex-start; gap: 7px; border: 1px solid var(--line); border-radius: 5px; color: var(--text-muted); background: var(--surface-0); font-size: 11px; line-height: 1.4; }
.commit-readiness.blocked { border-color: color-mix(in srgb, var(--warning) 35%, var(--line)); background: var(--warning-soft); color: var(--text); }
.commit-readiness.ready { border-color: color-mix(in srgb, var(--success) 35%, var(--line)); background: var(--success-soft); color: var(--text); }
.commit-readiness .icon { flex: 0 0 auto; margin-top: 1px; }
.stage-note { margin-top: 10px; color: var(--text-faint); font-size: 10px; line-height: 1.45; }
.update-card { margin-top: 12px; padding: 12px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-0); }
.update-card.available { border-color: color-mix(in srgb, var(--primary) 45%, var(--line)); background: var(--primary-soft); }
.update-card > div:first-child { display: flex; align-items: center; gap: 10px; min-width: 0; }
.update-card > div:first-child > span { min-width: 0; display: grid; gap: 2px; }
.update-card small, .server-card small { color: var(--text-faint); }
.server-list { display: grid; gap: 8px; }
.server-card { padding: 11px 12px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-0); }
.server-card-main { min-width: 0; display: flex; align-items: center; gap: 10px; }
.server-card-main > div { min-width: 0; display: grid; gap: 2px; }
.server-card-main span { color: var(--text-muted); font-family: var(--font-mono); font-size: 10px; overflow: hidden; text-overflow: ellipsis; }
.provider-choice { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin-bottom: 14px; }
.provider-note { padding: 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-0); color: var(--text-muted); font-size: 11px; }
.server-inspection { margin-top: 12px; }
@media (max-width: 1240px) {
.wordmark small { display: none; }
.update-card, .server-card { align-items: flex-start; flex-direction: column; }
}
.setup-brand-logo { width: 150px; height: auto; display: block; margin-bottom: 12px; }
+24
View File
@@ -0,0 +1,24 @@
'use strict';
const path = require('node:path');
function cloneDirectoryName(remoteUrl) {
const raw = String(remoteUrl || '').trim().replace(/[?#].*$/, '').replace(/[\\/]+$/, '');
const segment = raw.split(/[\\/:]/).filter(Boolean).at(-1) || 'repository';
return segment.replace(/\.git$/i, '').replace(/[^a-zA-Z0-9._-]/g, '-') || 'repository';
}
function resolveCloneTarget(workspaceRoot, remoteUrl) {
const root = path.resolve(String(workspaceRoot || ''));
if (!String(workspaceRoot || '').trim()) throw new Error('A project root is required.');
const target = path.resolve(root, cloneDirectoryName(remoteUrl));
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
const normalizedRoot = normalize(root);
const normalizedTarget = normalize(target);
if (normalizedTarget === normalizedRoot || !normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`)) {
throw new Error('Clone target escapes the selected project root.');
}
return { root, target, directoryName: path.basename(target) };
}
module.exports = { cloneDirectoryName, resolveCloneTarget };
+93
View File
@@ -0,0 +1,93 @@
'use strict';
function parseBranchHeader(line, branch) {
if (line.startsWith('# branch.oid ')) branch.oid = line.slice(13).trim();
if (line.startsWith('# branch.head ')) branch.head = line.slice(14).trim();
if (line.startsWith('# branch.upstream ')) branch.upstream = line.slice(18).trim();
if (line.startsWith('# branch.ab ')) {
const match = line.match(/\+(\d+)\s+-(\d+)/);
if (match) {
branch.ahead = Number(match[1]);
branch.behind = Number(match[2]);
}
}
}
function statusLabel(code) {
const map = {
M: 'modified', A: 'added', D: 'deleted', R: 'renamed', C: 'copied',
U: 'conflict', T: 'type-changed', '?': 'untracked', '!': 'ignored', '.': 'clean', ' ': 'clean'
};
return map[code] || 'changed';
}
function buildFile(path, originalPath, xy, kind) {
const indexCode = xy?.[0] || '.';
const worktreeCode = xy?.[1] || '.';
const conflict = kind === 'u' || indexCode === 'U' || worktreeCode === 'U';
const untracked = kind === '?';
return {
path,
originalPath: originalPath || null,
indexCode,
worktreeCode,
staged: !untracked && indexCode !== '.' && indexCode !== ' ',
unstaged: untracked || (worktreeCode !== '.' && worktreeCode !== ' '),
untracked,
conflict,
status: conflict ? 'conflict' : untracked ? 'untracked' : statusLabel(worktreeCode !== '.' ? worktreeCode : indexCode)
};
}
function parsePorcelainV2(output) {
const branch = { oid: null, head: null, upstream: null, ahead: 0, behind: 0 };
const files = [];
const entries = String(output || '').split('\0');
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
if (!entry) continue;
if (entry.startsWith('# ')) {
parseBranchHeader(entry, branch);
continue;
}
const kind = entry[0];
if (kind === '1') {
const parts = entry.split(' ');
const xy = parts[1];
const path = parts.slice(8).join(' ');
files.push(buildFile(path, null, xy, kind));
} else if (kind === '2') {
const parts = entry.split(' ');
const xy = parts[1];
const path = parts.slice(9).join(' ');
const originalPath = entries[index + 1] || null;
index += 1;
files.push(buildFile(path, originalPath, xy, kind));
} else if (kind === 'u') {
const parts = entry.split(' ');
const xy = parts[1];
const path = parts.slice(10).join(' ');
files.push(buildFile(path, null, xy, kind));
} else if (kind === '?' || kind === '!') {
const path = entry.slice(2);
if (kind === '?') files.push(buildFile(path, null, '??', kind));
}
}
return {
branch,
files,
counts: {
changed: files.length,
staged: files.filter((file) => file.staged).length,
unstaged: files.filter((file) => file.unstaged).length,
conflicts: files.filter((file) => file.conflict).length,
untracked: files.filter((file) => file.untracked).length
},
clean: files.length === 0
};
}
module.exports = { parsePorcelainV2, statusLabel };
+39
View File
@@ -0,0 +1,39 @@
'use strict';
function stripGitSuffix(value) {
return value.replace(/\.git$/i, '').replace(/^\/+|\/+$/g, '');
}
function normalizeRemoteUrl(remote) {
const raw = String(remote || '').trim();
if (!raw) return null;
const scp = raw.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
if (scp && !raw.includes('://') && !/^[a-zA-Z]:[\\/]/.test(raw)) {
return { host: scp[1].toLowerCase(), path: stripGitSuffix(scp[2]).toLowerCase() };
}
try {
const url = new URL(raw);
return { host: url.hostname.toLowerCase(), path: stripGitSuffix(url.pathname).toLowerCase() };
} catch {
return { host: '', path: stripGitSuffix(raw.replace(/\\/g, '/')).toLowerCase() };
}
}
function repositoryKey(repository) {
return String(repository?.full_name || `${repository?.owner?.login || repository?.owner || ''}/${repository?.name || ''}`)
.replace(/^\/+|\/+$/g, '')
.toLowerCase();
}
function matchRemoteToRepository(remote, repositories) {
const normalized = normalizeRemoteUrl(remote);
if (!normalized) return null;
return repositories.find((repository) => {
const key = repositoryKey(repository);
return normalized.path === key || normalized.path.endsWith(`/${key}`);
}) || null;
}
module.exports = { normalizeRemoteUrl, repositoryKey, matchRemoteToRepository };
+32
View File
@@ -0,0 +1,32 @@
'use strict';
function parseVersion(value) {
const match = String(value || '').trim().replace(/^v/i, '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
if (!match) return null;
return {
raw: String(value).trim(),
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] || ''
};
}
function compareVersions(leftValue, rightValue) {
const left = parseVersion(leftValue);
const right = parseVersion(rightValue);
if (!left || !right) throw new Error('Both versions must use semantic versioning (for example 1.2.3).');
for (const key of ['major', 'minor', 'patch']) {
if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1;
}
if (left.prerelease === right.prerelease) return 0;
if (!left.prerelease) return 1;
if (!right.prerelease) return -1;
return left.prerelease.localeCompare(right.prerelease, undefined, { numeric: true }) > 0 ? 1 : -1;
}
function isNewerVersion(candidate, current) {
return compareVersions(candidate, current) > 0;
}
module.exports = { parseVersion, compareVersions, isNewerVersion };
+36
View File
@@ -0,0 +1,36 @@
const path = require('node:path');
function normalizeRelativePosixPath(value) {
if (typeof value !== 'string' || !value.trim()) {
throw new Error('Shell validation path must be a non-empty string.');
}
const trimmed = value.trim();
if (path.isAbsolute(trimmed) || /^[A-Za-z]:[\\/]/.test(trimmed)) {
throw new Error('Shell validation path must be relative to the project root.');
}
const normalized = trimmed.replace(/\\/g, '/').replace(/^\.\//, '');
if (normalized.split('/').some((segment) => segment === '..')) {
throw new Error('Shell validation path may not escape the project root.');
}
return normalized;
}
function bashSyntaxCheckInvocation(root, scriptPath = 'examples/server/forgeflow-deploy') {
if (typeof root !== 'string' || !root.trim()) {
throw new Error('Project root is required for shell validation.');
}
return {
command: 'bash',
args: ['-n', normalizeRelativePosixPath(scriptPath)],
options: {
cwd: root,
encoding: 'utf8',
windowsHide: true
}
};
}
module.exports = {
bashSyntaxCheckInvocation,
normalizeRelativePosixPath
};
+42
View File
@@ -0,0 +1,42 @@
'use strict';
function uniqueCandidates(candidates) {
const seen = new Set();
return candidates.filter((candidate) => {
const key = JSON.stringify([candidate.file, candidate.args]);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function npmProbeCandidates(options = {}) {
const platform = options.platform || process.platform;
const env = options.env || process.env;
const execPath = options.execPath || process.execPath;
const candidates = [];
// npm exposes the exact CLI entry point while running an npm script. Calling
// it through Node avoids Windows' inability to exec .cmd shims directly.
if (env.npm_execpath) {
candidates.push({
file: env.npm_node_execpath || execPath,
args: [env.npm_execpath, '--version'],
source: 'npm_execpath'
});
}
if (platform === 'win32') {
candidates.push({
file: env.ComSpec || env.COMSPEC || 'cmd.exe',
args: ['/d', '/s', '/c', 'npm --version'],
source: 'windows-command-shim'
});
} else {
candidates.push({ file: 'npm', args: ['--version'], source: 'path' });
}
return uniqueCandidates(candidates);
}
module.exports = { npmProbeCandidates };
+125
View File
@@ -0,0 +1,125 @@
'use strict';
const path = require('node:path');
function normalizeBaseUrl(value) {
const raw = String(value || '').trim().replace(/\/+$/, '');
if (!raw) throw new Error('Gitea URL is required.');
const url = new URL(raw);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported.');
if (url.username || url.password) throw new Error('Do not include credentials in the Gitea URL.');
url.hash = '';
url.search = '';
return url.toString().replace(/\/$/, '');
}
function assertSafeRepositoryPath(value) {
if (!value || typeof value !== 'string') throw new Error('A repository path is required.');
if (value.includes('\0')) throw new Error('Invalid repository path.');
return path.resolve(value);
}
function assertRepositoryRelativePath(value) {
const filePath = String(value || '');
if (!filePath || filePath.includes('\0')) throw new Error('A repository-relative file path is required.');
const normalized = filePath.replace(/\\/g, '/');
if (path.posix.isAbsolute(normalized) || /^[a-zA-Z]:\//.test(normalized)) throw new Error('Absolute file paths are not allowed.');
if (normalized.split('/').some((segment) => segment === '..')) throw new Error('File path may not escape the repository.');
return normalized.replace(/^\.\//, '');
}
function assertRepositoryRelativePaths(values) {
if (!Array.isArray(values)) return [];
return [...new Set(values.filter(Boolean).map(assertRepositoryRelativePath))];
}
function assertCommitMessage(value) {
const message = String(value || '').trim();
if (!message) throw new Error('Enter a commit message.');
if (message.length > 5000) throw new Error('Commit message is too long.');
if (message.includes('\0')) throw new Error('Commit message contains an invalid character.');
return message;
}
function assertFullCommitSha(value) {
const sha = String(value || '').trim();
if (!/^[a-f0-9]{40,64}$/i.test(sha)) throw new Error('A full commit SHA is required.');
return sha.toLowerCase();
}
function assertWorkflowFile(value) {
const workflow = assertRepositoryRelativePath(String(value || '').trim());
if (!/^[a-zA-Z0-9._/-]+\.ya?ml$/i.test(workflow)) throw new Error('Workflow file must be a YAML filename.');
return workflow;
}
function assertBranchName(value) {
const branch = String(value || '').trim();
if (!branch) throw new Error('A branch name is required.');
if (branch.length > 255) throw new Error('The branch name is too long.');
if (branch === '@' || branch.startsWith('-') || branch.startsWith('/') || branch.endsWith('/') || branch.endsWith('.')) throw new Error('The branch name is invalid.');
if (branch.includes('..') || branch.includes('@{') || branch.includes('//') || /[\x00-\x20\x7f~^:?*\[\\]/.test(branch)) throw new Error('The branch name is invalid.');
if (branch.split('/').some((part) => !part || part.startsWith('.') || part.endsWith('.lock'))) throw new Error('The branch name is invalid.');
return branch;
}
function assertEnvironmentName(value) {
const environment = String(value || '').trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(environment)) {
throw new Error('Environment must use 1-64 lowercase letters, numbers, dots, dashes or underscores.');
}
return environment;
}
function assertWorkflowFileName(value) {
const workflow = assertWorkflowFile(value);
if (workflow.includes('/')) throw new Error('Workflow must be a filename from .gitea/workflows, not a path.');
return workflow;
}
function assertDeploymentRequest(profile, sha) {
if (!profile) throw new Error('Deployment profile not found.');
assertFullCommitSha(sha);
assertWorkflowFileName(profile.workflowFile);
assertBranchName(profile.branch);
assertEnvironmentName(profile.environment);
assertHttpUrl(profile.statusUrl, { label: 'Application status URL' });
}
function assertHttpUrl(value, { optional = false, label = 'URL' } = {}) {
const raw = String(value || '').trim();
if (!raw && optional) return '';
if (!raw) throw new Error(`${label} is required.`);
const url = new URL(raw);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error(`${label} must use HTTP or HTTPS.`);
if (url.username || url.password) throw new Error(`${label} may not contain credentials.`);
return url.toString();
}
function assertCloneRemote(value) {
const remote = String(value || '').trim();
if (!remote || remote.includes('\0')) throw new Error('Clone URL is required.');
const scp = /^(?:[^@\s]+@)?[^:\s]+:[^\s]+$/.test(remote) && !remote.includes('://');
if (scp) return remote;
const url = new URL(remote);
if (!['http:', 'https:', 'ssh:', 'git:'].includes(url.protocol)) throw new Error('Unsupported Git remote protocol.');
if (url.password) throw new Error('Do not include a password in the clone URL.');
return remote;
}
module.exports = {
normalizeBaseUrl,
assertSafeRepositoryPath,
assertRepositoryRelativePath,
assertRepositoryRelativePaths,
assertCommitMessage,
assertFullCommitSha,
assertWorkflowFile,
assertWorkflowFileName,
assertBranchName,
assertEnvironmentName,
assertDeploymentRequest,
assertHttpUrl,
assertCloneRemote
};
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const zlib = require('node:zlib');
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
table[n] = c >>> 0;
}
return table;
})();
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function dosDateTime(date = new Date()) {
const year = Math.max(1980, date.getFullYear());
const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
const day = date.getDate();
const month = date.getMonth() + 1;
const dosDate = ((year - 1980) << 9) | (month << 5) | day;
return { time, date: dosDate };
}
function createZip(entries) {
const localParts = [];
const centralParts = [];
let offset = 0;
const stamp = dosDateTime();
for (const entry of entries) {
const name = Buffer.from(String(entry.name).replace(/\\/g, '/').replace(/^\/+/, ''), 'utf8');
const source = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(String(entry.data ?? ''), 'utf8');
const compressed = zlib.deflateRawSync(source, { level: 6 });
const checksum = crc32(source);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x0800, 6);
local.writeUInt16LE(8, 8);
local.writeUInt16LE(stamp.time, 10);
local.writeUInt16LE(stamp.date, 12);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(compressed.length, 18);
local.writeUInt32LE(source.length, 22);
local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28);
localParts.push(local, name, compressed);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0x0800, 8);
central.writeUInt16LE(8, 10);
central.writeUInt16LE(stamp.time, 12);
central.writeUInt16LE(stamp.date, 14);
central.writeUInt32LE(checksum, 16);
central.writeUInt32LE(compressed.length, 20);
central.writeUInt32LE(source.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE(0, 38);
central.writeUInt32LE(offset, 42);
centralParts.push(central, name);
offset += local.length + name.length + compressed.length;
}
const centralDirectory = Buffer.concat(centralParts);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralDirectory.length, 12);
end.writeUInt32LE(offset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...localParts, centralDirectory, end]);
}
module.exports = { createZip, crc32 };