Update
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { safeStorage } = require('electron');
|
||||
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
schemaVersion: 5,
|
||||
setupComplete: false,
|
||||
appearance: 'dark',
|
||||
gitea: { baseUrl: '', user: null, encryptedToken: null },
|
||||
workspaceRoots: [],
|
||||
repositoryMappings: {},
|
||||
deploymentProfiles: {},
|
||||
deploymentStates: {},
|
||||
favorites: [],
|
||||
updates: {
|
||||
owner: 'Jens',
|
||||
repo: 'ForgeFlow',
|
||||
branch: 'main',
|
||||
autoCheck: true,
|
||||
lastCheckedAt: null
|
||||
},
|
||||
servers: [],
|
||||
preferences: {
|
||||
autoRefresh: true,
|
||||
repositoryPollSeconds: 4,
|
||||
operationPollSeconds: 5,
|
||||
fetchIntervalMinutes: 10,
|
||||
preferredCloneProtocol: 'https',
|
||||
diagnosticsEnabled: true,
|
||||
diagnosticLevel: 'info',
|
||||
logRetentionDays: 14,
|
||||
maxLogFileMb: 8
|
||||
},
|
||||
operations: []
|
||||
};
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return [...new Set((Array.isArray(values) ? values : []).map((value) => String(value || '').trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
class ConfigStore {
|
||||
constructor(userDataPath) {
|
||||
this.filePath = path.join(userDataPath, 'forgeflow-config.json');
|
||||
this.sessionToken = null;
|
||||
this.data = structuredClone(DEFAULT_CONFIG);
|
||||
}
|
||||
|
||||
migrate(parsed) {
|
||||
const source = parsed && typeof parsed === 'object' ? parsed : {};
|
||||
return {
|
||||
...structuredClone(DEFAULT_CONFIG),
|
||||
...source,
|
||||
schemaVersion: DEFAULT_CONFIG.schemaVersion,
|
||||
gitea: { ...DEFAULT_CONFIG.gitea, ...(source.gitea || {}) },
|
||||
workspaceRoots: uniqueStrings(source.workspaceRoots),
|
||||
repositoryMappings: source.repositoryMappings && typeof source.repositoryMappings === 'object' ? source.repositoryMappings : {},
|
||||
deploymentProfiles: source.deploymentProfiles && typeof source.deploymentProfiles === 'object' ? source.deploymentProfiles : {},
|
||||
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
|
||||
favorites: uniqueStrings(source.favorites).map((item) => item.toLowerCase()),
|
||||
updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) },
|
||||
servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [],
|
||||
preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) },
|
||||
operations: Array.isArray(source.operations) ? source.operations.slice(0, 250).map((operation) => { const { runnerLog, ...safeOperation } = operation || {}; return safeOperation; }) : []
|
||||
};
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, 'utf8');
|
||||
this.data = this.migrate(JSON.parse(raw));
|
||||
await this.save();
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
await this.save();
|
||||
}
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
async save() {
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fs.writeFile(temporary, JSON.stringify(this.data, null, 2), { mode: 0o600 });
|
||||
await fs.rename(temporary, this.filePath);
|
||||
try { await fs.chmod(this.filePath, 0o600); } catch {}
|
||||
}
|
||||
|
||||
setToken(token, { preserveExisting = false } = {}) {
|
||||
const value = String(token || '').trim();
|
||||
if (!value && preserveExisting && this.getToken()) return { persistent: Boolean(this.data.gitea.encryptedToken), preserved: true };
|
||||
if (!value) {
|
||||
this.data.gitea.encryptedToken = null;
|
||||
this.sessionToken = null;
|
||||
return { persistent: true, preserved: false };
|
||||
}
|
||||
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
this.data.gitea.encryptedToken = safeStorage.encryptString(value).toString('base64');
|
||||
this.sessionToken = null;
|
||||
return { persistent: true, preserved: false };
|
||||
}
|
||||
|
||||
this.data.gitea.encryptedToken = null;
|
||||
this.sessionToken = value;
|
||||
return { persistent: false, preserved: false };
|
||||
}
|
||||
|
||||
getToken() {
|
||||
if (this.sessionToken) return this.sessionToken;
|
||||
if (!this.data.gitea.encryptedToken) return '';
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(this.data.gitea.encryptedToken, 'base64'));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
encryptSecret(value) {
|
||||
const text = String(value || '');
|
||||
if (!text) return null;
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
const error = new Error('Secure credential storage is unavailable. ForgeFlow will not persist server passwords or key passphrases.');
|
||||
error.code = 'SECURE_STORAGE_UNAVAILABLE';
|
||||
throw error;
|
||||
}
|
||||
return safeStorage.encryptString(text).toString('base64');
|
||||
}
|
||||
|
||||
decryptSecret(value) {
|
||||
if (!value) return '';
|
||||
try { return safeStorage.decryptString(Buffer.from(value, 'base64')); }
|
||||
catch { return ''; }
|
||||
}
|
||||
|
||||
normalizeServer(server, existing = null) {
|
||||
const source = server || {};
|
||||
const name = String(source.name || existing?.name || 'Unraid').trim().slice(0, 100);
|
||||
const host = String(source.host || existing?.host || '').trim();
|
||||
if (!host || /[\s/@]/.test(host)) throw new Error('Enter a valid SSH hostname or IP address.');
|
||||
const port = Math.min(Math.max(Number(source.port || existing?.port || 22), 1), 65535);
|
||||
const username = String(source.username || existing?.username || '').trim();
|
||||
if (!username || /[\s@]/.test(username)) throw new Error('Enter a valid SSH username.');
|
||||
const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'privateKey');
|
||||
const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, '');
|
||||
if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.');
|
||||
const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
|
||||
const hostFingerprint = String(source.hostFingerprint || existing?.hostFingerprint || '').trim();
|
||||
return {
|
||||
id: source.id || existing?.id || crypto.randomUUID(),
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
authType,
|
||||
basePath,
|
||||
privateKeyPath,
|
||||
hostFingerprint,
|
||||
encryptedPassword: existing?.encryptedPassword || null,
|
||||
encryptedPassphrase: existing?.encryptedPassphrase || null,
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async saveServer(server, secrets = {}) {
|
||||
const existing = this.data.servers.find((item) => item.id === server?.id) || null;
|
||||
const normalized = this.normalizeServer(server, existing);
|
||||
if (Object.prototype.hasOwnProperty.call(secrets, 'password') && String(secrets.password || '')) {
|
||||
normalized.encryptedPassword = this.encryptSecret(secrets.password);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(secrets, 'passphrase') && String(secrets.passphrase || '')) {
|
||||
normalized.encryptedPassphrase = this.encryptSecret(secrets.passphrase);
|
||||
}
|
||||
if (normalized.authType === 'password') {
|
||||
normalized.privateKeyPath = '';
|
||||
normalized.encryptedPassphrase = null;
|
||||
} else {
|
||||
normalized.encryptedPassword = null;
|
||||
}
|
||||
if (normalized.authType === 'password' && !normalized.encryptedPassword) throw new Error('A password is required for password authentication.');
|
||||
if (normalized.authType === 'privateKey' && !normalized.privateKeyPath) throw new Error('Select a private key file.');
|
||||
this.data.servers = [normalized, ...this.data.servers.filter((item) => item.id !== normalized.id)];
|
||||
await this.save();
|
||||
return this.getPublicServer(normalized);
|
||||
}
|
||||
|
||||
async deleteServer(serverId) {
|
||||
this.data.servers = this.data.servers.filter((item) => item.id !== serverId);
|
||||
for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) {
|
||||
this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId);
|
||||
if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key];
|
||||
}
|
||||
await this.save();
|
||||
}
|
||||
|
||||
getServer(serverId) {
|
||||
return this.data.servers.find((item) => item.id === serverId) || null;
|
||||
}
|
||||
|
||||
getServerCredentials(serverId) {
|
||||
const server = this.getServer(serverId);
|
||||
if (!server) throw new Error('The configured server no longer exists.');
|
||||
return {
|
||||
password: this.decryptSecret(server.encryptedPassword),
|
||||
passphrase: this.decryptSecret(server.encryptedPassphrase)
|
||||
};
|
||||
}
|
||||
|
||||
getPublicServer(server) {
|
||||
if (!server) return null;
|
||||
const { encryptedPassword, encryptedPassphrase, ...publicServer } = server;
|
||||
return {
|
||||
...structuredClone(publicServer),
|
||||
hasPassword: Boolean(encryptedPassword),
|
||||
hasPassphrase: Boolean(encryptedPassphrase)
|
||||
};
|
||||
}
|
||||
|
||||
async setUpdatePreferences(updates) {
|
||||
const next = { ...this.data.updates, ...(updates || {}) };
|
||||
next.owner = String(next.owner || 'Jens').trim().slice(0, 100);
|
||||
next.repo = String(next.repo || 'ForgeFlow').trim().slice(0, 100);
|
||||
next.branch = assertBranchName(next.branch || 'main');
|
||||
next.autoCheck = next.autoCheck !== false;
|
||||
this.data.updates = next;
|
||||
await this.save();
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
async patch(patch) {
|
||||
this.data = this.migrate({ ...this.data, ...patch });
|
||||
await this.save();
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
async updateGitea({ baseUrl, token, user }) {
|
||||
const tokenState = this.setToken(token, { preserveExisting: true });
|
||||
this.data.gitea = {
|
||||
...this.data.gitea,
|
||||
baseUrl,
|
||||
user: user || this.data.gitea.user,
|
||||
encryptedToken: this.data.gitea.encryptedToken
|
||||
};
|
||||
await this.save();
|
||||
return tokenState;
|
||||
}
|
||||
|
||||
async completeSetup({ baseUrl, token, user, workspaceRoots }) {
|
||||
const tokenState = this.setToken(token);
|
||||
this.data.setupComplete = true;
|
||||
this.data.gitea = { baseUrl, user, encryptedToken: this.data.gitea.encryptedToken };
|
||||
this.data.workspaceRoots = uniqueStrings(workspaceRoots);
|
||||
await this.save();
|
||||
return { state: this.getPublicState(), tokenState };
|
||||
}
|
||||
|
||||
async saveMapping(fullName, localPath) {
|
||||
this.data.repositoryMappings[String(fullName).toLowerCase()] = localPath;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async removeMapping(fullName) {
|
||||
delete this.data.repositoryMappings[String(fullName).toLowerCase()];
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async setFavorite(fullName, favorite) {
|
||||
const key = String(fullName || '').toLowerCase();
|
||||
const favorites = new Set(this.data.favorites || []);
|
||||
if (favorite) favorites.add(key); else favorites.delete(key);
|
||||
this.data.favorites = [...favorites];
|
||||
await this.save();
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
normalizeDeploymentProfile(profile) {
|
||||
const environment = assertEnvironmentName(profile.environment || 'production');
|
||||
const provider = ['gitea-actions', 'ssh-unraid'].includes(profile.provider) ? profile.provider : 'gitea-actions';
|
||||
const healthcheckUrl = assertHttpUrl(profile.healthcheckUrl, { optional: true, label: 'Healthcheck URL' });
|
||||
const common = {
|
||||
id: profile.id || crypto.randomUUID(),
|
||||
name: String(profile.name || environment || 'Production').trim().slice(0, 100),
|
||||
environment,
|
||||
provider,
|
||||
branch: assertBranchName(profile.branch || 'main'),
|
||||
healthcheckUrl,
|
||||
confirmationRequired: profile.confirmationRequired !== false,
|
||||
inputs: {}
|
||||
};
|
||||
if (provider === 'ssh-unraid') {
|
||||
const remoteFolder = String(profile.remoteFolder || '').trim();
|
||||
if (!remoteFolder || !/^[a-zA-Z0-9._-]+$/.test(remoteFolder)) throw new Error('Remote folder must contain only letters, numbers, dots, underscores and dashes.');
|
||||
const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml']));
|
||||
return {
|
||||
...common,
|
||||
serverId: String(profile.serverId || '').trim(),
|
||||
remoteFolder,
|
||||
composeFile: String(profile.composeFile || 'docker-compose.yml').trim(),
|
||||
composeService: String(profile.composeService || '').trim(),
|
||||
cloneUrl: profile.cloneUrl ? assertCloneRemote(profile.cloneUrl) : '',
|
||||
alignRemote: profile.alignRemote === true,
|
||||
hostPort: profile.hostPort ? Math.min(Math.max(Number(profile.hostPort), 1), 65535) : null,
|
||||
containerPort: profile.containerPort ? Math.min(Math.max(Number(profile.containerPort), 1), 65535) : null,
|
||||
webUiUrl: assertHttpUrl(profile.webUiUrl, { optional: true, label: 'Web UI URL' }),
|
||||
iconUrl: assertHttpUrl(profile.iconUrl, { optional: true, label: 'Icon URL' }),
|
||||
preservePaths,
|
||||
generatedCompose: profile.generatedCompose === true
|
||||
};
|
||||
}
|
||||
const statusUrl = assertHttpUrl(profile.statusUrl, { label: 'Application status URL' });
|
||||
return {
|
||||
...common,
|
||||
workflowFile: assertWorkflowFileName(profile.workflowFile || 'deploy.yml'),
|
||||
rollbackWorkflowFile: profile.rollbackWorkflowFile ? assertWorkflowFileName(profile.rollbackWorkflowFile) : '',
|
||||
statusUrl
|
||||
};
|
||||
}
|
||||
|
||||
async saveDeploymentProfile(fullName, profile) {
|
||||
const key = String(fullName).toLowerCase();
|
||||
const profiles = Array.isArray(this.data.deploymentProfiles[key]) ? this.data.deploymentProfiles[key] : [];
|
||||
const normalized = this.normalizeDeploymentProfile(profile || {});
|
||||
const next = profiles.filter((item) => item.id !== normalized.id);
|
||||
next.push(normalized);
|
||||
this.data.deploymentProfiles[key] = next;
|
||||
await this.save();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async deleteDeploymentProfile(fullName, profileId) {
|
||||
const key = String(fullName || '').toLowerCase();
|
||||
const profiles = Array.isArray(this.data.deploymentProfiles[key]) ? this.data.deploymentProfiles[key] : [];
|
||||
const next = profiles.filter((item) => item.id !== profileId);
|
||||
if (next.length) this.data.deploymentProfiles[key] = next;
|
||||
else delete this.data.deploymentProfiles[key];
|
||||
delete this.data.deploymentStates[profileId];
|
||||
await this.save();
|
||||
return next;
|
||||
}
|
||||
|
||||
getDeploymentProfiles(fullName) {
|
||||
return structuredClone(this.data.deploymentProfiles[String(fullName || '').toLowerCase()] || []);
|
||||
}
|
||||
|
||||
getDeploymentProfile(fullName, profileId) {
|
||||
return this.getDeploymentProfiles(fullName).find((item) => item.id === profileId) || null;
|
||||
}
|
||||
|
||||
async saveDeploymentState(profileId, state) {
|
||||
this.data.deploymentStates[profileId] = {
|
||||
...(this.data.deploymentStates[profileId] || {}),
|
||||
...state,
|
||||
checkedAt: state.checkedAt || new Date().toISOString()
|
||||
};
|
||||
await this.save();
|
||||
return structuredClone(this.data.deploymentStates[profileId]);
|
||||
}
|
||||
|
||||
getDeploymentState(profileId) {
|
||||
return structuredClone(this.data.deploymentStates[profileId] || null);
|
||||
}
|
||||
|
||||
async addOperation(operation) {
|
||||
const existing = this.data.operations.find((item) => item.id === operation.id);
|
||||
const normalized = {
|
||||
id: operation.id || crypto.randomUUID(),
|
||||
createdAt: existing?.createdAt || operation.createdAt || new Date().toISOString(),
|
||||
...existing,
|
||||
...operation,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
this.data.operations = [normalized, ...this.data.operations.filter((item) => item.id !== normalized.id)].slice(0, 250);
|
||||
await this.save();
|
||||
return structuredClone(normalized);
|
||||
}
|
||||
|
||||
getOperation(operationId) {
|
||||
return structuredClone(this.data.operations.find((item) => item.id === operationId) || null);
|
||||
}
|
||||
|
||||
async setPreferences(preferences) {
|
||||
const next = { ...this.data.preferences, ...(preferences || {}) };
|
||||
next.repositoryPollSeconds = Math.min(Math.max(Number(next.repositoryPollSeconds) || 4, 2), 60);
|
||||
next.operationPollSeconds = Math.min(Math.max(Number(next.operationPollSeconds) || 5, 3), 120);
|
||||
next.fetchIntervalMinutes = Math.min(Math.max(Number(next.fetchIntervalMinutes) || 10, 0), 240);
|
||||
next.autoRefresh = next.autoRefresh !== false;
|
||||
next.preferredCloneProtocol = ['https', 'ssh'].includes(next.preferredCloneProtocol) ? next.preferredCloneProtocol : 'https';
|
||||
next.diagnosticsEnabled = next.diagnosticsEnabled !== false;
|
||||
next.diagnosticLevel = ['debug', 'info', 'warning', 'error'].includes(next.diagnosticLevel) ? next.diagnosticLevel : 'info';
|
||||
next.logRetentionDays = Math.min(Math.max(Number(next.logRetentionDays) || 14, 1), 90);
|
||||
next.maxLogFileMb = Math.min(Math.max(Number(next.maxLogFileMb) || 8, 1), 50);
|
||||
this.data.preferences = next;
|
||||
await this.save();
|
||||
return this.getPublicState();
|
||||
}
|
||||
|
||||
getPublicState() {
|
||||
return {
|
||||
schemaVersion: this.data.schemaVersion,
|
||||
setupComplete: this.data.setupComplete,
|
||||
appearance: this.data.appearance,
|
||||
gitea: {
|
||||
baseUrl: this.data.gitea.baseUrl,
|
||||
user: this.data.gitea.user,
|
||||
hasToken: Boolean(this.getToken())
|
||||
},
|
||||
workspaceRoots: [...this.data.workspaceRoots],
|
||||
repositoryMappings: { ...this.data.repositoryMappings },
|
||||
deploymentProfiles: structuredClone(this.data.deploymentProfiles),
|
||||
deploymentStates: structuredClone(this.data.deploymentStates),
|
||||
favorites: [...this.data.favorites],
|
||||
updates: { ...this.data.updates },
|
||||
servers: this.data.servers.map((server) => this.getPublicServer(server)),
|
||||
preferences: { ...this.data.preferences },
|
||||
operations: structuredClone(this.data.operations)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ConfigStore, DEFAULT_CONFIG };
|
||||
@@ -0,0 +1,424 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const { assertDeploymentRequest, assertFullCommitSha, assertHttpUrl } = require('../shared/validation.cjs');
|
||||
const { redactSecrets } = require('./log-redaction.cjs');
|
||||
|
||||
const TERMINAL_STATUSES = new Set(['success', 'failed', 'cancelled', 'rolled-back']);
|
||||
|
||||
function applicationVerificationFailure(operation, state) {
|
||||
if (!state?.statusConfigured) return { stage: 'version-verification', message: 'No server status endpoint is configured.' };
|
||||
if (!state.statusReachable) return { stage: 'version-verification', message: state.error || 'The server status endpoint is not reachable.' };
|
||||
if (!state.statusRepository) return { stage: 'version-verification', message: 'The server status endpoint did not identify its repository.' };
|
||||
if (state.statusRepository !== operation.repository) return { stage: 'version-verification', message: `The status endpoint belongs to ${state.statusRepository}, not ${operation.repository}.` };
|
||||
if (!state.statusEnvironment) return { stage: 'version-verification', message: 'The server status endpoint did not identify its environment.' };
|
||||
if (state.statusEnvironment !== operation.environment) return { stage: 'version-verification', message: `The status endpoint belongs to ${state.statusEnvironment}, not ${operation.environment}.` };
|
||||
if (!state.liveSha) return { stage: 'version-verification', message: 'The server status endpoint did not return a valid full commit SHA.' };
|
||||
if (state.liveSha !== operation.sha) return { stage: 'version-verification', message: `Server reports ${state.liveSha.slice(0, 7)} instead of ${operation.shortSha}.` };
|
||||
if (!state.requestedSha) return { stage: 'version-verification', message: 'The server status endpoint did not return the requested commit SHA.' };
|
||||
if (state.requestedSha !== operation.sha) return { stage: 'version-verification', message: 'The server status document was created for a different requested commit.' };
|
||||
if (!state.requestId) return { stage: 'version-verification', message: 'The server status endpoint did not return the deployment request ID.' };
|
||||
if (state.requestId !== operation.id) return { stage: 'version-verification', message: 'The server status belongs to a different deployment request.' };
|
||||
if (state.lastExitCode !== 0) return { stage: 'server-command', message: `The server deployment command reported exit code ${state.lastExitCode ?? 'unknown'}.` };
|
||||
if (state.healthy !== true) return { stage: 'healthcheck', message: state.error || `The server did not report a healthy application state (${state.healthStatus || 'unknown'}).` };
|
||||
return null;
|
||||
}
|
||||
|
||||
function terminalRunConclusion(run) {
|
||||
const value = String(run?.conclusion || run?.status || '').toLowerCase();
|
||||
if (['success'].includes(value)) return 'success';
|
||||
if (['failure', 'failed', 'timed_out', 'startup_failure'].includes(value)) return 'failed';
|
||||
if (['cancelled', 'canceled', 'skipped'].includes(value)) return 'cancelled';
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRunningStatus(value) {
|
||||
return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase());
|
||||
}
|
||||
|
||||
function isQueuedStatus(value) {
|
||||
return ['pending', 'queued', 'waiting', 'blocked', 'requested'].includes(String(value || '').toLowerCase());
|
||||
}
|
||||
|
||||
class DeploymentService {
|
||||
constructor(store, giteaService, gitService, diagnostics = null) {
|
||||
this.store = store;
|
||||
this.gitea = giteaService;
|
||||
this.git = gitService;
|
||||
this.diagnostics = diagnostics;
|
||||
this.refreshLocks = new Set();
|
||||
}
|
||||
|
||||
splitRepository(fullName) {
|
||||
const [owner, repo, ...unexpected] = String(fullName || '').split('/');
|
||||
if (!owner || !repo || unexpected.length) throw new Error('Invalid Gitea repository identity.');
|
||||
return { owner, repo };
|
||||
}
|
||||
|
||||
makeStages() {
|
||||
return [
|
||||
{ id: 'requested', label: 'Requested', status: 'complete' },
|
||||
{ id: 'verified', label: 'Verified', status: 'complete' },
|
||||
{ id: 'queued', label: 'Workflow queued', status: 'active' },
|
||||
{ id: 'runner', label: 'Runner execution', status: 'pending' },
|
||||
{ id: 'healthcheck', label: 'Healthcheck', status: 'pending' },
|
||||
{ id: 'complete', label: 'Complete', status: 'pending' }
|
||||
];
|
||||
}
|
||||
|
||||
setStage(operation, id, status) {
|
||||
const stage = operation.stages?.find((item) => item.id === id);
|
||||
if (stage) stage.status = status;
|
||||
}
|
||||
|
||||
appendLog(operation, line) {
|
||||
const clean = redactSecrets(line, [this.store.getToken()]);
|
||||
operation.logs = Array.isArray(operation.logs) ? operation.logs : [];
|
||||
if (operation.logs.at(-1) !== clean) operation.logs.push(clean);
|
||||
operation.logs = operation.logs.slice(-1000);
|
||||
}
|
||||
|
||||
async captureBaselineRunIds(owner, repo, branch, operation) {
|
||||
try {
|
||||
const result = await this.gitea.listWorkflowRuns({ owner, repo, branch, limit: 50 });
|
||||
const ids = (result.runs || []).map((run) => run.id).filter((id) => id !== null && id !== undefined).map(String);
|
||||
operation.baselineRunIds = [...new Set(ids)].slice(0, 100);
|
||||
this.appendLog(operation, `[info] Captured ${operation.baselineRunIds.length} existing Actions run identifier(s) before dispatch.`);
|
||||
} catch (error) {
|
||||
operation.baselineRunIds = [];
|
||||
this.appendLog(operation, `[warning] Could not capture the pre-dispatch run baseline: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async validateDeploy(repository, profile, sha) {
|
||||
assertDeploymentRequest(profile, sha);
|
||||
const localStatus = await this.git.status(repository.localPath);
|
||||
if (localStatus.head !== sha) throw new Error('The selected commit no longer matches the local repository. Refresh before deploying.');
|
||||
if (localStatus.branch.head !== profile.branch) throw new Error(`This profile only allows deployments from ${profile.branch}.`);
|
||||
if (localStatus.counts.changed) throw new Error('Commit local changes before deploying.');
|
||||
if (localStatus.branch.ahead) throw new Error('Push all local commits before deploying.');
|
||||
if (localStatus.branch.behind) throw new Error('Synchronize with Gitea before deploying.');
|
||||
if (!localStatus.branch.upstream) throw new Error('Publish this branch to Gitea before deploying.');
|
||||
await this.git.verifyCommitOnRemoteBranch(repository.localPath, sha, profile.branch);
|
||||
return localStatus;
|
||||
}
|
||||
|
||||
async deploy({ repository, profileId, sha }) {
|
||||
if (!repository?.fullName || !repository?.localPath) throw new Error('A linked local repository is required for deployment.');
|
||||
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
|
||||
const fullSha = assertFullCommitSha(sha);
|
||||
await this.validateDeploy(repository, profile, fullSha);
|
||||
const { owner, repo } = this.splitRepository(repository.fullName);
|
||||
|
||||
const operation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'deployment',
|
||||
action: 'deploy',
|
||||
status: 'requested',
|
||||
repository: repository.fullName,
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
environment: profile.environment,
|
||||
workflowFile: profile.workflowFile,
|
||||
branch: profile.branch,
|
||||
sha: fullSha,
|
||||
shortSha: fullSha.slice(0, 7),
|
||||
dispatchedAt: new Date().toISOString(),
|
||||
stages: this.makeStages(),
|
||||
logs: [
|
||||
`[info] Verified clean ${profile.branch} at ${fullSha}`,
|
||||
`[info] Dispatching ${profile.workflowFile} for ${repository.fullName}`
|
||||
]
|
||||
};
|
||||
await this.captureBaselineRunIds(owner, repo, profile.branch, operation);
|
||||
await this.store.addOperation(operation);
|
||||
await this.diagnostics?.info('deployment.dispatch.requested', { operationId: operation.id, repository: operation.repository, profileId, environment: operation.environment, branch: operation.branch, sha: operation.sha, workflowFile: operation.workflowFile });
|
||||
|
||||
try {
|
||||
await this.gitea.dispatchWorkflow({
|
||||
owner,
|
||||
repo,
|
||||
workflowFile: profile.workflowFile,
|
||||
ref: profile.branch,
|
||||
inputs: { environment: profile.environment, commit_sha: fullSha, request_id: operation.id }
|
||||
});
|
||||
operation.status = 'queued';
|
||||
this.appendLog(operation, '[ok] Gitea accepted the workflow dispatch request.');
|
||||
this.appendLog(operation, '[info] Resolving the corresponding Actions run…');
|
||||
const saved = await this.store.addOperation(operation);
|
||||
await this.diagnostics?.info('deployment.dispatch.accepted', { operationId: operation.id, repository: operation.repository, status: operation.status });
|
||||
return saved;
|
||||
} catch (error) {
|
||||
operation.status = 'failed';
|
||||
this.setStage(operation, 'queued', 'failed');
|
||||
operation.failure = { stage: 'dispatch', message: error.message };
|
||||
this.appendLog(operation, `[error] ${error.message}`);
|
||||
await this.store.addOperation(operation);
|
||||
await this.diagnostics?.error('deployment.dispatch.failed', { operationId: operation.id, repository: operation.repository, message: error.message, code: error.code, status: error.status });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async rollback({ repository, profileId, targetSha }) {
|
||||
if (!repository?.fullName || !repository?.localPath) throw new Error('A linked local repository is required for rollback.');
|
||||
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
|
||||
if (!profile) throw new Error('Deployment profile not found.');
|
||||
if (!profile.rollbackWorkflowFile) throw new Error('No rollback workflow is configured for this profile.');
|
||||
const fullSha = assertFullCommitSha(targetSha);
|
||||
assertDeploymentRequest({ ...profile, workflowFile: profile.rollbackWorkflowFile }, fullSha);
|
||||
const state = await this.refreshProfileState(repository.fullName, profileId);
|
||||
if (!state.statusReachable) throw new Error(state.error || 'The server status endpoint must be reachable before rollback.');
|
||||
if (state.statusRepository !== repository.fullName || state.statusEnvironment !== profile.environment) throw new Error('The status endpoint does not match this repository and environment.');
|
||||
if (!state.previousSha) throw new Error('The server status endpoint does not report a previous version.');
|
||||
if (state.previousSha !== fullSha) throw new Error('The requested rollback SHA is no longer the previous server version. Refresh the environment state.');
|
||||
if (state.liveSha === fullSha) throw new Error('The requested rollback version is already live.');
|
||||
await this.git.verifyCommitOnRemoteBranch(repository.localPath, fullSha, profile.branch);
|
||||
const { owner, repo } = this.splitRepository(repository.fullName);
|
||||
|
||||
const operation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'deployment',
|
||||
action: 'rollback',
|
||||
status: 'requested',
|
||||
repository: repository.fullName,
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
environment: profile.environment,
|
||||
workflowFile: profile.rollbackWorkflowFile,
|
||||
branch: profile.branch,
|
||||
sha: fullSha,
|
||||
shortSha: fullSha.slice(0, 7),
|
||||
dispatchedAt: new Date().toISOString(),
|
||||
stages: this.makeStages(),
|
||||
logs: [
|
||||
`[warning] Rollback target verified on origin/${profile.branch}: ${fullSha}`,
|
||||
`[info] Dispatching ${profile.rollbackWorkflowFile}`
|
||||
]
|
||||
};
|
||||
await this.captureBaselineRunIds(owner, repo, profile.branch, operation);
|
||||
await this.store.addOperation(operation);
|
||||
await this.diagnostics?.info('deployment.rollback.requested', { operationId: operation.id, repository: operation.repository, profileId, environment: operation.environment, branch: operation.branch, sha: operation.sha, workflowFile: operation.workflowFile });
|
||||
|
||||
try {
|
||||
await this.gitea.dispatchWorkflow({
|
||||
owner,
|
||||
repo,
|
||||
workflowFile: profile.rollbackWorkflowFile,
|
||||
ref: profile.branch,
|
||||
inputs: { environment: profile.environment, target_sha: fullSha, request_id: operation.id }
|
||||
});
|
||||
operation.status = 'queued';
|
||||
this.appendLog(operation, '[ok] Gitea accepted the rollback request.');
|
||||
const saved = await this.store.addOperation(operation);
|
||||
await this.diagnostics?.info('deployment.rollback.accepted', { operationId: operation.id, repository: operation.repository });
|
||||
return saved;
|
||||
} catch (error) {
|
||||
operation.status = 'failed';
|
||||
this.setStage(operation, 'queued', 'failed');
|
||||
operation.failure = { stage: 'dispatch', message: error.message };
|
||||
this.appendLog(operation, `[error] ${error.message}`);
|
||||
await this.store.addOperation(operation);
|
||||
await this.diagnostics?.error('deployment.rollback.failed', { operationId: operation.id, repository: operation.repository, message: error.message, code: error.code, status: error.status });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
mapJobsToStages(operation, jobs) {
|
||||
operation.jobs = jobs;
|
||||
if (!jobs.length) return;
|
||||
const running = jobs.some((job) => isRunningStatus(job.status));
|
||||
const failed = jobs.some((job) => terminalRunConclusion(job) === 'failed');
|
||||
const allDone = jobs.every((job) => terminalRunConclusion(job));
|
||||
this.setStage(operation, 'queued', 'complete');
|
||||
this.setStage(operation, 'runner', failed ? 'failed' : allDone ? 'complete' : running ? 'active' : 'pending');
|
||||
}
|
||||
|
||||
async refreshOperation(operationId) {
|
||||
if (this.refreshLocks.has(operationId)) return this.store.getOperation(operationId);
|
||||
const operation = this.store.getOperation(operationId);
|
||||
if (!operation || operation.type !== 'deployment') throw new Error('Deployment operation not found.');
|
||||
if (TERMINAL_STATUSES.has(operation.status)) return operation;
|
||||
|
||||
this.refreshLocks.add(operationId);
|
||||
try {
|
||||
const profile = this.store.getDeploymentProfile(operation.repository, operation.profileId);
|
||||
if (!profile) throw new Error('The deployment profile used by this operation no longer exists.');
|
||||
const { owner, repo } = this.splitRepository(operation.repository);
|
||||
const found = await this.gitea.findWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
sha: operation.sha,
|
||||
branch: operation.branch,
|
||||
workflowFile: operation.workflowFile,
|
||||
dispatchedAt: operation.dispatchedAt || operation.createdAt,
|
||||
excludeRunIds: operation.baselineRunIds || []
|
||||
});
|
||||
|
||||
if (!found.run) {
|
||||
operation.status = 'queued';
|
||||
this.setStage(operation, 'queued', 'active');
|
||||
this.appendLog(operation, '[info] Workflow is queued or not visible through the Actions API yet.');
|
||||
return await this.store.addOperation(operation);
|
||||
}
|
||||
|
||||
operation.run = { ...found.run, source: found.source };
|
||||
operation.runUrl = found.run.htmlUrl || `${this.store.data.gitea.baseUrl}/${operation.repository}/actions/runs/${found.run.runNumber}`;
|
||||
this.setStage(operation, 'queued', 'complete');
|
||||
const runConclusion = terminalRunConclusion(found.run);
|
||||
if (!runConclusion) {
|
||||
operation.status = isRunningStatus(found.run.status) ? 'running' : 'queued';
|
||||
this.setStage(operation, 'runner', operation.status === 'running' ? 'active' : 'pending');
|
||||
}
|
||||
|
||||
try {
|
||||
const jobs = await this.gitea.listWorkflowJobs({ owner, repo, runNumber: found.run.runNumber });
|
||||
this.mapJobsToStages(operation, jobs);
|
||||
for (const job of jobs) {
|
||||
const conclusion = job.conclusion || job.status;
|
||||
this.appendLog(operation, `[job] ${job.name}: ${conclusion}`);
|
||||
}
|
||||
// Raw runner output is intentionally not ingested or persisted. Open the trusted Gitea run for full logs.
|
||||
} catch (error) {
|
||||
this.appendLog(operation, `[warning] Job details unavailable: ${error.message}`);
|
||||
}
|
||||
|
||||
if (runConclusion === 'success') {
|
||||
this.setStage(operation, 'runner', 'complete');
|
||||
this.setStage(operation, 'healthcheck', 'active');
|
||||
const state = await this.refreshProfileState(operation.repository, operation.profileId, { expectedSha: operation.sha });
|
||||
operation.applicationState = state;
|
||||
const verificationFailure = applicationVerificationFailure(operation, state);
|
||||
if (verificationFailure) {
|
||||
operation.status = 'failed';
|
||||
this.setStage(operation, 'healthcheck', 'failed');
|
||||
this.setStage(operation, 'complete', 'failed');
|
||||
operation.failure = verificationFailure;
|
||||
this.appendLog(operation, `[error] ${verificationFailure.message}`);
|
||||
} else {
|
||||
operation.status = operation.action === 'rollback' ? 'rolled-back' : 'success';
|
||||
this.setStage(operation, 'healthcheck', 'complete');
|
||||
this.setStage(operation, 'complete', 'complete');
|
||||
this.appendLog(operation, `[ok] ${operation.action === 'rollback' ? 'Rollback' : 'Deployment'} completed successfully.`);
|
||||
}
|
||||
} else if (runConclusion === 'failed' || runConclusion === 'cancelled') {
|
||||
operation.status = runConclusion;
|
||||
this.setStage(operation, 'runner', runConclusion === 'failed' ? 'failed' : 'cancelled');
|
||||
this.setStage(operation, 'healthcheck', 'skipped');
|
||||
this.setStage(operation, 'complete', runConclusion === 'failed' ? 'failed' : 'cancelled');
|
||||
operation.failure = { stage: 'runner', message: `Gitea Actions finished with ${runConclusion}.` };
|
||||
this.appendLog(operation, `[error] ${operation.failure.message}`);
|
||||
}
|
||||
|
||||
const saved = await this.store.addOperation(operation);
|
||||
if (TERMINAL_STATUSES.has(operation.status)) {
|
||||
await this.diagnostics?.info('deployment.operation.terminal', { operationId: operation.id, repository: operation.repository, status: operation.status, failure: operation.failure || null, applicationState: operation.applicationState || null });
|
||||
} else {
|
||||
await this.diagnostics?.debug('deployment.operation.refreshed', { operationId: operation.id, repository: operation.repository, status: operation.status, run: operation.run ? { id: operation.run.id, runNumber: operation.run.runNumber, status: operation.run.status, conclusion: operation.run.conclusion } : null });
|
||||
}
|
||||
return saved;
|
||||
} catch (error) {
|
||||
operation.pollError = error.message;
|
||||
this.appendLog(operation, `[warning] Status refresh failed: ${error.message}`);
|
||||
await this.diagnostics?.warning('deployment.operation.poll-failed', { operationId: operation.id, repository: operation.repository, message: error.message });
|
||||
return await this.store.addOperation(operation);
|
||||
} finally {
|
||||
this.refreshLocks.delete(operationId);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshActiveOperations() {
|
||||
const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status));
|
||||
const results = [];
|
||||
for (const operation of active.slice(0, 20)) results.push(await this.refreshOperation(operation.id));
|
||||
return results;
|
||||
}
|
||||
|
||||
async checkHealth(url) {
|
||||
if (!url) return { configured: false, healthy: null };
|
||||
const normalized = assertHttpUrl(url, { label: 'Healthcheck URL' });
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(normalized, { signal: AbortSignal.timeout(10_000), redirect: 'follow', headers: { Accept: 'application/json, text/plain, */*' } });
|
||||
return { configured: true, healthy: response.ok, status: response.status, latencyMs: Date.now() - started };
|
||||
} catch (error) {
|
||||
return { configured: true, healthy: false, error: error.message, latencyMs: Date.now() - started };
|
||||
}
|
||||
}
|
||||
|
||||
async readStatusEndpoint(url) {
|
||||
if (!url) return { configured: false };
|
||||
const normalized = assertHttpUrl(url, { label: 'Application status URL' });
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(normalized, { signal: AbortSignal.timeout(10_000), redirect: 'follow', headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) return { configured: true, reachable: true, ok: false, status: response.status, latencyMs: Date.now() - started };
|
||||
const payload = await response.json();
|
||||
const liveSha = payload.commit_sha || payload.commitSha || payload.sha || payload.version?.commit_sha || payload.version?.sha || null;
|
||||
const previousSha = payload.previous_sha || payload.previousSha || payload.previous?.sha || null;
|
||||
const requestId = payload.request_id || payload.requestId || null;
|
||||
const requestedSha = payload.requested_sha || payload.requestedSha || null;
|
||||
const repository = payload.repository || null;
|
||||
const environment = payload.environment || null;
|
||||
const rawExitCode = payload.last_exit_code ?? payload.lastExitCode ?? null;
|
||||
return {
|
||||
configured: true,
|
||||
reachable: true,
|
||||
ok: true,
|
||||
status: response.status,
|
||||
latencyMs: Date.now() - started,
|
||||
liveSha: /^[a-f0-9]{40,64}$/i.test(String(liveSha || '')) ? String(liveSha).toLowerCase() : null,
|
||||
previousSha: /^[a-f0-9]{40,64}$/i.test(String(previousSha || '')) ? String(previousSha).toLowerCase() : null,
|
||||
requestId: typeof requestId === 'string' ? requestId.slice(0, 100) : null,
|
||||
requestedSha: /^[a-f0-9]{40,64}$/i.test(String(requestedSha || '')) ? String(requestedSha).toLowerCase() : null,
|
||||
repository: typeof repository === 'string' ? repository.slice(0, 200) : null,
|
||||
environment: typeof environment === 'string' ? environment.slice(0, 64).toLowerCase() : null,
|
||||
lastExitCode: rawExitCode !== null && rawExitCode !== '' && Number.isInteger(Number(rawExitCode)) ? Number(rawExitCode) : null,
|
||||
deployedAt: payload.deployed_at || payload.deployedAt || null,
|
||||
health: payload.health || payload.status || null,
|
||||
payload
|
||||
};
|
||||
} catch (error) {
|
||||
return { configured: true, reachable: false, ok: false, error: error.message, latencyMs: Date.now() - started };
|
||||
}
|
||||
}
|
||||
|
||||
async refreshProfileState(fullName, profileId, { expectedSha = null } = {}) {
|
||||
const profile = this.store.getDeploymentProfile(fullName, profileId);
|
||||
if (!profile) throw new Error('Deployment profile not found.');
|
||||
const [status, health] = await Promise.all([
|
||||
this.readStatusEndpoint(profile.statusUrl),
|
||||
this.checkHealth(profile.healthcheckUrl)
|
||||
]);
|
||||
const state = {
|
||||
profileId,
|
||||
repository: fullName,
|
||||
environment: profile.environment,
|
||||
liveSha: status.liveSha || null,
|
||||
previousSha: status.previousSha || null,
|
||||
deployedAt: status.deployedAt || null,
|
||||
statusConfigured: Boolean(status.configured),
|
||||
statusReachable: status.configured ? Boolean(status.reachable && status.ok) : null,
|
||||
statusCode: status.status || null,
|
||||
statusRepository: status.repository || null,
|
||||
statusEnvironment: status.environment || null,
|
||||
requestedSha: status.requestedSha || null,
|
||||
lastExitCode: status.lastExitCode,
|
||||
healthConfigured: Boolean(health.configured),
|
||||
healthy: health.configured
|
||||
? Boolean(health.healthy)
|
||||
: (['healthy', 'ok', 'success', 'ready'].includes(String(status.health || '').toLowerCase())
|
||||
? true
|
||||
: (['unhealthy', 'failed', 'error', 'degraded'].includes(String(status.health || '').toLowerCase()) ? false : null)),
|
||||
healthStatus: health.status || status.health || null,
|
||||
latencyMs: health.latencyMs ?? status.latencyMs ?? null,
|
||||
expectedSha: expectedSha || null,
|
||||
requestId: status.requestId || null,
|
||||
versionMatches: expectedSha && status.liveSha ? status.liveSha === expectedSha : null,
|
||||
error: health.error || status.error || null,
|
||||
checkedAt: new Date().toISOString()
|
||||
};
|
||||
return this.store.saveDeploymentState(profileId, state);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DeploymentService, TERMINAL_STATUSES, terminalRunConclusion, applicationVerificationFailure };
|
||||
@@ -0,0 +1,356 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const crypto = require('node:crypto');
|
||||
const { createZip } = require('../shared/zip-writer.cjs');
|
||||
const { sanitizeForDiagnostics, redactSecrets } = require('./log-redaction.cjs');
|
||||
|
||||
const LEVELS = { debug: 10, info: 20, warning: 30, error: 40 };
|
||||
|
||||
function dateKey(value = new Date()) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function byteSizeLabel(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function safeJson(value) {
|
||||
return `${JSON.stringify(value, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function auditBundleEntries(entries, secrets = []) {
|
||||
const candidates = [...new Set((secrets || []).map((item) => String(item || '').trim()).filter((item) => item.length >= 4))];
|
||||
const findings = [];
|
||||
for (const entry of entries) {
|
||||
const text = Buffer.isBuffer(entry.data) ? entry.data.toString('utf8') : String(entry.data ?? '');
|
||||
for (const secret of candidates) {
|
||||
if (text.includes(secret)) findings.push({ file: entry.name, type: 'known-runtime-secret' });
|
||||
}
|
||||
if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i.test(text)) findings.push({ file: entry.name, type: 'private-key-marker' });
|
||||
if (/https?:\/\/[^\s:@/]+:(?!\[REDACTED\])[^@\s/]+@/i.test(text)) findings.push({ file: entry.name, type: 'url-credential' });
|
||||
}
|
||||
return { passed: findings.length === 0, checkedFiles: entries.length, knownRuntimeSecretCount: candidates.length, findings };
|
||||
}
|
||||
|
||||
class DiagnosticsService {
|
||||
constructor({ userDataPath, appInfo = {}, secretProvider = () => [], preferencesProvider = () => ({}) }) {
|
||||
this.userDataPath = userDataPath;
|
||||
this.logDirectory = path.join(userDataPath, 'diagnostics');
|
||||
this.appInfo = appInfo;
|
||||
this.secretProvider = secretProvider;
|
||||
this.preferencesProvider = preferencesProvider;
|
||||
this.sessionId = crypto.randomUUID();
|
||||
this.writeChain = Promise.resolve();
|
||||
this.initialized = false;
|
||||
this.lastWriteError = null;
|
||||
this.lastBundlePath = null;
|
||||
}
|
||||
|
||||
preferences() {
|
||||
const source = this.preferencesProvider?.() || {};
|
||||
return {
|
||||
enabled: source.diagnosticsEnabled !== false,
|
||||
level: ['debug', 'info', 'warning', 'error'].includes(source.diagnosticLevel) ? source.diagnosticLevel : 'info',
|
||||
retentionDays: Math.min(Math.max(Number(source.logRetentionDays) || 14, 1), 90),
|
||||
maxFileMb: Math.min(Math.max(Number(source.maxLogFileMb) || 8, 1), 50)
|
||||
};
|
||||
}
|
||||
|
||||
sanitize(value, options = {}) {
|
||||
return sanitizeForDiagnostics(value, {
|
||||
secrets: this.secretProvider?.() || [],
|
||||
homeDir: os.homedir(),
|
||||
cwd: process.cwd(),
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
|
||||
try { await fs.chmod(this.logDirectory, 0o700); } catch {}
|
||||
this.initialized = true;
|
||||
await this.prune();
|
||||
await this.info('diagnostics.session.started', {
|
||||
sessionId: this.sessionId,
|
||||
app: this.appInfo,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
node: process.versions.node,
|
||||
electron: process.versions.electron || null
|
||||
});
|
||||
}
|
||||
|
||||
shouldWrite(level) {
|
||||
const preferences = this.preferences();
|
||||
return preferences.enabled && LEVELS[level] >= LEVELS[preferences.level];
|
||||
}
|
||||
|
||||
filePathForToday() {
|
||||
return path.join(this.logDirectory, `forgeflow-${dateKey()}.jsonl`);
|
||||
}
|
||||
|
||||
async rotateIfNeeded(filePath) {
|
||||
const limit = this.preferences().maxFileMb * 1024 * 1024;
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
if (!stat || stat.size < limit) return filePath;
|
||||
for (let index = 1; index < 100; index += 1) {
|
||||
const candidate = path.join(this.logDirectory, `forgeflow-${dateKey()}-${String(index).padStart(2, '0')}.jsonl`);
|
||||
const candidateStat = await fs.stat(candidate).catch(() => null);
|
||||
if (!candidateStat || candidateStat.size < limit) return candidate;
|
||||
}
|
||||
return path.join(this.logDirectory, `forgeflow-${dateKey()}-${Date.now()}.jsonl`);
|
||||
}
|
||||
|
||||
log(level, event, details = {}) {
|
||||
if (!this.shouldWrite(level)) return Promise.resolve(false);
|
||||
const record = this.sanitize({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
event: String(event || 'diagnostics.event').slice(0, 160),
|
||||
sessionId: this.sessionId,
|
||||
details
|
||||
});
|
||||
const line = `${JSON.stringify(record)}\n`;
|
||||
this.writeChain = this.writeChain.then(async () => {
|
||||
try {
|
||||
if (!this.initialized) await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
|
||||
const target = await this.rotateIfNeeded(this.filePathForToday());
|
||||
await fs.appendFile(target, line, { encoding: 'utf8', mode: 0o600 });
|
||||
try { await fs.chmod(target, 0o600); } catch {}
|
||||
this.lastWriteError = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.lastWriteError = error.message;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return this.writeChain;
|
||||
}
|
||||
|
||||
debug(event, details) { return this.log('debug', event, details); }
|
||||
info(event, details) { return this.log('info', event, details); }
|
||||
warning(event, details) { return this.log('warning', event, details); }
|
||||
error(event, details) { return this.log('error', event, details); }
|
||||
|
||||
async flush() {
|
||||
await this.writeChain;
|
||||
}
|
||||
|
||||
async listLogFiles() {
|
||||
await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 });
|
||||
const entries = await fs.readdir(this.logDirectory, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !/^forgeflow-.*\.jsonl$/i.test(entry.name)) continue;
|
||||
const absolute = path.join(this.logDirectory, entry.name);
|
||||
const stat = await fs.stat(absolute).catch(() => null);
|
||||
if (stat) files.push({ name: entry.name, path: absolute, size: stat.size, modifiedAt: stat.mtime.toISOString() });
|
||||
}
|
||||
return files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
||||
}
|
||||
|
||||
async prune() {
|
||||
const cutoff = Date.now() - this.preferences().retentionDays * 24 * 60 * 60 * 1000;
|
||||
for (const file of await this.listLogFiles()) {
|
||||
if (new Date(file.modifiedAt).getTime() < cutoff) await fs.rm(file.path, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
await this.flush();
|
||||
const files = await this.listLogFiles();
|
||||
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
||||
return {
|
||||
enabled: this.preferences().enabled,
|
||||
level: this.preferences().level,
|
||||
retentionDays: this.preferences().retentionDays,
|
||||
maxFileMb: this.preferences().maxFileMb,
|
||||
directory: this.sanitize(this.logDirectory),
|
||||
fileCount: files.length,
|
||||
totalBytes,
|
||||
totalSize: byteSizeLabel(totalBytes),
|
||||
latestAt: files[0]?.modifiedAt || null,
|
||||
lastWriteError: this.lastWriteError
|
||||
};
|
||||
}
|
||||
|
||||
async clear() {
|
||||
await this.flush();
|
||||
for (const file of await this.listLogFiles()) await fs.rm(file.path, { force: true });
|
||||
await this.info('diagnostics.logs.cleared', {});
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
async collectLogs(maxBytes = 20 * 1024 * 1024) {
|
||||
await this.flush();
|
||||
const output = [];
|
||||
let used = 0;
|
||||
for (const file of await this.listLogFiles()) {
|
||||
if (used >= maxBytes) break;
|
||||
const remaining = maxBytes - used;
|
||||
const content = await fs.readFile(file.path);
|
||||
const slice = content.length > remaining ? content.subarray(content.length - remaining) : content;
|
||||
output.push({ name: `logs/${file.name}`, data: Buffer.from(redactSecrets(slice.toString('utf8'), this.secretProvider?.() || []), 'utf8') });
|
||||
used += slice.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async exportSupportBundle({ destinationPath, publicState, repositories = [], operations = [], preflight = null, privacyMode = 'standard', extra = {} }) {
|
||||
if (!destinationPath) throw new Error('No support bundle destination was selected.');
|
||||
if (!['standard', 'strict'].includes(privacyMode)) throw new Error('Unsupported diagnostic privacy mode.');
|
||||
if (path.extname(destinationPath).toLowerCase() !== '.zip') throw new Error('Diagnostic bundles must use the .zip extension.');
|
||||
await this.info('diagnostics.bundle.requested', { privacyMode, repositoryCount: repositories.length, operationCount: operations.length });
|
||||
const strict = privacyMode === 'strict';
|
||||
const sanitize = (value) => this.sanitize(value, { strictIdentifiers: strict });
|
||||
const generatedAt = new Date().toISOString();
|
||||
const diagnosticsStatus = await this.getStatus();
|
||||
const system = sanitize({
|
||||
app: this.appInfo,
|
||||
generatedAt,
|
||||
sessionId: this.sessionId,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
release: os.release(),
|
||||
type: os.type(),
|
||||
cpus: os.cpus()?.map((cpu) => cpu.model).filter((value, index, array) => array.indexOf(value) === index),
|
||||
cpuCount: os.cpus()?.length || null,
|
||||
totalMemoryBytes: os.totalmem(),
|
||||
freeMemoryBytes: os.freemem(),
|
||||
uptimeSeconds: os.uptime(),
|
||||
locale: Intl.DateTimeFormat().resolvedOptions().locale,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
versions: process.versions
|
||||
});
|
||||
|
||||
const sanitizedState = sanitize(publicState || {});
|
||||
if (sanitizedState.gitea) sanitizedState.gitea.hasToken = Boolean(publicState?.gitea?.hasToken);
|
||||
const sanitizedRepositories = sanitize(repositories.map((repository) => ({
|
||||
id: repository.id,
|
||||
fullName: repository.fullName,
|
||||
linkState: repository.linkState,
|
||||
localPath: repository.localPath,
|
||||
attention: repository.attention,
|
||||
attentionReason: repository.attentionReason,
|
||||
readyToDeploy: repository.readyToDeploy,
|
||||
localStatus: repository.localStatus ? {
|
||||
branch: repository.localStatus.branch,
|
||||
head: repository.localStatus.head,
|
||||
counts: repository.localStatus.counts,
|
||||
clean: repository.localStatus.clean,
|
||||
remoteUrl: repository.localStatus.remoteUrl
|
||||
} : null,
|
||||
deploymentProfiles: repository.deploymentProfiles?.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
environment: profile.environment,
|
||||
branch: profile.branch,
|
||||
workflowFile: profile.workflowFile,
|
||||
rollbackWorkflowFile: profile.rollbackWorkflowFile,
|
||||
healthcheckUrl: profile.healthcheckUrl,
|
||||
statusUrl: profile.statusUrl,
|
||||
state: profile.state
|
||||
})) || []
|
||||
})));
|
||||
const sanitizedOperations = sanitize(operations.map((operation) => ({
|
||||
id: operation.id,
|
||||
type: operation.type,
|
||||
action: operation.action,
|
||||
status: operation.status,
|
||||
repository: operation.repository,
|
||||
profileId: operation.profileId,
|
||||
profileName: operation.profileName,
|
||||
environment: operation.environment,
|
||||
workflowFile: operation.workflowFile,
|
||||
branch: operation.branch,
|
||||
sha: operation.sha,
|
||||
shortSha: operation.shortSha,
|
||||
createdAt: operation.createdAt,
|
||||
updatedAt: operation.updatedAt,
|
||||
dispatchedAt: operation.dispatchedAt,
|
||||
stages: operation.stages,
|
||||
jobs: operation.jobs,
|
||||
logs: operation.logs,
|
||||
failure: operation.failure,
|
||||
pollError: operation.pollError,
|
||||
applicationState: operation.applicationState,
|
||||
run: operation.run ? {
|
||||
id: operation.run.id,
|
||||
runNumber: operation.run.runNumber,
|
||||
name: operation.run.name,
|
||||
status: operation.run.status,
|
||||
conclusion: operation.run.conclusion,
|
||||
headSha: operation.run.headSha,
|
||||
headBranch: operation.run.headBranch,
|
||||
workflowPath: operation.run.workflowPath,
|
||||
createdAt: operation.run.createdAt,
|
||||
updatedAt: operation.run.updatedAt
|
||||
} : null,
|
||||
runnerLog: operation.runnerLog ? {
|
||||
included: false,
|
||||
reason: 'Raw runner output is intentionally omitted from diagnostic bundles.',
|
||||
characters: String(operation.runnerLog).length,
|
||||
lines: String(operation.runnerLog).split(/\r?\n/).length
|
||||
} : null
|
||||
})));
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
product: 'ForgeFlow Support Bundle',
|
||||
generatedAt,
|
||||
privacyMode,
|
||||
containsSecrets: false,
|
||||
redaction: {
|
||||
knownRuntimeSecrets: true,
|
||||
sensitiveObjectKeys: true,
|
||||
authorizationHeaders: true,
|
||||
credentialUrls: true,
|
||||
privateKeys: true,
|
||||
userHomePaths: true,
|
||||
identifiersHashed: strict
|
||||
},
|
||||
files: []
|
||||
};
|
||||
|
||||
const entries = [
|
||||
{ name: 'README.txt', data: `ForgeFlow diagnostic support bundle\nGenerated: ${generatedAt}\nPrivacy mode: ${privacyMode}\n\nThis bundle is generated locally. Access tokens, passwords, authorization headers, embedded URL credentials, encrypted token blobs and private keys are removed. Review the bundle before sharing it.\n` },
|
||||
{ name: 'system.json', data: safeJson(system) },
|
||||
{ name: 'diagnostics-status.json', data: safeJson(sanitize(diagnosticsStatus)) },
|
||||
{ name: 'configuration-sanitized.json', data: safeJson(sanitizedState) },
|
||||
{ name: 'repositories-sanitized.json', data: safeJson(sanitizedRepositories) },
|
||||
{ name: 'operations-sanitized.json', data: safeJson(sanitizedOperations) },
|
||||
{ name: 'preflight.json', data: safeJson(sanitize(preflight || {})) },
|
||||
{ name: 'context.json', data: safeJson(sanitize(extra || {})) },
|
||||
...(await this.collectLogs())
|
||||
];
|
||||
|
||||
const safetyAudit = auditBundleEntries(entries, this.secretProvider?.() || []);
|
||||
if (!safetyAudit.passed) {
|
||||
await this.error('diagnostics.bundle.safety-check-failed', { findings: safetyAudit.findings });
|
||||
throw new Error('The diagnostic bundle failed its local secret-safety check and was not written.');
|
||||
}
|
||||
entries.push({ name: 'safety-audit.json', data: safeJson(safetyAudit) });
|
||||
manifest.files = entries.map((entry) => ({ name: entry.name, bytes: Buffer.byteLength(entry.data) }));
|
||||
entries.unshift({ name: 'manifest.json', data: safeJson(manifest) });
|
||||
const archive = createZip(entries);
|
||||
const temporary = `${destinationPath}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
|
||||
await fs.writeFile(temporary, archive, { mode: 0o600 });
|
||||
await fs.rename(temporary, destinationPath);
|
||||
try { await fs.chmod(destinationPath, 0o600); } catch {}
|
||||
this.lastBundlePath = path.resolve(destinationPath);
|
||||
const sha256 = crypto.createHash('sha256').update(archive).digest('hex');
|
||||
await this.info('diagnostics.bundle.created', { destinationPath, bytes: archive.length, sha256, privacyMode });
|
||||
return { path: destinationPath, bytes: archive.length, size: byteSizeLabel(archive.length), sha256, privacyMode, generatedAt };
|
||||
}
|
||||
|
||||
isKnownBundlePath(filePath) {
|
||||
return Boolean(filePath && this.lastBundlePath && path.resolve(filePath) === this.lastBundlePath);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DiagnosticsService, dateKey, byteSizeLabel, auditBundleEntries, LEVELS };
|
||||
@@ -0,0 +1,298 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const { run } = require('./process-runner.cjs');
|
||||
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
|
||||
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
||||
const {
|
||||
assertSafeRepositoryPath,
|
||||
assertRepositoryRelativePath,
|
||||
assertRepositoryRelativePaths,
|
||||
assertCommitMessage,
|
||||
assertFullCommitSha,
|
||||
assertCloneRemote
|
||||
} = require('../shared/validation.cjs');
|
||||
|
||||
class GitService {
|
||||
async isAvailable() {
|
||||
try {
|
||||
const result = await run('git', ['--version'], { timeout: 10_000 });
|
||||
return { available: true, version: result.stdout.trim() };
|
||||
} catch (error) {
|
||||
return { available: false, version: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async ensureRepository(repoPath) {
|
||||
const resolved = assertSafeRepositoryPath(repoPath);
|
||||
const stat = await fs.stat(resolved).catch(() => null);
|
||||
if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.');
|
||||
const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 });
|
||||
return path.resolve(result.stdout.trim());
|
||||
}
|
||||
|
||||
async status(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const result = await run('git', ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
|
||||
cwd: root,
|
||||
timeout: 30_000
|
||||
});
|
||||
const parsed = parsePorcelainV2(result.stdout);
|
||||
const remoteUrl = await this.getRemoteUrl(root).catch(() => '');
|
||||
const head = parsed.branch.oid && parsed.branch.oid !== '(initial)' ? parsed.branch.oid : null;
|
||||
return { ...parsed, root, remoteUrl, head, shortHead: head ? head.slice(0, 7) : null };
|
||||
}
|
||||
|
||||
statusFingerprint(status) {
|
||||
return JSON.stringify({
|
||||
head: status?.head || null,
|
||||
branch: status?.branch || null,
|
||||
files: (status?.files || []).map((file) => [file.path, file.originalPath, file.indexCode, file.worktreeCode])
|
||||
});
|
||||
}
|
||||
|
||||
async getRemoteUrl(repoPath, remote = 'origin') {
|
||||
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
async diff(repoPath, filePath, staged = false) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const safeFile = filePath ? assertRepositoryRelativePath(filePath) : '';
|
||||
const args = ['diff', '--no-ext-diff', '--no-color', '--unified=4'];
|
||||
if (staged) args.push('--cached');
|
||||
if (safeFile) args.push('--', safeFile);
|
||||
const result = await run('git', args, { cwd: root, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
if (!result.stdout && safeFile && !staged) {
|
||||
const candidate = path.resolve(root, safeFile);
|
||||
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
|
||||
const content = await fs.readFile(candidate, 'utf8').catch(() => '');
|
||||
if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`;
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async stage(repoPath, files) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const selected = assertRepositoryRelativePaths(files);
|
||||
await run('git', selected.length ? ['add', '--', ...selected] : ['add', '--all'], { cwd: root, timeout: 60_000 });
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async unstage(repoPath, files) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const selected = assertRepositoryRelativePaths(files);
|
||||
const hasHead = await run('git', ['rev-parse', '--verify', 'HEAD'], { cwd: root, allowExitCodes: [128] });
|
||||
if (hasHead.exitCode === 0) {
|
||||
await run('git', selected.length ? ['restore', '--staged', '--', ...selected] : ['restore', '--staged', '.'], { cwd: root });
|
||||
} else {
|
||||
await run('git', selected.length ? ['rm', '--cached', '--', ...selected] : ['rm', '--cached', '-r', '.'], { cwd: root, allowExitCodes: [1] });
|
||||
}
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async prepareSelectedStage(root, files) {
|
||||
const selected = assertRepositoryRelativePaths(files);
|
||||
if (selected.length) {
|
||||
const stagedBefore = await run('git', ['diff', '--cached', '--name-only', '-z'], { cwd: root });
|
||||
const alreadyStaged = stagedBefore.stdout.split('\0').filter(Boolean);
|
||||
const excludedStaged = alreadyStaged.filter((file) => !selected.includes(file));
|
||||
if (excludedStaged.length) {
|
||||
throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`);
|
||||
}
|
||||
}
|
||||
await this.stage(root, selected);
|
||||
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
|
||||
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
|
||||
return selected;
|
||||
}
|
||||
|
||||
async commit(repoPath, message, files = []) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const commitMessage = assertCommitMessage(message);
|
||||
await this.prepareSelectedStage(root, files);
|
||||
const result = await run('git', ['commit', '-m', commitMessage], { cwd: root, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
const status = await this.status(root);
|
||||
return { output: result.stdout.trim(), sha: status.head, shortSha: status.shortHead, status };
|
||||
}
|
||||
|
||||
async commitAndPush(repoPath, message, files = []) {
|
||||
const committed = await this.commit(repoPath, message, files);
|
||||
try {
|
||||
const pushed = await this.push(repoPath);
|
||||
return { commitOutput: committed.output, pushOutput: pushed.output, status: pushed.status, sha: committed.sha };
|
||||
} catch (error) {
|
||||
const wrapped = new Error(`Commit ${committed.shortSha} was created locally, but push failed: ${error.message}`);
|
||||
wrapped.code = 'PUSH_AFTER_COMMIT_FAILED';
|
||||
wrapped.commitSha = committed.sha;
|
||||
wrapped.recoverable = true;
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
async push(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const status = await this.status(root);
|
||||
const branch = status.branch.head;
|
||||
if (!branch || branch === '(detached)') throw new Error('Cannot push from a detached HEAD.');
|
||||
const args = status.branch.upstream ? ['push', '--porcelain'] : ['push', '--porcelain', '--set-upstream', 'origin', branch];
|
||||
const result = await run('git', args, { cwd: root, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
|
||||
}
|
||||
|
||||
async fetch(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const result = await run('git', ['fetch', '--prune'], { cwd: root, timeout: 180_000 });
|
||||
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
|
||||
}
|
||||
|
||||
async pullFastForward(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const status = await this.status(root);
|
||||
if (!status.clean) throw new Error('Commit or stash local changes before synchronizing.');
|
||||
if (!status.branch.upstream) throw new Error('This branch has no upstream branch. Publish it first.');
|
||||
const result = await run('git', ['pull', '--ff-only'], { cwd: root, timeout: 180_000 });
|
||||
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
|
||||
}
|
||||
|
||||
async history(repoPath, limit = 20) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const format = '%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e';
|
||||
const result = await run('git', ['log', `-${Math.min(Math.max(Number(limit) || 20, 1), 100)}`, `--format=${format}`], { cwd: root, allowExitCodes: [128] });
|
||||
if (result.exitCode === 128) return [];
|
||||
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||
const [sha, shortSha, author, email, date, subject] = record.split('\x1f');
|
||||
return { sha, shortSha, author, email, date, subject };
|
||||
});
|
||||
}
|
||||
|
||||
async branches(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const format = '%(refname:short)%x1f%(objectname)%x1f%(HEAD)%x1f%(upstream:short)%x1f%(upstream:track)%x1e';
|
||||
const result = await run('git', ['for-each-ref', `--format=${format}`, 'refs/heads'], { cwd: root });
|
||||
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||
const [name, sha, current, upstream, track] = record.split('\x1f');
|
||||
const ahead = Number(track?.match(/ahead (\d+)/)?.[1] || 0);
|
||||
const behind = Number(track?.match(/behind (\d+)/)?.[1] || 0);
|
||||
return { name, sha, shortSha: sha?.slice(0, 7), current: current === '*', upstream: upstream || null, ahead, behind };
|
||||
});
|
||||
}
|
||||
|
||||
assertBranchName(branch) {
|
||||
const value = String(branch || '').trim();
|
||||
if (!value) throw new Error('Branch name is required.');
|
||||
return value;
|
||||
}
|
||||
|
||||
async checkoutBranch(repoPath, branch) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const status = await this.status(root);
|
||||
if (!status.clean) throw new Error('Commit or stash local changes before switching branches.');
|
||||
const value = this.assertBranchName(branch);
|
||||
await run('git', ['check-ref-format', '--branch', value], { cwd: root });
|
||||
await run('git', ['switch', value], { cwd: root, timeout: 60_000 });
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async createBranch(repoPath, branch) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const status = await this.status(root);
|
||||
if (!status.clean) throw new Error('Commit or stash local changes before creating a branch.');
|
||||
const value = this.assertBranchName(branch);
|
||||
await run('git', ['check-ref-format', '--branch', value], { cwd: root });
|
||||
await run('git', ['switch', '-c', value], { cwd: root, timeout: 60_000 });
|
||||
return this.status(root);
|
||||
}
|
||||
|
||||
async stash(repoPath, message = '') {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const status = await this.status(root);
|
||||
if (status.clean) throw new Error('There are no changes to stash.');
|
||||
const args = ['stash', 'push', '--include-untracked'];
|
||||
const label = String(message || '').trim();
|
||||
if (label) args.push('-m', label.slice(0, 200));
|
||||
const result = await run('git', args, { cwd: root, timeout: 120_000 });
|
||||
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
|
||||
}
|
||||
|
||||
async stashList(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const format = '%gd%x1f%H%x1f%aI%x1f%gs%x1e';
|
||||
const result = await run('git', ['stash', 'list', `--format=${format}`], { cwd: root });
|
||||
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||
const [ref, sha, date, subject] = record.split('\x1f');
|
||||
return { ref, sha, shortSha: sha.slice(0, 7), date, subject };
|
||||
});
|
||||
}
|
||||
|
||||
async popStash(repoPath, ref = 'stash@{0}') {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const value = String(ref || 'stash@{0}');
|
||||
if (!/^stash@\{\d+\}$/.test(value)) throw new Error('Invalid stash reference.');
|
||||
const result = await run('git', ['stash', 'pop', value], { cwd: root, timeout: 120_000 });
|
||||
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
|
||||
}
|
||||
|
||||
async verifyCommitOnRemoteBranch(repoPath, sha, branch) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const fullSha = assertFullCommitSha(sha);
|
||||
const branchName = this.assertBranchName(branch);
|
||||
await run('git', ['fetch', '--prune', 'origin', branchName], { cwd: root, timeout: 180_000 });
|
||||
await run('git', ['cat-file', '-e', `${fullSha}^{commit}`], { cwd: root, timeout: 30_000 });
|
||||
const ancestor = await run('git', ['merge-base', '--is-ancestor', fullSha, `origin/${branchName}`], { cwd: root, allowExitCodes: [1] });
|
||||
if (ancestor.exitCode !== 0) throw new Error(`Commit ${fullSha.slice(0, 7)} is not contained in origin/${branchName}.`);
|
||||
return { valid: true, sha: fullSha, branch: branchName };
|
||||
}
|
||||
|
||||
async inspectCloneTarget(remoteUrl, destination) {
|
||||
const remote = assertCloneRemote(remoteUrl);
|
||||
const target = assertSafeRepositoryPath(destination);
|
||||
const existing = await fs.stat(target).catch(() => null);
|
||||
|
||||
if (!existing) return { state: 'missing', remote, target };
|
||||
if (!existing.isDirectory()) {
|
||||
const error = new Error('The automatic clone target exists and is not a folder.');
|
||||
error.code = 'CLONE_TARGET_NOT_DIRECTORY';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(target);
|
||||
if (!entries.length) return { state: 'empty', remote, target };
|
||||
|
||||
const existingRemote = await this.getRemoteUrl(target).catch(() => '');
|
||||
const expected = normalizeRemoteUrl(remote);
|
||||
const actual = normalizeRemoteUrl(existingRemote);
|
||||
const sameRepository = Boolean(
|
||||
expected && actual
|
||||
&& expected.host === actual.host
|
||||
&& expected.path === actual.path
|
||||
);
|
||||
|
||||
if (sameRepository) return { state: 'matching-repository', remote, target };
|
||||
|
||||
const error = new Error(existingRemote
|
||||
? 'The automatic clone target already contains a different Git repository.'
|
||||
: 'The automatic clone target already contains files. Choose another location or link the existing folder.');
|
||||
error.code = existingRemote ? 'CLONE_TARGET_DIFFERENT_REPOSITORY' : 'CLONE_TARGET_NOT_EMPTY';
|
||||
throw error;
|
||||
}
|
||||
|
||||
async clone(remoteUrl, destination) {
|
||||
const assessment = await this.inspectCloneTarget(remoteUrl, destination);
|
||||
if (assessment.state === 'matching-repository') {
|
||||
const status = await this.status(assessment.target);
|
||||
return { ...status, reused: true };
|
||||
}
|
||||
|
||||
if (assessment.state === 'missing') {
|
||||
await fs.mkdir(path.dirname(assessment.target), { recursive: true });
|
||||
}
|
||||
|
||||
await run('git', ['clone', '--progress', assessment.remote, assessment.target], { timeout: 15 * 60_000, maxBuffer: 32 * 1024 * 1024 });
|
||||
const status = await this.status(assessment.target);
|
||||
return { ...status, reused: false };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { GitService };
|
||||
@@ -0,0 +1,277 @@
|
||||
'use strict';
|
||||
|
||||
const { normalizeBaseUrl } = require('../shared/validation.cjs');
|
||||
const { redactSecrets } = require('./log-redaction.cjs');
|
||||
|
||||
class GiteaService {
|
||||
constructor(store, diagnostics = null) {
|
||||
this.store = store;
|
||||
this.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
async request(pathname, options = {}) {
|
||||
const baseUrl = normalizeBaseUrl(options.baseUrl || this.store.data.gitea.baseUrl);
|
||||
const token = options.token || this.store.getToken();
|
||||
if (!token && options.auth !== false) throw new Error('No Gitea access token is available.');
|
||||
|
||||
const headers = {
|
||||
Accept: options.accept || 'application/json',
|
||||
...(token && options.auth !== false ? { Authorization: `token ${token}` } : {}),
|
||||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options.headers || {})
|
||||
};
|
||||
|
||||
const started = Date.now();
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${baseUrl}/api/v1${pathname}`, {
|
||||
method: options.method || 'GET',
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
signal: AbortSignal.timeout(options.timeout || 30_000),
|
||||
redirect: 'follow'
|
||||
});
|
||||
} catch (error) {
|
||||
const wrapped = new Error(`Could not reach Gitea: ${redactSecrets(error.message, [token])}`);
|
||||
wrapped.code = error.code || 'GITEA_NETWORK_ERROR';
|
||||
await this.diagnostics?.warning('gitea.request.failed', { method: options.method || 'GET', pathname, durationMs: Date.now() - started, code: wrapped.code, message: wrapped.message });
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
let text = '';
|
||||
let payload = null;
|
||||
if (options.responseType === 'buffer') {
|
||||
payload = Buffer.from(await response.arrayBuffer());
|
||||
} else {
|
||||
text = await response.text();
|
||||
if (text) {
|
||||
if (options.responseType === 'text') payload = text;
|
||||
else {
|
||||
try { payload = JSON.parse(text); } catch { payload = text; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = typeof payload === 'object' && !Buffer.isBuffer(payload) && payload?.message ? payload.message : text || response.statusText;
|
||||
const error = new Error(`Gitea returned ${response.status}: ${redactSecrets(detail, [token])}`);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
await this.diagnostics?.warning('gitea.request.rejected', { method: options.method || 'GET', pathname, status: response.status, durationMs: Date.now() - started, message: error.message });
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.diagnostics?.debug('gitea.request.completed', { method: options.method || 'GET', pathname, status: response.status, durationMs: Date.now() - started });
|
||||
return { status: response.status, headers: response.headers, data: payload };
|
||||
}
|
||||
|
||||
async validateConnection(baseUrl, token) {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
const user = await this.request('/user', { baseUrl: normalized, token });
|
||||
const repositories = await this.listRepositories({ baseUrl: normalized, token, limitPages: 1 });
|
||||
const version = await this.request('/version', { baseUrl: normalized, token }).then((result) => result.data?.version || null).catch(() => null);
|
||||
return { baseUrl: normalized, user: user.data, repositoryCount: repositories.length, version };
|
||||
}
|
||||
|
||||
async listRepositories(options = {}) {
|
||||
const repositories = [];
|
||||
const pageSize = 50;
|
||||
const limitPages = options.limitPages || 20;
|
||||
for (let page = 1; page <= limitPages; page += 1) {
|
||||
const result = await this.request(`/user/repos?limit=${pageSize}&page=${page}&sort=updated`, options);
|
||||
const batch = Array.isArray(result.data) ? result.data : [];
|
||||
repositories.push(...batch);
|
||||
if (batch.length < pageSize) break;
|
||||
}
|
||||
return repositories;
|
||||
}
|
||||
|
||||
async getRepository(owner, repo) {
|
||||
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`)).data;
|
||||
}
|
||||
|
||||
async repositoryFileExists({ owner, repo, filePath, ref }) {
|
||||
const encodedPath = String(filePath || '').split('/').map(encodeURIComponent).join('/');
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : '';
|
||||
try {
|
||||
await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.status === 404) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async getBranch(owner, repo, branch) {
|
||||
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/${encodeURIComponent(branch)}`)).data;
|
||||
}
|
||||
|
||||
async getRepositoryFile({ owner, repo, filePath, ref }) {
|
||||
const encodedPath = String(filePath || '').split('/').map(encodeURIComponent).join('/');
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : '';
|
||||
const payload = (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/contents/${encodedPath}${query}`)).data;
|
||||
if (!payload || Array.isArray(payload)) throw new Error(`Repository path ${filePath} is not a file.`);
|
||||
if (payload.encoding === 'base64' && typeof payload.content === 'string') {
|
||||
return { ...payload, decoded: Buffer.from(payload.content.replace(/\s/g, ''), 'base64').toString('utf8') };
|
||||
}
|
||||
if (typeof payload.content === 'string') return { ...payload, decoded: payload.content };
|
||||
throw new Error(`Gitea did not return readable content for ${filePath}.`);
|
||||
}
|
||||
|
||||
async getLatestRelease(owner, repo) {
|
||||
try {
|
||||
return (await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`)).data;
|
||||
} catch (error) {
|
||||
if (error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAuthenticated(url, { timeout = 180_000 } = {}) {
|
||||
const baseUrl = normalizeBaseUrl(this.store.data.gitea.baseUrl);
|
||||
const base = new URL(baseUrl);
|
||||
const token = this.store.getToken();
|
||||
let target = new URL(url, `${baseUrl}/`);
|
||||
for (let redirects = 0; redirects <= 5; redirects += 1) {
|
||||
if (target.origin !== base.origin) throw new Error('Refusing to send the Gitea token to a different origin.');
|
||||
const response = await fetch(target, {
|
||||
headers: { Authorization: `token ${token}`, Accept: 'application/octet-stream' },
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
redirect: 'manual'
|
||||
});
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
const location = response.headers.get('location');
|
||||
if (!location) throw new Error('The update download redirect did not contain a destination.');
|
||||
target = new URL(location, target);
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) throw new Error(`Update download failed with HTTP ${response.status}.`);
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
throw new Error('The update download exceeded the redirect limit.');
|
||||
}
|
||||
|
||||
async dispatchWorkflow({ owner, repo, workflowFile, ref, inputs = {} }) {
|
||||
const result = await this.request(
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/workflows/${encodeURIComponent(workflowFile)}/dispatches`,
|
||||
{ method: 'POST', body: { ref, inputs }, timeout: 60_000 }
|
||||
);
|
||||
return { accepted: [200, 201, 204].includes(result.status), status: result.status };
|
||||
}
|
||||
|
||||
normalizeRun(run) {
|
||||
if (!run || typeof run !== 'object') return null;
|
||||
const status = String(run.status || run.conclusion || '').toLowerCase();
|
||||
const conclusion = String(run.conclusion || '').toLowerCase() || (['success', 'failure', 'cancelled', 'skipped'].includes(status) ? status : null);
|
||||
return {
|
||||
id: run.id ?? run.run_id ?? run.task_id ?? null,
|
||||
runNumber: run.run_number ?? run.index ?? run.id ?? null,
|
||||
name: run.name || run.workflow_name || run.workflow_id || 'Workflow',
|
||||
event: run.event || null,
|
||||
status,
|
||||
conclusion,
|
||||
headSha: run.head_sha || run.commit_sha || run.commit?.sha || null,
|
||||
headBranch: run.head_branch || run.ref || run.branch || null,
|
||||
workflowPath: run.path || run.workflow_path || run.workflow_file || null,
|
||||
displayTitle: run.display_title || run.title || run.name || null,
|
||||
actor: run.actor?.login || run.trigger_user?.login || run.user?.login || null,
|
||||
createdAt: run.created_at || run.started || run.start_time || null,
|
||||
updatedAt: run.updated_at || run.stopped || run.end_time || null,
|
||||
htmlUrl: run.html_url || run.url || null,
|
||||
raw: run
|
||||
};
|
||||
}
|
||||
|
||||
async listWorkflowRuns({ owner, repo, sha, branch, limit = 30 } = {}) {
|
||||
const base = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions`;
|
||||
const normalizedLimit = String(Math.min(Math.max(limit, 1), 100));
|
||||
const filtered = new URLSearchParams({ limit: normalizedLimit });
|
||||
if (sha) filtered.set('head_sha', sha);
|
||||
if (branch) filtered.set('branch', branch);
|
||||
const basic = new URLSearchParams({ limit: normalizedLimit });
|
||||
|
||||
const tryEndpoint = async (endpoint) => {
|
||||
try {
|
||||
return await this.request(`${base}/${endpoint}?${filtered}`);
|
||||
} catch (error) {
|
||||
// Action API query support differs across Gitea releases. Retry without
|
||||
// optional filters and apply SHA/branch matching locally.
|
||||
if (![400, 422].includes(error.status) || String(filtered) === String(basic)) throw error;
|
||||
return this.request(`${base}/${endpoint}?${basic}`);
|
||||
}
|
||||
};
|
||||
|
||||
let result;
|
||||
let source = 'runs';
|
||||
try {
|
||||
result = await tryEndpoint('runs');
|
||||
} catch (error) {
|
||||
if (![404, 405].includes(error.status)) throw error;
|
||||
source = 'tasks';
|
||||
result = await tryEndpoint('tasks');
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
const items = Array.isArray(data) ? data : data?.workflow_runs || data?.runs || data?.tasks || [];
|
||||
return { source, runs: items.map((item) => this.normalizeRun(item)).filter(Boolean), totalCount: data?.total_count ?? items.length };
|
||||
}
|
||||
|
||||
async findWorkflowRun({ owner, repo, sha, branch, workflowFile, dispatchedAt, excludeRunIds = [] }) {
|
||||
const { runs, source } = await this.listWorkflowRuns({ owner, repo, sha, branch, limit: 50 });
|
||||
const earliest = dispatchedAt ? new Date(dispatchedAt).getTime() - 120_000 : 0;
|
||||
const workflowBase = String(workflowFile || '').split('/').pop();
|
||||
const excluded = new Set((excludeRunIds || []).map((value) => String(value)));
|
||||
const candidates = runs.filter((run) => {
|
||||
if (run.id !== null && run.id !== undefined && excluded.has(String(run.id))) return false;
|
||||
if (sha && run.headSha && run.headSha.toLowerCase() !== sha.toLowerCase()) return false;
|
||||
if (branch && run.headBranch && run.headBranch.replace(/^refs\/heads\//, '') !== branch) return false;
|
||||
if (earliest && run.createdAt && new Date(run.createdAt).getTime() < earliest) return false;
|
||||
if (workflowBase && run.workflowPath) {
|
||||
const runBase = String(run.workflowPath).split('/').pop();
|
||||
if (runBase && runBase !== workflowBase) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
candidates.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
|
||||
return { source, run: candidates[0] || null };
|
||||
}
|
||||
|
||||
async listWorkflowJobs({ owner, repo, runNumber }) {
|
||||
if (runNumber === null || runNumber === undefined) return [];
|
||||
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs/${encodeURIComponent(runNumber)}/jobs?limit=100`);
|
||||
const data = result.data;
|
||||
const jobs = Array.isArray(data) ? data : data?.jobs || [];
|
||||
return jobs.map((job) => ({
|
||||
id: job.id,
|
||||
name: job.name || job.job_name || `Job ${job.id}`,
|
||||
status: String(job.status || '').toLowerCase(),
|
||||
conclusion: String(job.conclusion || '').toLowerCase() || null,
|
||||
startedAt: job.started_at || null,
|
||||
completedAt: job.completed_at || null,
|
||||
steps: Array.isArray(job.steps) ? job.steps.map((step) => ({
|
||||
name: step.name,
|
||||
status: String(step.status || '').toLowerCase(),
|
||||
conclusion: String(step.conclusion || '').toLowerCase() || null,
|
||||
number: step.number
|
||||
})) : []
|
||||
}));
|
||||
}
|
||||
|
||||
async getJobLogs({ owner, repo, jobId }) {
|
||||
if (!jobId) return '';
|
||||
try {
|
||||
const result = await this.request(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/jobs/${encodeURIComponent(jobId)}/logs`, {
|
||||
accept: 'text/plain, application/octet-stream',
|
||||
responseType: 'text',
|
||||
timeout: 60_000
|
||||
});
|
||||
return String(result.data || '').slice(-500_000);
|
||||
} catch (error) {
|
||||
if ([404, 410].includes(error.status)) return '';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { GiteaService };
|
||||
@@ -0,0 +1,409 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const { fileURLToPath } = require('node:url');
|
||||
const { ipcMain, dialog, shell, app } = require('electron');
|
||||
const { matchRemoteToRepository } = require('../shared/repository-match.cjs');
|
||||
const { cloneDirectoryName, resolveCloneTarget } = require('../shared/clone-target.cjs');
|
||||
|
||||
let diagnosticsService = null;
|
||||
const TRUSTED_RENDERER_PATH = path.resolve(__dirname, '..', 'renderer', 'index.html');
|
||||
|
||||
function toErrorPayload(error) {
|
||||
return {
|
||||
message: error?.message || 'Unknown error',
|
||||
code: error?.code || null,
|
||||
status: error?.status || null,
|
||||
recoverable: Boolean(error?.recoverable),
|
||||
commitSha: error?.commitSha || null
|
||||
};
|
||||
}
|
||||
|
||||
function assertTrustedSender(event) {
|
||||
const url = event?.senderFrame?.url || event?.sender?.getURL?.() || '';
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'file:') throw new Error('not a file URL');
|
||||
const senderPath = path.resolve(fileURLToPath(parsed));
|
||||
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
||||
if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH)) throw new Error('unexpected renderer file');
|
||||
} catch {
|
||||
throw new Error('Rejected IPC request from an untrusted renderer origin.');
|
||||
}
|
||||
}
|
||||
|
||||
function register(channel, handler) {
|
||||
ipcMain.handle(channel, async (event, payload) => {
|
||||
const started = Date.now();
|
||||
try {
|
||||
assertTrustedSender(event);
|
||||
const data = await handler(payload || {}, event);
|
||||
await diagnosticsService?.debug('ipc.completed', { channel, durationMs: Date.now() - started });
|
||||
return { ok: true, data };
|
||||
} catch (error) {
|
||||
await diagnosticsService?.error('ipc.failed', {
|
||||
channel,
|
||||
durationMs: Date.now() - started,
|
||||
error: { name: error?.name, message: error?.message, code: error?.code, status: error?.status, stack: error?.stack }
|
||||
});
|
||||
console.error(`[${channel}]`, error);
|
||||
return { ok: false, error: toErrorPayload(error) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerIpc({ store, git, gitea, repositories, deployments, unraid, ssh, updates, preflight, diagnostics, monitor }) {
|
||||
diagnosticsService = diagnostics;
|
||||
const withRepositoryPause = async (localPath, action) => {
|
||||
monitor?.pause(localPath);
|
||||
try { return await action(); }
|
||||
finally { monitor?.resume(localPath); }
|
||||
};
|
||||
|
||||
const canonicalPath = async (value) => {
|
||||
const resolved = path.resolve(String(value || ''));
|
||||
return fs.realpath(resolved).catch(() => resolved);
|
||||
};
|
||||
|
||||
const assertKnownRepositoryPath = async (localPath) => {
|
||||
const candidate = await canonicalPath(localPath);
|
||||
let knownPaths = repositories.getWatchPaths();
|
||||
if (!knownPaths.length && store.data.setupComplete) {
|
||||
await repositories.refresh();
|
||||
knownPaths = repositories.getWatchPaths();
|
||||
}
|
||||
const canonicalKnown = await Promise.all(knownPaths.map(canonicalPath));
|
||||
if (!canonicalKnown.some((known) => known === candidate)) throw new Error('The requested local repository is not linked or discovered by ForgeFlow.');
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const resolveRepository = async (repositoryPayload) => {
|
||||
const fullName = String(repositoryPayload?.fullName || '').trim();
|
||||
if (!fullName) throw new Error('Repository identity is required.');
|
||||
const current = (await repositories.refresh()).find((item) => item.fullName === fullName);
|
||||
if (!current) throw new Error('The repository is no longer available through the configured Gitea account.');
|
||||
return current;
|
||||
};
|
||||
|
||||
|
||||
const assertProjectRoot = async (rootValue) => {
|
||||
const root = await canonicalPath(rootValue);
|
||||
const stat = await fs.stat(root).catch(() => null);
|
||||
if (!stat?.isDirectory()) throw new Error('The selected project root no longer exists.');
|
||||
return root;
|
||||
};
|
||||
|
||||
const cloneRepositoryInto = async (fullName, projectRoot) => {
|
||||
const current = await resolveRepository({ fullName });
|
||||
if (current.localPath) throw new Error('This repository already has a linked local folder.');
|
||||
|
||||
const remoteUrl = current.preferredCloneUrl || current.cloneUrl || current.sshUrl;
|
||||
if (!remoteUrl) throw new Error('Gitea did not provide a usable clone URL for this repository.');
|
||||
|
||||
const root = await assertProjectRoot(projectRoot);
|
||||
const { target } = resolveCloneTarget(root, remoteUrl);
|
||||
const status = await git.clone(remoteUrl, target);
|
||||
|
||||
await store.saveMapping(current.fullName, target);
|
||||
const result = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
await diagnostics.info(status.reused ? 'repository.clone.reused' : 'repository.cloned', {
|
||||
fullName: current.fullName,
|
||||
projectRoot: root,
|
||||
target,
|
||||
head: status.head,
|
||||
branch: status.branch?.head
|
||||
});
|
||||
|
||||
return {
|
||||
target,
|
||||
status,
|
||||
reused: Boolean(status.reused),
|
||||
repositories: result,
|
||||
state: store.getPublicState()
|
||||
};
|
||||
};
|
||||
|
||||
register('app:bootstrap', async () => ({
|
||||
appVersion: app.getVersion(),
|
||||
platform: process.platform,
|
||||
state: store.getPublicState(),
|
||||
git: await git.isAvailable(),
|
||||
diagnostics: await diagnostics.getStatus()
|
||||
}));
|
||||
|
||||
register('dialog:select-directory', async ({ title = 'Select folder', defaultPath }) => {
|
||||
const result = await dialog.showOpenDialog({ title, defaultPath, properties: ['openDirectory', 'createDirectory'] });
|
||||
return result.canceled ? null : result.filePaths[0];
|
||||
});
|
||||
|
||||
register('dialog:select-key-file', async ({ title = 'Select SSH private key', defaultPath }) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title,
|
||||
defaultPath,
|
||||
properties: ['openFile']
|
||||
});
|
||||
return result.canceled ? null : result.filePaths[0];
|
||||
});
|
||||
|
||||
register('setup:preflight', ({ baseUrl, token, roots }) => preflight.runSystem({ baseUrl, token, roots }));
|
||||
register('setup:validate-gitea', ({ baseUrl, token }) => gitea.validateConnection(baseUrl, token));
|
||||
register('setup:complete', async ({ baseUrl, token, workspaceRoots }) => {
|
||||
const report = await preflight.runSystem({ baseUrl, token, roots: workspaceRoots });
|
||||
if (!report.summary.ready || !report.giteaValidation) throw new Error('Setup readiness checks must pass before configuration can be completed.');
|
||||
const validation = report.giteaValidation;
|
||||
const result = await store.completeSetup({ baseUrl: validation.baseUrl, token, user: validation.user, workspaceRoots });
|
||||
await diagnostics.info('setup.completed', { baseUrl: validation.baseUrl, user: validation.user?.login || null, workspaceRootCount: workspaceRoots?.length || 0, tokenPersistent: result.tokenState.persistent });
|
||||
return result;
|
||||
});
|
||||
|
||||
register('settings:update-gitea', async ({ baseUrl, token }) => {
|
||||
const effectiveToken = String(token || '').trim() || store.getToken();
|
||||
const validation = await gitea.validateConnection(baseUrl, effectiveToken);
|
||||
const tokenState = await store.updateGitea({ baseUrl: validation.baseUrl, token, user: validation.user });
|
||||
await diagnostics.info('settings.gitea.updated', { baseUrl: validation.baseUrl, user: validation.user?.login || null, tokenPersistent: tokenState.persistent, tokenPreserved: tokenState.preserved });
|
||||
return { validation, tokenState, state: store.getPublicState() };
|
||||
});
|
||||
|
||||
register('settings:set-roots', async ({ roots }) => {
|
||||
store.data.workspaceRoots = [...new Set((roots || []).filter(Boolean))];
|
||||
await store.save();
|
||||
await diagnostics.info('settings.workspace-roots.updated', { rootCount: store.data.workspaceRoots.length, roots: store.data.workspaceRoots });
|
||||
return store.getPublicState();
|
||||
});
|
||||
|
||||
register('settings:set-appearance', async ({ appearance }) => {
|
||||
if (!['dark', 'light', 'system'].includes(appearance)) throw new Error('Unsupported appearance setting.');
|
||||
store.data.appearance = appearance;
|
||||
await store.save();
|
||||
return store.getPublicState();
|
||||
});
|
||||
|
||||
register('settings:set-preferences', async ({ preferences }) => {
|
||||
const state = await store.setPreferences(preferences);
|
||||
monitor?.restart();
|
||||
await diagnostics.info('settings.preferences.updated', { preferences: state.preferences });
|
||||
return state;
|
||||
});
|
||||
|
||||
register('updates:preferences', ({ updates: next }) => store.setUpdatePreferences(next));
|
||||
register('updates:check', () => updates.check());
|
||||
register('updates:download', () => updates.download());
|
||||
register('updates:apply', async () => {
|
||||
const result = await updates.apply();
|
||||
setTimeout(() => app.quit(), 650).unref?.();
|
||||
return result;
|
||||
});
|
||||
|
||||
register('server:save', async ({ server, password = '', passphrase = '' }) => {
|
||||
const saved = await store.saveServer(server, { password, passphrase });
|
||||
await diagnostics.info('server.saved', {
|
||||
serverId: saved.id,
|
||||
name: saved.name,
|
||||
host: saved.host,
|
||||
port: saved.port,
|
||||
username: saved.username,
|
||||
authType: saved.authType,
|
||||
basePath: saved.basePath
|
||||
});
|
||||
return { server: saved, state: store.getPublicState() };
|
||||
});
|
||||
register('server:delete', async ({ serverId }) => {
|
||||
await store.deleteServer(serverId);
|
||||
await diagnostics.info('server.deleted', { serverId });
|
||||
return store.getPublicState();
|
||||
});
|
||||
register('server:test', async ({ serverId }) => {
|
||||
const server = store.getServer(serverId);
|
||||
if (!server) throw new Error('The configured server no longer exists.');
|
||||
const result = await ssh.test(serverId, { trustOnFirstUse: !server.hostFingerprint });
|
||||
if (!server.hostFingerprint) {
|
||||
await store.saveServer({ ...server, hostFingerprint: result.fingerprint }, {});
|
||||
result.trusted = true;
|
||||
}
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register('server:inspect-project', async ({ repository, profileId }) => unraid.inspect({ repository: await resolveRepository(repository), profileId }));
|
||||
|
||||
register('repositories:refresh', async () => {
|
||||
const result = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
return result;
|
||||
});
|
||||
|
||||
register('repositories:discover', async ({ roots }) => {
|
||||
const paths = await repositories.discoverAll(roots || store.data.workspaceRoots);
|
||||
return repositories.getLocalDescriptors(paths);
|
||||
});
|
||||
|
||||
register('repository:favorite', async ({ fullName, favorite }) => store.setFavorite(fullName, favorite));
|
||||
|
||||
register('repository:link', async ({ fullName, localPath }) => {
|
||||
await git.ensureRepository(localPath);
|
||||
const remoteUrl = await git.getRemoteUrl(localPath).catch(() => '');
|
||||
if (!remoteUrl || !matchRemoteToRepository(remoteUrl, [{ full_name: fullName }])) {
|
||||
throw new Error(`The selected folder's origin does not match ${fullName}.`);
|
||||
}
|
||||
await store.saveMapping(fullName, localPath);
|
||||
await diagnostics.info('repository.linked', { fullName, localPath });
|
||||
const result = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
return result;
|
||||
});
|
||||
|
||||
register('repository:unlink', async ({ fullName }) => {
|
||||
await store.removeMapping(fullName);
|
||||
await diagnostics.info('repository.unlinked', { fullName });
|
||||
const result = await repositories.refresh();
|
||||
monitor?.setPaths(repositories.getWatchPaths());
|
||||
return result;
|
||||
});
|
||||
|
||||
register('repository:status', async ({ localPath }) => git.status(await assertKnownRepositoryPath(localPath)));
|
||||
register('repository:diff', async ({ localPath, filePath, staged }) => git.diff(await assertKnownRepositoryPath(localPath), filePath, staged));
|
||||
register('repository:stage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stage(safePath, files)); });
|
||||
register('repository:unstage', async ({ localPath, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.unstage(safePath, files)); });
|
||||
register('repository:commit', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commit(safePath, message, files)); });
|
||||
register('repository:commit-push', async ({ localPath, message, files }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.commitAndPush(safePath, message, files)); });
|
||||
register('repository:push', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.push(safePath)); });
|
||||
register('repository:fetch', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.fetch(safePath)); });
|
||||
register('repository:pull', async ({ localPath }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.pullFastForward(safePath)); });
|
||||
register('repository:history', async ({ localPath, limit }) => git.history(await assertKnownRepositoryPath(localPath), limit));
|
||||
register('repository:branches', async ({ localPath }) => git.branches(await assertKnownRepositoryPath(localPath)));
|
||||
register('repository:checkout-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.checkoutBranch(safePath, branch)); });
|
||||
register('repository:create-branch', async ({ localPath, branch }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.createBranch(safePath, branch)); });
|
||||
register('repository:stash', async ({ localPath, message }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.stash(safePath, message)); });
|
||||
register('repository:stash-list', async ({ localPath }) => git.stashList(await assertKnownRepositoryPath(localPath)));
|
||||
register('repository:stash-pop', async ({ localPath, ref }) => { const safePath = await assertKnownRepositoryPath(localPath); return withRepositoryPause(safePath, () => git.popStash(safePath, ref)); });
|
||||
|
||||
register('repository:clone', async ({ fullName, mode = 'default' }) => {
|
||||
if (!['default', 'custom'].includes(mode)) throw new Error('Unsupported clone location mode.');
|
||||
|
||||
let projectRoot = store.data.workspaceRoots[0] || null;
|
||||
if (mode === 'custom' || !projectRoot) {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: `Choose a project root for ${String(fullName || 'repository')}`,
|
||||
defaultPath: projectRoot || undefined,
|
||||
buttonLabel: 'Use this project root',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
});
|
||||
if (result.canceled || !result.filePaths[0]) return { cancelled: true };
|
||||
projectRoot = result.filePaths[0];
|
||||
}
|
||||
|
||||
return cloneRepositoryInto(fullName, projectRoot);
|
||||
});
|
||||
|
||||
register('repository:open-path', async ({ localPath }) => {
|
||||
const safePath = await assertKnownRepositoryPath(localPath);
|
||||
const error = await shell.openPath(safePath);
|
||||
if (error) throw new Error(error);
|
||||
return true;
|
||||
});
|
||||
|
||||
register('external:open', async ({ url }) => {
|
||||
const parsed = new URL(url);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Only HTTP and HTTPS links can be opened.');
|
||||
await shell.openExternal(parsed.toString());
|
||||
return true;
|
||||
});
|
||||
|
||||
register('deployment:save-profile', async ({ fullName, profile }) => {
|
||||
const saved = await store.saveDeploymentProfile(fullName, profile);
|
||||
await diagnostics.info('deployment.profile.saved', { repository: fullName, profile: saved });
|
||||
return { profile: saved, state: store.getPublicState() };
|
||||
});
|
||||
register('deployment:delete-profile', async ({ fullName, profileId }) => {
|
||||
const profiles = await store.deleteDeploymentProfile(fullName, profileId);
|
||||
await diagnostics.info('deployment.profile.deleted', { repository: fullName, profileId });
|
||||
return { profiles, state: store.getPublicState() };
|
||||
});
|
||||
register('deployment:preflight', async ({ repository, profileId }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider === 'ssh-unraid') return unraid.preflight({ repository: current, profileId });
|
||||
return preflight.runDeployment({ repository: current, profileId });
|
||||
});
|
||||
register('deployment:dispatch', async ({ repository, profileId, sha }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider === 'ssh-unraid') return unraid.deploy({ repository: current, profileId, sha });
|
||||
return deployments.deploy({ repository: current, profileId, sha });
|
||||
});
|
||||
register('deployment:rollback', async ({ repository, profileId, targetSha }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const profile = store.getDeploymentProfile(current.fullName, profileId);
|
||||
if (profile?.provider === 'ssh-unraid') return unraid.rollback({ repository: current, profileId, targetSha });
|
||||
return deployments.rollback({ repository: current, profileId, targetSha });
|
||||
});
|
||||
register('deployment:health', ({ url }) => deployments.checkHealth(url));
|
||||
register('deployment:profile-state', ({ fullName, profileId }) => {
|
||||
const profile = store.getDeploymentProfile(fullName, profileId);
|
||||
if (profile?.provider === 'ssh-unraid') return unraid.refreshProfileState(fullName, profileId);
|
||||
return deployments.refreshProfileState(fullName, profileId);
|
||||
});
|
||||
register('operations:refresh', async ({ operationId }) => {
|
||||
if (operationId) {
|
||||
const operation = store.getOperation(operationId);
|
||||
if (operation?.provider === 'ssh-unraid') return operation;
|
||||
return deployments.refreshOperation(operationId);
|
||||
}
|
||||
return deployments.refreshActiveOperations();
|
||||
});
|
||||
register('operations:get', ({ operationId }) => store.getOperation(operationId));
|
||||
|
||||
register('diagnostics:status', () => diagnostics.getStatus());
|
||||
register('diagnostics:clear', () => diagnostics.clear());
|
||||
register('diagnostics:open-folder', async () => {
|
||||
const error = await shell.openPath(diagnostics.logDirectory);
|
||||
if (error) throw new Error(error);
|
||||
return true;
|
||||
});
|
||||
register('diagnostics:export', async ({ privacyMode = 'standard' }) => {
|
||||
if (!['standard', 'strict'].includes(privacyMode)) throw new Error('Unsupported diagnostic privacy mode.');
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: 'Export ForgeFlow diagnostic bundle',
|
||||
defaultPath: path.join(app.getPath('downloads'), `ForgeFlow-Diagnostics-${new Date().toISOString().replace(/[:.]/g, '-')}.zip`),
|
||||
filters: [{ name: 'ZIP archive', extensions: ['zip'] }]
|
||||
});
|
||||
if (result.canceled || !result.filePath) return null;
|
||||
const repositoryState = await repositories.refresh().catch((error) => {
|
||||
diagnostics.warning('diagnostics.repository-snapshot.failed', error);
|
||||
return [];
|
||||
});
|
||||
const systemPreflight = await preflight.runSystem().catch((error) => ({ error: error.message }));
|
||||
const destinationPath = path.extname(result.filePath).toLowerCase() === '.zip' ? result.filePath : `${result.filePath}.zip`;
|
||||
return diagnostics.exportSupportBundle({
|
||||
destinationPath,
|
||||
publicState: store.getPublicState(),
|
||||
repositories: repositoryState,
|
||||
operations: store.data.operations,
|
||||
preflight: systemPreflight,
|
||||
privacyMode,
|
||||
extra: { appVersion: app.getVersion(), setupComplete: store.data.setupComplete }
|
||||
});
|
||||
});
|
||||
register('diagnostics:show-bundle', async ({ filePath }) => {
|
||||
if (!diagnostics.isKnownBundlePath(filePath)) throw new Error('Only the most recently generated support bundle can be revealed.');
|
||||
shell.showItemInFolder(filePath);
|
||||
return true;
|
||||
});
|
||||
register('renderer:report', async ({ level = 'info', event = 'renderer.event', details = {} }) => {
|
||||
const method = ['debug', 'info', 'warning', 'error'].includes(level) ? level : 'info';
|
||||
await diagnostics[method](`renderer.${String(event || 'event').slice(0, 120)}`, details);
|
||||
return true;
|
||||
});
|
||||
|
||||
register('app:reset', async () => {
|
||||
await diagnostics.info('app.reset.requested', {});
|
||||
store.data = store.migrate({});
|
||||
store.sessionToken = null;
|
||||
await store.save();
|
||||
monitor?.setPaths([]);
|
||||
monitor?.restart();
|
||||
return store.getPublicState();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerIpc, cloneDirectoryName, assertTrustedSender, toErrorPayload };
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const SENSITIVE_KEY = /(^|_)(token|password|passwd|authorization|secret|credential|clientsecret|client_secret|apikey|api_key|privatekey|private_key|encryptedtoken|encrypted_token)($|_)/i;
|
||||
const MAX_DIAGNOSTIC_STRING = 200_000;
|
||||
|
||||
function redactSecrets(value, secrets = []) {
|
||||
let text = String(value ?? '');
|
||||
const candidates = [...new Set((secrets || []).map((item) => String(item || '').trim()).filter((item) => item.length >= 4))]
|
||||
.sort((a, b) => b.length - a.length);
|
||||
for (const secret of candidates) text = text.split(secret).join('[REDACTED]');
|
||||
|
||||
text = text
|
||||
.replace(/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/gi, '[REDACTED PRIVATE KEY]')
|
||||
.replace(/(authorization\s*[:=]\s*(?:token|bearer|basic)\s+)[^\s,;]+/gi, '$1[REDACTED]')
|
||||
.replace(/([?&](?:access_token|token|api_key|apikey|key|secret|password)=)[^&#\s]+/gi, '$1[REDACTED]')
|
||||
.replace(/((?:access_token|token|api_key|apikey|client_secret|password|passwd|secret)\s*[=:]\s*)[^\s,;]+/gi, '$1[REDACTED]')
|
||||
.replace(/("(?:access_token|token|api_key|apikey|client_secret|password|passwd|secret)"\s*:\s*")[^"]+("?)/gi, '$1[REDACTED]$2')
|
||||
.replace(/(https?:\/\/[^\s:@/]+:)[^@\s/]+@/gi, '$1[REDACTED]@')
|
||||
.replace(/\b(?:ghp|github_pat|glpat|gitea)_[A-Za-z0-9_-]{16,}\b/g, '[REDACTED TOKEN]');
|
||||
|
||||
return text.length > MAX_DIAGNOSTIC_STRING ? `${text.slice(0, MAX_DIAGNOSTIC_STRING)}\n[TRUNCATED]` : text;
|
||||
}
|
||||
|
||||
function pathAlias(value, { homeDir = os.homedir(), cwd = process.cwd() } = {}) {
|
||||
let text = String(value ?? '');
|
||||
const replacements = [
|
||||
[homeDir, '<HOME>'],
|
||||
[cwd, '<APP_ROOT>']
|
||||
].filter(([candidate]) => candidate && candidate.length > 3)
|
||||
.sort((a, b) => b[0].length - a[0].length);
|
||||
for (const [candidate, replacement] of replacements) {
|
||||
const normalized = path.resolve(candidate);
|
||||
text = text.split(normalized).join(replacement);
|
||||
text = text.split(normalized.replace(/\\/g, '/')).join(replacement);
|
||||
text = text.split(normalized.replace(/\//g, '\\')).join(replacement);
|
||||
}
|
||||
text = text
|
||||
.replace(/[A-Za-z]:\\Users\\[^\\\s]+/g, '<HOME>')
|
||||
.replace(/\/(?:home|Users)\/[^/\s]+/g, '<HOME>');
|
||||
return text;
|
||||
}
|
||||
|
||||
function stableAlias(value, prefix = 'item') {
|
||||
const hash = crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 12);
|
||||
return `${prefix}-${hash}`;
|
||||
}
|
||||
|
||||
function sanitizeForDiagnostics(value, options = {}, seen = new WeakSet()) {
|
||||
const {
|
||||
secrets = [],
|
||||
pathMode = 'alias',
|
||||
homeDir = os.homedir(),
|
||||
cwd = process.cwd(),
|
||||
strictIdentifiers = false
|
||||
} = options;
|
||||
|
||||
if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') return value;
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (typeof value === 'string') {
|
||||
let output = redactSecrets(value, secrets);
|
||||
if (pathMode === 'alias') output = pathAlias(output, { homeDir, cwd });
|
||||
return output;
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
return sanitizeForDiagnostics({ name: value.name, message: value.message, code: value.code, stack: value.stack }, options, seen);
|
||||
}
|
||||
if (Array.isArray(value)) return value.slice(0, 1000).map((item) => sanitizeForDiagnostics(item, options, seen));
|
||||
if (typeof value !== 'object') return redactSecrets(String(value), secrets);
|
||||
if (seen.has(value)) return '[CIRCULAR]';
|
||||
seen.add(value);
|
||||
|
||||
const output = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const normalizedKey = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[-.]/g, '_');
|
||||
if (SENSITIVE_KEY.test(normalizedKey)) {
|
||||
output[key] = '[REDACTED]';
|
||||
continue;
|
||||
}
|
||||
if (strictIdentifiers && ['fullName', 'repository', 'owner', 'user', 'login', 'email'].includes(key)) {
|
||||
output[key] = stableAlias(typeof item === 'object' ? JSON.stringify(item) : item, key.toLowerCase());
|
||||
continue;
|
||||
}
|
||||
output[key] = sanitizeForDiagnostics(item, options, seen);
|
||||
}
|
||||
seen.delete(value);
|
||||
return output;
|
||||
}
|
||||
|
||||
module.exports = { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, SENSITIVE_KEY };
|
||||
@@ -0,0 +1,208 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { run } = require('./process-runner.cjs');
|
||||
|
||||
function check(id, label, status, detail, { required = false, help = '' } = {}) {
|
||||
return { id, label, status, detail, required, help };
|
||||
}
|
||||
|
||||
function summarize(checks) {
|
||||
const counts = checks.reduce((acc, item) => {
|
||||
acc[item.status] = (acc[item.status] || 0) + 1;
|
||||
return acc;
|
||||
}, { pass: 0, warning: 0, fail: 0, skipped: 0 });
|
||||
const blocking = checks.filter((item) => item.required && item.status === 'fail');
|
||||
return { counts, blocking: blocking.map((item) => item.id), ready: blocking.length === 0 };
|
||||
}
|
||||
|
||||
class PreflightService {
|
||||
constructor({ store, git, gitea, deployments, diagnostics, userDataPath, secureStorageAvailable = () => false }) {
|
||||
this.store = store;
|
||||
this.git = git;
|
||||
this.gitea = gitea;
|
||||
this.deployments = deployments;
|
||||
this.diagnostics = diagnostics;
|
||||
this.userDataPath = userDataPath;
|
||||
this.secureStorageAvailable = secureStorageAvailable;
|
||||
}
|
||||
|
||||
async writableDirectory(directory) {
|
||||
const marker = path.join(directory, `.forgeflow-write-test-${process.pid}-${Date.now()}`);
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
await fs.writeFile(marker, 'ok', { mode: 0o600 });
|
||||
await fs.rm(marker, { force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
async gitIdentity() {
|
||||
const [name, email] = await Promise.all([
|
||||
run('git', ['config', '--global', '--get', 'user.name'], { allowExitCodes: [1], timeout: 10_000 }),
|
||||
run('git', ['config', '--global', '--get', 'user.email'], { allowExitCodes: [1], timeout: 10_000 })
|
||||
]);
|
||||
return { name: name.stdout.trim(), email: email.stdout.trim() };
|
||||
}
|
||||
|
||||
async runSystem({ baseUrl = '', token = '', roots = [] } = {}) {
|
||||
const startedAt = new Date().toISOString();
|
||||
const checks = [];
|
||||
|
||||
const git = await this.git.isAvailable();
|
||||
checks.push(check('git.available', 'Git command line', git.available ? 'pass' : 'fail', git.available ? git.version : git.error || 'Git was not found on PATH.', {
|
||||
required: true,
|
||||
help: 'Install Git for Windows and ensure git.exe is available on PATH.'
|
||||
}));
|
||||
|
||||
if (git.available) {
|
||||
try {
|
||||
const identity = await this.gitIdentity();
|
||||
checks.push(check('git.identity', 'Git author identity', identity.name && identity.email ? 'pass' : 'warning', identity.name && identity.email ? `${identity.name} <${identity.email}>` : 'Global user.name or user.email is missing.', {
|
||||
help: 'Set git config --global user.name and user.email before creating commits.'
|
||||
}));
|
||||
} catch (error) {
|
||||
checks.push(check('git.identity', 'Git author identity', 'warning', error.message));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.writableDirectory(this.userDataPath);
|
||||
checks.push(check('storage.userdata', 'Application data storage', 'pass', 'ForgeFlow can write its local configuration.', { required: true }));
|
||||
} catch (error) {
|
||||
checks.push(check('storage.userdata', 'Application data storage', 'fail', error.message, { required: true }));
|
||||
}
|
||||
|
||||
try {
|
||||
await this.writableDirectory(this.diagnostics.logDirectory);
|
||||
checks.push(check('storage.diagnostics', 'Diagnostic log storage', 'pass', 'The diagnostic directory is writable.', { required: true }));
|
||||
} catch (error) {
|
||||
checks.push(check('storage.diagnostics', 'Diagnostic log storage', 'fail', error.message, { required: true }));
|
||||
}
|
||||
|
||||
checks.push(check('storage.credentials', 'Protected credential storage', this.secureStorageAvailable() ? 'pass' : 'warning', this.secureStorageAvailable()
|
||||
? 'The operating system can encrypt the Gitea token at rest.'
|
||||
: 'OS credential encryption is unavailable; the token will remain session-only.', {
|
||||
help: 'Use a normal signed-in desktop session and make sure the OS credential service is available.'
|
||||
}));
|
||||
|
||||
const normalizedRoots = [...new Set((roots || []).map((item) => String(item || '').trim()).filter(Boolean))];
|
||||
if (!normalizedRoots.length) {
|
||||
checks.push(check('workspace.roots', 'Development folders', 'warning', 'No development folder has been selected yet.'));
|
||||
} else {
|
||||
for (let index = 0; index < normalizedRoots.length; index += 1) {
|
||||
const root = normalizedRoots[index];
|
||||
try {
|
||||
const stat = await fs.stat(root);
|
||||
checks.push(check(`workspace.root.${index}`, `Development folder ${index + 1}`, stat.isDirectory() ? 'pass' : 'fail', stat.isDirectory() ? root : 'The selected path is not a directory.', { required: true }));
|
||||
} catch (error) {
|
||||
checks.push(check(`workspace.root.${index}`, `Development folder ${index + 1}`, 'fail', error.message, { required: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveBaseUrl = String(baseUrl || this.store.data.gitea.baseUrl || '').trim();
|
||||
const effectiveToken = String(token || this.store.getToken() || '').trim();
|
||||
let giteaValidation = null;
|
||||
if (!effectiveBaseUrl || !effectiveToken) {
|
||||
checks.push(check('gitea.connection', 'Gitea connection', 'warning', 'Enter the Gitea URL and a local access token to test the connection.'));
|
||||
} else {
|
||||
try {
|
||||
giteaValidation = await this.gitea.validateConnection(effectiveBaseUrl, effectiveToken);
|
||||
checks.push(check('gitea.connection', 'Gitea connection', 'pass', `Connected to Gitea ${giteaValidation.version || 'unknown version'} as ${giteaValidation.user?.login || 'user'}.`, { required: true }));
|
||||
checks.push(check('gitea.repositories', 'Repository access', giteaValidation.repositoryCount >= 0 ? 'pass' : 'warning', `${giteaValidation.repositoryCount} accessible repositories returned.`));
|
||||
} catch (error) {
|
||||
checks.push(check('gitea.connection', 'Gitea connection', 'fail', error.message, { required: true }));
|
||||
}
|
||||
}
|
||||
|
||||
const result = { kind: 'system', startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks), giteaValidation };
|
||||
await this.diagnostics.info('preflight.system.completed', { summary: result.summary, checks });
|
||||
return result;
|
||||
}
|
||||
|
||||
async fileExists(filePath) {
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
return Boolean(stat?.isFile());
|
||||
}
|
||||
|
||||
async runDeployment({ repository, profileId }) {
|
||||
const checks = [];
|
||||
const startedAt = new Date().toISOString();
|
||||
if (!repository?.fullName) throw new Error('Repository identity is required.');
|
||||
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
|
||||
if (!profile) throw new Error('Deployment profile not found.');
|
||||
|
||||
checks.push(check('repository.linked', 'Local repository link', repository.localPath ? 'pass' : 'fail', repository.localPath || 'No local folder is linked.', { required: true }));
|
||||
if (!repository.localPath) {
|
||||
const result = { kind: 'deployment', repository: repository.fullName, profileId, startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks) };
|
||||
await this.diagnostics.info('preflight.deployment.completed', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
let status = null;
|
||||
try {
|
||||
status = await this.git.status(repository.localPath);
|
||||
checks.push(check('git.repository', 'Git working tree', 'pass', status.root, { required: true }));
|
||||
checks.push(check('git.branch', 'Allowed branch', status.branch.head === profile.branch ? 'pass' : 'fail', `Current: ${status.branch.head || 'detached'}; required: ${profile.branch}.`, { required: true }));
|
||||
checks.push(check('git.clean', 'Clean working tree', status.clean ? 'pass' : 'fail', status.clean ? 'No uncommitted changes.' : `${status.counts.changed} changed file(s) remain.`, { required: true }));
|
||||
checks.push(check('git.upstream', 'Published upstream', status.branch.upstream ? 'pass' : 'fail', status.branch.upstream || 'No upstream branch configured.', { required: true }));
|
||||
checks.push(check('git.sync', 'Local and Gitea synchronized', !status.branch.ahead && !status.branch.behind ? 'pass' : 'fail', `${status.branch.ahead || 0} ahead, ${status.branch.behind || 0} behind.`, { required: true }));
|
||||
if (status.head) {
|
||||
try {
|
||||
await this.git.verifyCommitOnRemoteBranch(repository.localPath, status.head, profile.branch);
|
||||
checks.push(check('git.remote-sha', 'Exact commit on remote branch', 'pass', `${status.head.slice(0, 7)} exists on origin/${profile.branch}.`, { required: true }));
|
||||
} catch (error) {
|
||||
checks.push(check('git.remote-sha', 'Exact commit on remote branch', 'fail', error.message, { required: true }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
checks.push(check('git.repository', 'Git working tree', 'fail', error.message, { required: true }));
|
||||
}
|
||||
|
||||
const workflowPath = path.join(repository.localPath, '.gitea', 'workflows', profile.workflowFile);
|
||||
checks.push(check('workflow.deploy.local', 'Deploy workflow in local repository', await this.fileExists(workflowPath) ? 'pass' : 'fail', workflowPath, { required: true }));
|
||||
if (profile.rollbackWorkflowFile) {
|
||||
const rollbackPath = path.join(repository.localPath, '.gitea', 'workflows', profile.rollbackWorkflowFile);
|
||||
checks.push(check('workflow.rollback.local', 'Rollback workflow in local repository', await this.fileExists(rollbackPath) ? 'pass' : 'warning', rollbackPath));
|
||||
}
|
||||
|
||||
try {
|
||||
const [owner, repo] = repository.fullName.split('/');
|
||||
const remoteWorkflow = await this.gitea.repositoryFileExists({ owner, repo, filePath: `.gitea/workflows/${profile.workflowFile}`, ref: profile.branch });
|
||||
checks.push(check('workflow.deploy.remote', 'Deploy workflow on Gitea branch', remoteWorkflow ? 'pass' : 'fail', remoteWorkflow ? `${profile.workflowFile} exists on ${profile.branch}.` : `${profile.workflowFile} is not present on ${profile.branch}.`, { required: true }));
|
||||
try {
|
||||
await this.gitea.listWorkflowRuns({ owner, repo, branch: profile.branch, limit: 1 });
|
||||
checks.push(check('gitea.actions', 'Gitea Actions API', 'pass', 'The Actions runs endpoint is accessible.', { required: true }));
|
||||
} catch (error) {
|
||||
checks.push(check('gitea.actions', 'Gitea Actions API', 'fail', error.message, { required: true }));
|
||||
}
|
||||
} catch (error) {
|
||||
checks.push(check('workflow.deploy.remote', 'Deploy workflow on Gitea branch', 'fail', error.message, { required: true }));
|
||||
}
|
||||
|
||||
if (profile.statusUrl) {
|
||||
const state = await this.deployments.readStatusEndpoint(profile.statusUrl);
|
||||
checks.push(check('server.status.configured', 'Server version endpoint configured', 'pass', profile.statusUrl, { required: true }));
|
||||
checks.push(check('server.status.reachable', 'Server version endpoint reachable', state.reachable && state.ok ? 'pass' : 'warning', state.reachable && state.ok ? `Endpoint reachable${state.liveSha ? `; live ${state.liveSha.slice(0, 7)}` : '; no live SHA reported yet'}.` : state.error || `HTTP ${state.status || 'unavailable'}.`, { help: 'The first deployment may create the status file. Successful completion still requires the endpoint to return the exact SHA and request ID.' }));
|
||||
if (state.reachable && state.ok) {
|
||||
const identityMatches = (!state.repository || state.repository === repository.fullName) && (!state.environment || state.environment === profile.environment);
|
||||
checks.push(check('server.status.identity', 'Status endpoint target identity', identityMatches ? (state.repository && state.environment ? 'pass' : 'warning') : 'fail', state.repository && state.environment ? `${state.repository} / ${state.environment}` : 'Repository or environment is not present in the current status document.', { required: !identityMatches }));
|
||||
}
|
||||
} else checks.push(check('server.status.configured', 'Server version endpoint configured', 'fail', 'A status URL is required for exact post-deployment verification.', { required: true }));
|
||||
|
||||
if (profile.healthcheckUrl) {
|
||||
const health = await this.deployments.checkHealth(profile.healthcheckUrl);
|
||||
checks.push(check('server.health', 'Application healthcheck', health.healthy ? 'pass' : 'warning', health.healthy ? `HTTP ${health.status} in ${health.latencyMs} ms.` : health.error || `HTTP ${health.status || 'unavailable'}.`));
|
||||
} else checks.push(check('server.health', 'Application healthcheck', 'warning', 'No healthcheck URL is configured.'));
|
||||
|
||||
const result = {
|
||||
kind: 'deployment', repository: repository.fullName, profileId, profileName: profile.name,
|
||||
startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks),
|
||||
head: status?.head || null
|
||||
};
|
||||
await this.diagnostics.info('preflight.deployment.completed', { repository: repository.fullName, profileId, summary: result.summary, checks });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PreflightService, summarize, check };
|
||||
@@ -0,0 +1,37 @@
|
||||
'use strict';
|
||||
|
||||
const { execFile } = require('node:child_process');
|
||||
|
||||
function run(command, args = [], options = {}) {
|
||||
const {
|
||||
cwd,
|
||||
timeout = 60_000,
|
||||
maxBuffer = 8 * 1024 * 1024,
|
||||
env,
|
||||
allowExitCodes = []
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, {
|
||||
cwd,
|
||||
timeout,
|
||||
maxBuffer,
|
||||
windowsHide: true,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...(env || {}) }
|
||||
}, (error, stdout, stderr) => {
|
||||
if (error && !allowExitCodes.includes(error.code)) {
|
||||
const wrapped = new Error((stderr || stdout || error.message).trim());
|
||||
wrapped.code = error.code;
|
||||
wrapped.stdout = stdout;
|
||||
wrapped.stderr = stderr;
|
||||
wrapped.command = `${command} ${args.join(' ')}`;
|
||||
reject(wrapped);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: stdout || '', stderr: stderr || '', exitCode: error?.code || 0 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
class RepositoryMonitor {
|
||||
constructor({ store, git, onChange, diagnostics = null }) {
|
||||
this.store = store;
|
||||
this.git = git;
|
||||
this.onChange = onChange;
|
||||
this.diagnostics = diagnostics;
|
||||
this.paths = [];
|
||||
this.fingerprints = new Map();
|
||||
this.timer = null;
|
||||
this.running = false;
|
||||
this.paused = new Set();
|
||||
}
|
||||
|
||||
setPaths(paths) {
|
||||
this.paths = [...new Set((paths || []).filter(Boolean))];
|
||||
for (const existing of [...this.fingerprints.keys()]) {
|
||||
if (!this.paths.includes(existing)) this.fingerprints.delete(existing);
|
||||
}
|
||||
}
|
||||
|
||||
pause(localPath) { if (localPath) this.paused.add(localPath); }
|
||||
resume(localPath) { if (localPath) this.paused.delete(localPath); }
|
||||
|
||||
restart() {
|
||||
this.stop();
|
||||
if (!this.store.data.preferences.autoRefresh) return;
|
||||
const seconds = Math.min(Math.max(Number(this.store.data.preferences.repositoryPollSeconds) || 4, 2), 60);
|
||||
this.timer = setInterval(() => this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error)), seconds * 1000);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
async tick() {
|
||||
if (this.running || !this.paths.length) return;
|
||||
this.running = true;
|
||||
try {
|
||||
for (const localPath of this.paths) {
|
||||
if (this.paused.has(localPath)) continue;
|
||||
try {
|
||||
const status = await this.git.status(localPath);
|
||||
const next = this.git.statusFingerprint(status);
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
this.fingerprints.set(localPath, next);
|
||||
if (previous && previous !== next) {
|
||||
await this.diagnostics?.debug('repository-monitor.changed', { localPath, head: status.head, branch: status.branch?.head, counts: status.counts });
|
||||
this.onChange?.({ localPath, status, reason: 'working-tree-changed' });
|
||||
}
|
||||
} catch (error) {
|
||||
const next = `error:${error.message}`;
|
||||
const previous = this.fingerprints.get(localPath);
|
||||
this.fingerprints.set(localPath, next);
|
||||
if (previous && previous !== next) {
|
||||
await this.diagnostics?.warning('repository-monitor.unavailable', { localPath, message: error.message });
|
||||
this.onChange?.({ localPath, error: error.message, reason: 'repository-unavailable' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { RepositoryMonitor };
|
||||
@@ -0,0 +1,193 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { matchRemoteToRepository, repositoryKey } = require('../shared/repository-match.cjs');
|
||||
|
||||
const SKIP_DIRECTORIES = new Set([
|
||||
'.git', '.svn', '.hg', 'node_modules', '.next', '.nuxt', 'dist', 'build', 'coverage',
|
||||
'.cache', '.venv', 'venv', '__pycache__', '$RECYCLE.BIN', 'System Volume Information'
|
||||
]);
|
||||
|
||||
async function mapLimit(items, limit, mapper) {
|
||||
const output = new Array(items.length);
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor++;
|
||||
output[index] = await mapper(items[index], index);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return output;
|
||||
}
|
||||
|
||||
class RepositoryService {
|
||||
constructor(store, gitService, giteaService, diagnostics = null) {
|
||||
this.store = store;
|
||||
this.git = gitService;
|
||||
this.gitea = giteaService;
|
||||
this.diagnostics = diagnostics;
|
||||
this.lastKnownLocalPaths = [];
|
||||
}
|
||||
|
||||
async discoverInRoot(root, maxDepth = 4) {
|
||||
const found = [];
|
||||
const seen = new Set();
|
||||
|
||||
const visit = async (directory, depth) => {
|
||||
let real;
|
||||
try { real = await fs.realpath(directory); } catch { return; }
|
||||
if (seen.has(real)) return;
|
||||
seen.add(real);
|
||||
|
||||
const gitMarker = path.join(directory, '.git');
|
||||
const marker = await fs.stat(gitMarker).catch(() => null);
|
||||
if (marker) {
|
||||
found.push(real);
|
||||
return;
|
||||
}
|
||||
if (depth >= maxDepth) return;
|
||||
|
||||
let entries;
|
||||
try { entries = await fs.readdir(real, { withFileTypes: true }); } catch { return; }
|
||||
await mapLimit(entries
|
||||
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && !SKIP_DIRECTORIES.has(entry.name)), 12,
|
||||
(entry) => visit(path.join(real, entry.name), depth + 1));
|
||||
};
|
||||
|
||||
await visit(root, 0);
|
||||
return found;
|
||||
}
|
||||
|
||||
async discoverAll(roots) {
|
||||
const grouped = await mapLimit((roots || []).filter(Boolean), 4, (root) => this.discoverInRoot(root));
|
||||
return [...new Set(grouped.flat())];
|
||||
}
|
||||
|
||||
async getLocalDescriptors(paths) {
|
||||
return mapLimit(paths, 5, async (localPath) => {
|
||||
try {
|
||||
const status = await this.git.status(localPath);
|
||||
return { localPath: status.root, remoteUrl: status.remoteUrl, status };
|
||||
} catch (error) {
|
||||
return { localPath, remoteUrl: '', status: null, error: error.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getWatchPaths() {
|
||||
return [...this.lastKnownLocalPaths];
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const started = Date.now();
|
||||
const remoteRepositories = this.store.data.gitea.baseUrl && this.store.getToken()
|
||||
? await this.gitea.listRepositories()
|
||||
: [];
|
||||
|
||||
const discoveredPaths = await this.discoverAll(this.store.data.workspaceRoots);
|
||||
const mappedPaths = Object.values(this.store.data.repositoryMappings || {});
|
||||
const localPaths = [...new Set([...discoveredPaths, ...mappedPaths])];
|
||||
const localDescriptors = await this.getLocalDescriptors(localPaths);
|
||||
this.lastKnownLocalPaths = localDescriptors.filter((item) => item.status).map((item) => item.status.root);
|
||||
|
||||
const usedLocalPaths = new Set();
|
||||
const repositories = [];
|
||||
|
||||
for (const remote of remoteRepositories) {
|
||||
const key = repositoryKey(remote);
|
||||
const explicitPath = this.store.data.repositoryMappings[key];
|
||||
let local = explicitPath ? localDescriptors.find((item) => path.resolve(item.localPath) === path.resolve(explicitPath)) : null;
|
||||
if (!local) local = localDescriptors.find((item) => !usedLocalPaths.has(item.localPath) && matchRemoteToRepository(item.remoteUrl, [remote]));
|
||||
if (local) usedLocalPaths.add(local.localPath);
|
||||
|
||||
const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({
|
||||
...profile,
|
||||
state: this.store.getDeploymentState(profile.id)
|
||||
}));
|
||||
repositories.push(this.decorate(remote, local, profiles));
|
||||
}
|
||||
|
||||
for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) {
|
||||
const name = path.basename(local.localPath);
|
||||
repositories.push({
|
||||
id: `local:${local.localPath}`,
|
||||
name,
|
||||
fullName: name,
|
||||
owner: { login: 'local' },
|
||||
description: 'Local repository not matched to Gitea',
|
||||
private: true,
|
||||
defaultBranch: local.status?.branch.head || 'main',
|
||||
htmlUrl: null,
|
||||
cloneUrl: null,
|
||||
sshUrl: null,
|
||||
preferredCloneUrl: null,
|
||||
localPath: local.localPath,
|
||||
localStatus: local.status,
|
||||
linkState: 'unmatched-local',
|
||||
deploymentProfiles: [],
|
||||
readyToDeploy: false,
|
||||
favorite: false,
|
||||
attention: Boolean(local.error),
|
||||
attentionReason: local.error || null
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = repositories.sort((a, b) => {
|
||||
const score = (repo) => (repo.attention ? 100 : 0)
|
||||
+ (repo.localStatus?.counts.changed ? 50 : 0)
|
||||
+ (repo.localStatus?.branch.ahead ? 30 : 0)
|
||||
+ (repo.readyToDeploy ? 20 : 0)
|
||||
+ (repo.favorite ? 5 : 0);
|
||||
return score(b) - score(a) || a.fullName.localeCompare(b.fullName);
|
||||
});
|
||||
await this.diagnostics?.debug('repositories.refresh.completed', {
|
||||
durationMs: Date.now() - started,
|
||||
remoteCount: remoteRepositories.length,
|
||||
discoveredCount: discoveredPaths.length,
|
||||
linkedCount: sorted.filter((item) => item.localPath).length,
|
||||
attentionCount: sorted.filter((item) => item.attention).length,
|
||||
readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
decorate(remote, local, profiles) {
|
||||
const status = local?.status || null;
|
||||
const hasChanges = Boolean(status?.counts.changed);
|
||||
const ahead = status?.branch.ahead || 0;
|
||||
const behind = status?.branch.behind || 0;
|
||||
const conflict = Boolean(status?.counts.conflicts);
|
||||
const profileForBranch = profiles.find((profile) => profile.branch === status?.branch.head);
|
||||
const readyToDeploy = Boolean(profileForBranch && status?.head && status?.branch.upstream && !hasChanges && ahead === 0 && behind === 0);
|
||||
const key = String(remote.full_name || '').toLowerCase();
|
||||
const preferredCloneUrl = this.store.data.preferences.preferredCloneProtocol === 'ssh'
|
||||
? (remote.ssh_url || remote.clone_url)
|
||||
: (remote.clone_url || remote.ssh_url);
|
||||
return {
|
||||
id: remote.id,
|
||||
name: remote.name,
|
||||
fullName: remote.full_name,
|
||||
owner: remote.owner,
|
||||
description: remote.description || '',
|
||||
private: remote.private,
|
||||
defaultBranch: remote.default_branch || 'main',
|
||||
htmlUrl: remote.html_url,
|
||||
cloneUrl: remote.clone_url,
|
||||
sshUrl: remote.ssh_url,
|
||||
preferredCloneUrl,
|
||||
updatedAt: remote.updated_at,
|
||||
localPath: local?.localPath || null,
|
||||
localStatus: status,
|
||||
linkState: local ? 'linked' : 'remote-only',
|
||||
deploymentProfiles: profiles,
|
||||
readyToDeploy,
|
||||
favorite: (this.store.data.favorites || []).includes(key),
|
||||
attention: conflict || behind > 0 || Boolean(local?.error),
|
||||
attentionReason: conflict ? 'Merge conflict' : behind > 0 ? `${behind} commit${behind === 1 ? '' : 's'} behind remote` : local?.error || null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { RepositoryService, SKIP_DIRECTORIES, mapLimit };
|
||||
@@ -0,0 +1,146 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
function loadSshClient() {
|
||||
try { return require('ssh2').Client; }
|
||||
catch {
|
||||
const error = new Error('The ssh2 dependency is not installed. Run npm install before configuring SSH deployments.');
|
||||
error.code = 'SSH2_NOT_INSTALLED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function fingerprintKey(key) {
|
||||
const buffer = Buffer.isBuffer(key) ? key : Buffer.from(key);
|
||||
return `SHA256:${crypto.createHash('sha256').update(buffer).digest('base64').replace(/=+$/, '')}`;
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
class SshService {
|
||||
constructor({ store, diagnostics }) {
|
||||
this.store = store;
|
||||
this.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
async connectionOptions(server, { trustOnFirstUse = false } = {}) {
|
||||
const credentials = this.store.getServerCredentials(server.id);
|
||||
let observedFingerprint = null;
|
||||
const options = {
|
||||
host: server.host,
|
||||
port: server.port || 22,
|
||||
username: server.username,
|
||||
readyTimeout: 20_000,
|
||||
keepaliveInterval: 10_000,
|
||||
keepaliveCountMax: 3,
|
||||
hostVerifier: (key) => {
|
||||
observedFingerprint = fingerprintKey(key);
|
||||
return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint);
|
||||
}
|
||||
};
|
||||
if (server.authType === 'password') {
|
||||
options.password = credentials.password;
|
||||
} else {
|
||||
options.privateKey = await fs.readFile(server.privateKeyPath);
|
||||
if (credentials.passphrase) options.passphrase = credentials.passphrase;
|
||||
}
|
||||
return { options, getObservedFingerprint: () => observedFingerprint };
|
||||
}
|
||||
|
||||
async withClient(serverId, action, options = {}) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server) throw new Error('The configured SSH server no longer exists.');
|
||||
const Client = loadSshClient();
|
||||
const connection = await this.connectionOptions(server, options);
|
||||
const client = new Client();
|
||||
const started = Date.now();
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { client.end(); } catch {}
|
||||
callback(value);
|
||||
};
|
||||
client.once('ready', async () => {
|
||||
try {
|
||||
const data = await action(client, server, connection.getObservedFingerprint());
|
||||
await this.diagnostics?.debug('ssh.connection.completed', {
|
||||
serverId,
|
||||
host: server.host,
|
||||
durationMs: Date.now() - started
|
||||
});
|
||||
finish(resolve, data);
|
||||
} catch (error) { finish(reject, error); }
|
||||
});
|
||||
client.once('error', async (error) => {
|
||||
const wrapped = new Error(`SSH connection failed: ${error.message}`);
|
||||
wrapped.code = error.code || 'SSH_CONNECTION_FAILED';
|
||||
await this.diagnostics?.warning('ssh.connection.failed', {
|
||||
serverId,
|
||||
host: server.host,
|
||||
durationMs: Date.now() - started,
|
||||
code: wrapped.code,
|
||||
message: wrapped.message
|
||||
});
|
||||
finish(reject, wrapped);
|
||||
});
|
||||
client.connect(connection.options);
|
||||
});
|
||||
}
|
||||
|
||||
execClient(client, command, { timeout = 15 * 60_000, maxOutput = 2 * 1024 * 1024 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('The SSH command timed out.')), timeout);
|
||||
client.exec(command, (error, stream) => {
|
||||
if (error) {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
stream.on('data', (chunk) => { if (stdout.length < maxOutput) stdout += chunk.toString(); });
|
||||
stream.stderr.on('data', (chunk) => { if (stderr.length < maxOutput) stderr += chunk.toString(); });
|
||||
stream.on('close', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
const failure = new Error(`Remote command failed with exit code ${code}: ${(stderr || stdout).trim().slice(-4000)}`);
|
||||
failure.code = 'SSH_COMMAND_FAILED';
|
||||
failure.exitCode = code;
|
||||
failure.signal = signal;
|
||||
reject(failure);
|
||||
} else resolve({ stdout, stderr, exitCode: code });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async test(serverId, { trustOnFirstUse = true } = {}) {
|
||||
return this.withClient(serverId, async (client, server, fingerprint) => {
|
||||
const result = await this.execClient(client, 'uname -srm && command -v git && (docker compose version || docker-compose version)', { timeout: 30_000 });
|
||||
return {
|
||||
connected: true,
|
||||
fingerprint,
|
||||
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
|
||||
output: result.stdout.trim()
|
||||
};
|
||||
}, { trustOnFirstUse });
|
||||
}
|
||||
|
||||
async exec(serverId, command, options = {}) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server?.hostFingerprint) {
|
||||
const error = new Error('Test and trust the SSH server fingerprint before running deployment commands.');
|
||||
error.code = 'SSH_HOST_NOT_TRUSTED';
|
||||
throw error;
|
||||
}
|
||||
return this.withClient(serverId, (client) => this.execClient(client, command, options), { trustOnFirstUse: false });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SshService, shellQuote, fingerprintKey };
|
||||
@@ -0,0 +1,595 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path').posix;
|
||||
const nativePath = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { shellQuote } = require('./ssh-service.cjs');
|
||||
const { assertFullCommitSha } = require('../shared/validation.cjs');
|
||||
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
||||
|
||||
function safeRemoteFolder(value) {
|
||||
const text = String(value || '').trim();
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(text) || text === '.' || text === '..') throw new Error('Remote folder contains unsupported characters.');
|
||||
return text;
|
||||
}
|
||||
|
||||
function safeRelativeRemoteFile(value, fallback = '') {
|
||||
const text = String(value || fallback).trim().replace(/\\/g, '/');
|
||||
if (!text || text.startsWith('/') || text.split('/').some((part) => !part || part === '.' || part === '..')) {
|
||||
throw new Error('Remote file path must remain inside the project folder.');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function bash(command) {
|
||||
const script = `set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\nexport GIT_SSH_COMMAND='ssh -o BatchMode=yes'\n${command}`;
|
||||
const payload = Buffer.from(script, 'utf8').toString('base64');
|
||||
return `printf '%s' ${shellQuote(payload)} | base64 -d | bash`;
|
||||
}
|
||||
|
||||
function parseInspection(text) {
|
||||
const jsonMarker = '__FORGEFLOW_JSON__';
|
||||
const jsonIndex = text.lastIndexOf(jsonMarker);
|
||||
if (jsonIndex >= 0) return JSON.parse(text.slice(jsonIndex + jsonMarker.length).trim());
|
||||
|
||||
const kvMarker = '__FORGEFLOW_KV__';
|
||||
const kvIndex = text.lastIndexOf(kvMarker);
|
||||
if (kvIndex < 0) throw new Error('The server inspection did not return a ForgeFlow result.');
|
||||
const fields = {};
|
||||
for (const line of text.slice(kvIndex + kvMarker.length).trim().split(/\r?\n/)) {
|
||||
const separator = line.indexOf('=');
|
||||
if (separator > 0) fields[line.slice(0, separator)] = line.slice(separator + 1);
|
||||
}
|
||||
const decodeLines = (value) => {
|
||||
try { return value ? Buffer.from(value, 'base64').toString('utf8').split(/\r?\n/).filter(Boolean) : []; }
|
||||
catch { return []; }
|
||||
};
|
||||
const decodeText = (value) => {
|
||||
try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; }
|
||||
catch { return ''; }
|
||||
};
|
||||
return {
|
||||
exists: fields.exists === 'true',
|
||||
rootGit: fields.rootGit === 'true',
|
||||
head: fields.head || null,
|
||||
branch: fields.branch || null,
|
||||
remote: fields.remote ? Buffer.from(fields.remote, 'base64').toString('utf8') : null,
|
||||
trackedChanges: decodeLines(fields.trackedChanges),
|
||||
composeFiles: decodeLines(fields.composeFiles),
|
||||
nestedGit: decodeLines(fields.nestedGit),
|
||||
dockerfile: fields.dockerfile === 'true',
|
||||
dockerignoreContent: decodeText(fields.dockerignoreContent),
|
||||
existingPreservePaths: decodeLines(fields.existingPreservePaths)
|
||||
};
|
||||
}
|
||||
|
||||
function dockerIgnoreHasPath(content, value) {
|
||||
const target = String(value || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, '').replace(/\/$/, '');
|
||||
if (!target) return false;
|
||||
return String(content || '').split(/\r?\n/).some((line) => {
|
||||
let rule = line.trim();
|
||||
if (!rule || rule.startsWith('#') || rule.startsWith('!')) return false;
|
||||
rule = rule.replace(/^\.\//, '').replace(/^\//, '').replace(/\/$/, '');
|
||||
return rule === target || rule === `${target}/**` || rule === `${target}/**/*`;
|
||||
});
|
||||
}
|
||||
|
||||
function checksSummary(checks) {
|
||||
const counts = {
|
||||
pass: checks.filter((item) => item.status === 'pass').length,
|
||||
warning: checks.filter((item) => item.status === 'warning').length,
|
||||
fail: checks.filter((item) => item.status === 'fail').length
|
||||
};
|
||||
return {
|
||||
ready: counts.fail === 0,
|
||||
counts,
|
||||
blocking: checks.filter((item) => item.status === 'fail').map((item) => item.id)
|
||||
};
|
||||
}
|
||||
|
||||
class UnraidDeploymentService {
|
||||
constructor({ store, ssh, git, diagnostics }) {
|
||||
this.store = store;
|
||||
this.ssh = ssh;
|
||||
this.git = git;
|
||||
this.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
resolve(repository, profileId) {
|
||||
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
|
||||
if (!profile || profile.provider !== 'ssh-unraid') throw new Error('The SSH / Unraid deployment profile no longer exists.');
|
||||
const server = this.store.getServer(profile.serverId);
|
||||
if (!server) throw new Error('The deployment server no longer exists.');
|
||||
const remoteFolder = safeRemoteFolder(profile.remoteFolder || repository.name);
|
||||
const remotePath = path.join(server.basePath, remoteFolder);
|
||||
if (!remotePath.startsWith(`${server.basePath}/`)) throw new Error('Remote project path escapes the configured server base path.');
|
||||
return { profile, server, remoteFolder, remotePath };
|
||||
}
|
||||
|
||||
async inspect({ repository, profileId }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const preserveProbe = (profile.preservePaths || []).map((relativePath) =>
|
||||
`if [ -e "$root"/${shellQuote(relativePath)} ]; then printf '%s\\n' ${shellQuote(relativePath)}; fi`
|
||||
).join('\n');
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
exists=false; root_git=false; head=""; branch=""; remote=""; tracked_changes=""; compose_files=""; nested_git=""; dockerfile=false; dockerignore_content=""; existing_preserve_paths=""
|
||||
if [ -d "$root" ]; then
|
||||
exists=true
|
||||
if [ -d "$root/.git" ]; then
|
||||
root_git=true
|
||||
head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
|
||||
branch=$(git -C "$root" branch --show-current 2>/dev/null || true)
|
||||
remote=$(git -C "$root" remote get-url origin 2>/dev/null || true)
|
||||
tracked_changes=$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null | head -n 25 | base64 | tr -d '\\r\\n' || true)
|
||||
fi
|
||||
compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' -o -name 'compose.forgeflow.yml' \\) -printf '%P\\n' 2>/dev/null | sort | base64 | tr -d '\\r\\n' || true)
|
||||
nested_git=$(find "$root" -mindepth 2 -maxdepth 5 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true)
|
||||
[ -f "$root/Dockerfile" ] && dockerfile=true
|
||||
[ -f "$root/.dockerignore" ] && dockerignore_content=$(base64 < "$root/.dockerignore" | tr -d '\\r\\n' || true)
|
||||
existing_preserve_paths=$({ ${preserveProbe || ':'}; } | sort -u | base64 | tr -d '\\r\\n' || true)
|
||||
fi
|
||||
printf '__FORGEFLOW_KV__\\n'
|
||||
printf 'exists=%s\\n' "$exists"
|
||||
printf 'rootGit=%s\\n' "$root_git"
|
||||
printf 'head=%s\\n' "$head"
|
||||
printf 'branch=%s\\n' "$branch"
|
||||
printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')"
|
||||
printf 'trackedChanges=%s\\n' "$tracked_changes"
|
||||
printf 'composeFiles=%s\\n' "$compose_files"
|
||||
printf 'nestedGit=%s\\n' "$nested_git"
|
||||
printf 'dockerfile=%s\\n' "$dockerfile"
|
||||
printf 'dockerignoreContent=%s\\n' "$dockerignore_content"
|
||||
printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths"
|
||||
`;
|
||||
const wrapped = bash(script);
|
||||
const result = await this.ssh.exec(server.id, wrapped, { timeout: 60_000 });
|
||||
const parsed = parseInspection(result.stdout);
|
||||
const contextCandidates = [...new Set([...(parsed.existingPreservePaths || []), ...(parsed.nestedGit || [])])];
|
||||
const inspection = {
|
||||
...parsed,
|
||||
dockerignore: Boolean(parsed.dockerignoreContent),
|
||||
dockerignoreGitExcluded: dockerIgnoreHasPath(parsed.dockerignoreContent, '.git'),
|
||||
dockerContextExclusionsMissing: parsed.dockerfile
|
||||
? contextCandidates.filter((item) => !dockerIgnoreHasPath(parsed.dockerignoreContent, item))
|
||||
: [],
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
remotePath,
|
||||
profileId: profile.id
|
||||
};
|
||||
await this.diagnostics?.info('unraid.inspected', {
|
||||
repository: repository.fullName,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
exists: inspection.exists,
|
||||
rootGit: inspection.rootGit,
|
||||
head: inspection.head,
|
||||
composeFiles: inspection.composeFiles,
|
||||
nestedGitCount: inspection.nestedGit.length,
|
||||
trackedChangeCount: inspection.trackedChanges.length,
|
||||
dockerContextExclusionsMissing: inspection.dockerContextExclusionsMissing
|
||||
});
|
||||
return inspection;
|
||||
}
|
||||
|
||||
async preflight({ repository, profileId, sha = null }) {
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const targetSha = assertFullCommitSha(sha || repository.localStatus?.head);
|
||||
const checks = [];
|
||||
let inspection = null;
|
||||
|
||||
if (!repository.localPath) {
|
||||
checks.push({ id: 'local-repository', label: 'Local repository', status: 'fail', detail: 'Link or clone the repository locally before deploying.' });
|
||||
} else {
|
||||
try {
|
||||
const localStatus = await this.git.status(repository.localPath);
|
||||
checks.push({ id: 'local-repository', label: 'Local repository', status: 'pass', detail: localStatus.root });
|
||||
checks.push({ id: 'local-branch', label: 'Allowed branch', status: localStatus.branch.head === profile.branch ? 'pass' : 'fail', detail: `Current: ${localStatus.branch.head || 'detached'}; required: ${profile.branch}.` });
|
||||
checks.push({ id: 'local-clean', label: 'Clean local working tree', status: localStatus.clean ? 'pass' : 'fail', detail: localStatus.clean ? 'No uncommitted changes.' : `${localStatus.counts.changed} changed file(s) remain.` });
|
||||
checks.push({ id: 'local-upstream', label: 'Published upstream', status: localStatus.branch.upstream ? 'pass' : 'fail', detail: localStatus.branch.upstream || 'No upstream branch is configured.' });
|
||||
checks.push({ id: 'local-sync', label: 'Local and Gitea synchronized', status: !localStatus.branch.ahead && !localStatus.branch.behind ? 'pass' : 'fail', detail: `${localStatus.branch.ahead || 0} ahead, ${localStatus.branch.behind || 0} behind.` });
|
||||
checks.push({ id: 'local-target-sha', label: 'Selected deployment commit', status: localStatus.head === targetSha ? 'pass' : 'fail', detail: localStatus.head === targetSha ? targetSha : `Local HEAD is ${localStatus.head || 'unknown'}, but deployment requested ${targetSha}.` });
|
||||
try {
|
||||
await this.git.verifyCommitOnRemoteBranch(repository.localPath, targetSha, profile.branch);
|
||||
checks.push({ id: 'remote-target-sha', label: 'Exact commit on Gitea branch', status: 'pass', detail: `${targetSha.slice(0, 7)} exists on origin/${profile.branch}.` });
|
||||
} catch (error) {
|
||||
checks.push({ id: 'remote-target-sha', label: 'Exact commit on Gitea branch', status: 'fail', detail: error.message });
|
||||
}
|
||||
|
||||
const localDeploymentFile = profile.generatedCompose
|
||||
? nativePath.join(repository.localPath, 'Dockerfile')
|
||||
: nativePath.join(repository.localPath, safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml'));
|
||||
const localDeploymentFileExists = Boolean((await fs.stat(localDeploymentFile).catch(() => null))?.isFile());
|
||||
checks.push({
|
||||
id: 'local-deployment-file',
|
||||
label: profile.generatedCompose ? 'Dockerfile in repository' : 'Compose file in repository',
|
||||
status: localDeploymentFileExists ? 'pass' : 'fail',
|
||||
detail: localDeploymentFileExists ? localDeploymentFile : `${localDeploymentFile} was not found in the exact local checkout.`
|
||||
});
|
||||
} catch (error) {
|
||||
checks.push({ id: 'local-repository', label: 'Local repository', status: 'fail', detail: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const connection = await this.ssh.test(server.id, { trustOnFirstUse: false });
|
||||
checks.push({ id: 'ssh', label: 'SSH connection', status: 'pass', detail: `${server.username}@${server.host}:${server.port}` });
|
||||
if (!/docker compose|docker-compose/i.test(connection.output)) {
|
||||
checks.push({ id: 'compose-command', label: 'Docker Compose', status: 'fail', detail: 'Docker Compose was not detected on the server.' });
|
||||
} else checks.push({ id: 'compose-command', label: 'Docker Compose', status: 'pass', detail: 'Docker Compose is available.' });
|
||||
} catch (error) {
|
||||
checks.push({ id: 'ssh', label: 'SSH connection', status: 'fail', detail: error.message });
|
||||
}
|
||||
if (!server.hostFingerprint) checks.push({ id: 'host-key', label: 'Server identity', status: 'fail', detail: 'Test and trust the SSH host key first.' });
|
||||
else checks.push({ id: 'host-key', label: 'Server identity', status: 'pass', detail: server.hostFingerprint });
|
||||
|
||||
try {
|
||||
inspection = await this.inspect({ repository, profileId });
|
||||
if (!inspection.exists) {
|
||||
checks.push({ id: 'remote-folder', label: 'Remote project folder', status: 'pass', detail: `${remotePath} will be created.` });
|
||||
} else if (!inspection.rootGit) {
|
||||
checks.push({ id: 'remote-folder', label: 'Remote project folder', status: 'fail', detail: `${remotePath} exists but is not a Git working tree. Adopt or migrate it before deployment.` });
|
||||
} else {
|
||||
checks.push({ id: 'remote-folder', label: 'Remote Git working tree', status: 'pass', detail: `${remotePath} at ${String(inspection.head || '').slice(0, 7) || 'unknown'}.` });
|
||||
}
|
||||
if (inspection.trackedChanges.length) {
|
||||
checks.push({ id: 'tracked-changes', label: 'Server-side tracked changes', status: 'fail', detail: `${inspection.trackedChanges.length} tracked change(s) would be overwritten. Commit, revert or migrate them first.` });
|
||||
} else if (inspection.rootGit) checks.push({ id: 'tracked-changes', label: 'Server-side tracked changes', status: 'pass', detail: 'No tracked server-only edits detected.' });
|
||||
|
||||
if (inspection.rootGit && profile.cloneUrl && inspection.remote) {
|
||||
const expectedRemote = normalizeRemoteUrl(profile.cloneUrl);
|
||||
const currentRemote = normalizeRemoteUrl(inspection.remote);
|
||||
const matches = Boolean(expectedRemote && currentRemote && expectedRemote.host === currentRemote.host && expectedRemote.path === currentRemote.path);
|
||||
if (!matches && profile.alignRemote) {
|
||||
checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'warning', detail: `Origin will be aligned from ${inspection.remote} to the configured clone URL before fetch.` });
|
||||
} else if (!matches) {
|
||||
checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'fail', detail: `Current origin ${inspection.remote} does not match the configured clone URL. Enable controlled origin alignment or correct the profile.` });
|
||||
} else {
|
||||
checks.push({ id: 'origin-url', label: 'Server Git origin', status: 'pass', detail: inspection.remote });
|
||||
}
|
||||
}
|
||||
if (inspection.nestedGit.length) {
|
||||
checks.push({ id: 'nested-git', label: 'Nested Git repositories', status: 'warning', detail: `Detected: ${inspection.nestedGit.join(', ')}. ForgeFlow will not delete them automatically.` });
|
||||
}
|
||||
if (inspection.dockerfile && !inspection.dockerignore) {
|
||||
checks.push({ id: 'dockerignore', label: 'Docker build context', status: 'warning', detail: 'A Dockerfile exists but .dockerignore is missing. Add one in the repository before large builds.' });
|
||||
} else if (inspection.dockerfile && !inspection.dockerignoreGitExcluded) {
|
||||
checks.push({ id: 'dockerignore-git', label: 'Git metadata excluded from Docker', status: 'warning', detail: '.dockerignore does not explicitly exclude .git.' });
|
||||
} else if (inspection.dockerfile) {
|
||||
checks.push({ id: 'dockerignore-git', label: 'Git metadata excluded from Docker', status: 'pass', detail: '.git is excluded from the Docker build context.' });
|
||||
}
|
||||
if (inspection.dockerContextExclusionsMissing.length) {
|
||||
checks.push({ id: 'dockerignore-runtime', label: 'Runtime data excluded from Docker', status: 'warning', detail: `Add these existing runtime or legacy paths to .dockerignore: ${inspection.dockerContextExclusionsMissing.join(', ')}.` });
|
||||
} else if (inspection.dockerfile && inspection.existingPreservePaths.length) {
|
||||
checks.push({ id: 'dockerignore-runtime', label: 'Runtime data excluded from Docker', status: 'pass', detail: 'Detected preserved runtime paths are excluded from the Docker build context.' });
|
||||
}
|
||||
const composeFile = safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
|
||||
if (inspection.exists && !inspection.composeFiles.includes(composeFile) && !profile.generatedCompose) {
|
||||
checks.push({ id: 'compose-file', label: 'Compose configuration', status: 'fail', detail: `${composeFile} was not found. Select an existing file or enable generated Compose.` });
|
||||
} else {
|
||||
checks.push({ id: 'compose-file', label: 'Compose configuration', status: 'pass', detail: profile.generatedCompose ? 'ForgeFlow will generate an isolated Compose file.' : composeFile });
|
||||
}
|
||||
} catch (error) {
|
||||
checks.push({ id: 'inspection', label: 'Server project inspection', status: 'fail', detail: error.message });
|
||||
}
|
||||
checks.push({ id: 'exact-sha', label: 'Exact deployment commit', status: 'pass', detail: targetSha });
|
||||
return {
|
||||
provider: 'ssh-unraid',
|
||||
repository: repository.fullName,
|
||||
environment: profile.environment,
|
||||
sha: targetSha,
|
||||
server: { id: server.id, name: server.name, host: server.host },
|
||||
remotePath,
|
||||
inspection,
|
||||
checks,
|
||||
summary: checksSummary(checks)
|
||||
};
|
||||
}
|
||||
|
||||
generatedCompose(profile, repository) {
|
||||
const service = String(profile.composeService || repository.name || 'app').toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'app';
|
||||
if (!profile.hostPort || !profile.containerPort) throw new Error('Host and container ports are required for generated Compose.');
|
||||
const labels = [
|
||||
'net.unraid.docker.managed=dockerman',
|
||||
profile.webUiUrl ? `net.unraid.docker.webui=${profile.webUiUrl}` : '',
|
||||
profile.iconUrl ? `net.unraid.docker.icon=${profile.iconUrl}` : ''
|
||||
].filter(Boolean);
|
||||
return [
|
||||
'services:',
|
||||
` ${service}:`,
|
||||
' build:',
|
||||
' context: ..',
|
||||
` container_name: ${service}`,
|
||||
' restart: unless-stopped',
|
||||
' ports:',
|
||||
` - "${profile.hostPort}:${profile.containerPort}"`,
|
||||
...(labels.length ? [' labels:', ...labels.map((label) => ` - ${JSON.stringify(label)}`)] : [])
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
|
||||
async checkHealth(url) {
|
||||
if (!url) return { configured: false, healthy: null, status: null, latencyMs: null };
|
||||
let last = null;
|
||||
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(8_000), redirect: 'manual' });
|
||||
last = { configured: true, healthy: response.ok, status: response.status, latencyMs: Date.now() - started };
|
||||
if (response.ok) return last;
|
||||
} catch (error) {
|
||||
last = { configured: true, healthy: false, status: null, latencyMs: Date.now() - started, error: error.message };
|
||||
}
|
||||
if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 3_000));
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
async deploy({ repository, profileId, sha }) {
|
||||
const targetSha = assertFullCommitSha(sha);
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const preflight = await this.preflight({ repository, profileId, sha: targetSha });
|
||||
if (!preflight.summary.ready) {
|
||||
const error = new Error(`SSH deployment preflight failed: ${preflight.summary.blocking.join(', ')}`);
|
||||
error.code = 'SSH_DEPLOYMENT_PREFLIGHT_FAILED';
|
||||
throw error;
|
||||
}
|
||||
const requestId = crypto.randomUUID();
|
||||
const operation = await this.store.addOperation({
|
||||
id: requestId,
|
||||
type: 'deployment',
|
||||
action: 'deploy',
|
||||
provider: 'ssh-unraid',
|
||||
repository: repository.fullName,
|
||||
environment: profile.environment,
|
||||
profileId,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
sha: targetSha,
|
||||
shortSha: targetSha.slice(0, 7),
|
||||
status: 'running',
|
||||
logs: ['SSH connection verified.', `Deploying exact commit ${targetSha}.`]
|
||||
});
|
||||
|
||||
const cloneUrl = String(profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || '').trim();
|
||||
if (!cloneUrl) throw new Error('No server-usable Git clone URL is configured.');
|
||||
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
|
||||
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : '';
|
||||
const branch = String(profile.branch || 'main');
|
||||
const statusJson = JSON.stringify({
|
||||
repository: repository.fullName,
|
||||
environment: profile.environment,
|
||||
requested_sha: targetSha,
|
||||
live_sha: targetSha,
|
||||
request_id: requestId,
|
||||
healthy: null,
|
||||
healthcheck_url_configured: Boolean(profile.healthcheckUrl),
|
||||
deployed_at: new Date().toISOString()
|
||||
});
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
parent=$(dirname "$root")
|
||||
mkdir -p "$parent"
|
||||
if [ ! -d "$root" ]; then
|
||||
git clone --branch ${shellQuote(branch)} --single-branch ${shellQuote(cloneUrl)} "$root"
|
||||
fi
|
||||
test -d "$root/.git" || { echo "Existing folder is not a Git working tree" >&2; exit 32; }
|
||||
${profile.alignRemote ? `git -C "$root" remote set-url origin ${shellQuote(cloneUrl)}` : ''}
|
||||
changes=$(git -C "$root" status --porcelain --untracked-files=no)
|
||||
test -z "$changes" || { echo "Tracked server-side changes block deployment" >&2; printf '%s\\n' "$changes" >&2; exit 33; }
|
||||
git -C "$root" fetch --prune origin ${shellQuote(branch)}
|
||||
git -C "$root" cat-file -e ${shellQuote(`${targetSha}^{commit}`)}
|
||||
git -C "$root" merge-base --is-ancestor ${shellQuote(targetSha)} ${shellQuote(`origin/${branch}`)}
|
||||
previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
|
||||
git -C "$root" checkout -B ${shellQuote(branch)} ${shellQuote(`origin/${branch}`)}
|
||||
git -C "$root" reset --hard ${shellQuote(targetSha)}
|
||||
mkdir -p "$root/.forgeflow"
|
||||
printf '%s' "$previous" > "$root/.forgeflow/previous-sha"
|
||||
printf '%s' ${shellQuote(targetSha)} > "$root/.forgeflow/current-sha"
|
||||
${profile.generatedCompose ? `cat > "$root/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'\n${generated}FORGEFLOW_COMPOSE` : ''}
|
||||
cd "$root"
|
||||
docker compose -f ${shellQuote(composeFile)} config >/dev/null
|
||||
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
|
||||
cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS'
|
||||
${statusJson}
|
||||
FORGEFLOW_STATUS
|
||||
`;
|
||||
try {
|
||||
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
|
||||
const health = await this.checkHealth(profile.healthcheckUrl);
|
||||
const finalStatus = health.healthy === false ? 'failed' : 'success';
|
||||
const finalLogs = [
|
||||
...operation.logs,
|
||||
...result.stdout.trim().split('\n').filter(Boolean).slice(-60),
|
||||
'Docker Compose deployment completed.',
|
||||
health.configured ? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.` : 'No desktop healthcheck URL configured.'
|
||||
];
|
||||
const completed = await this.store.addOperation({
|
||||
...operation,
|
||||
status: finalStatus,
|
||||
previousSha: preflight.inspection?.head || null,
|
||||
health,
|
||||
logs: finalLogs,
|
||||
error: health.healthy === false ? 'The application healthcheck did not pass after deployment.' : null
|
||||
});
|
||||
await this.store.saveDeploymentState(profileId, {
|
||||
liveSha: targetSha,
|
||||
previousSha: preflight.inspection?.head || null,
|
||||
healthy: health.healthy,
|
||||
healthStatus: health.status,
|
||||
healthLatencyMs: health.latencyMs,
|
||||
requestId,
|
||||
remotePath,
|
||||
provider: 'ssh-unraid'
|
||||
});
|
||||
await this.diagnostics?.info('unraid.deployment.completed', {
|
||||
requestId,
|
||||
repository: repository.fullName,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
sha: targetSha,
|
||||
healthy: health.healthy,
|
||||
healthStatus: health.status
|
||||
});
|
||||
if (health.healthy === false) {
|
||||
const error = new Error('Deployment completed, but the configured healthcheck failed. The previous SHA remains available for rollback.');
|
||||
error.code = 'DEPLOYMENT_HEALTHCHECK_FAILED';
|
||||
error.operationId = completed.id;
|
||||
throw error;
|
||||
}
|
||||
return completed;
|
||||
} catch (error) {
|
||||
if (error.code !== 'DEPLOYMENT_HEALTHCHECK_FAILED') {
|
||||
await this.store.addOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] });
|
||||
}
|
||||
await this.diagnostics?.error('unraid.deployment.failed', {
|
||||
requestId,
|
||||
repository: repository.fullName,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
sha: targetSha,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async rollback({ repository, profileId, targetSha }) {
|
||||
const target = assertFullCommitSha(targetSha);
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const deploymentState = this.store.getDeploymentState(profileId);
|
||||
if (!deploymentState?.previousSha || deploymentState.previousSha !== target) {
|
||||
const error = new Error('Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile.');
|
||||
error.code = 'ROLLBACK_TARGET_NOT_PREVIOUS_SHA';
|
||||
throw error;
|
||||
}
|
||||
if (!repository.localPath) throw new Error('A linked local repository is required for rollback verification.');
|
||||
await this.git.verifyCommitOnRemoteBranch(repository.localPath, target, profile.branch);
|
||||
const inspection = await this.inspect({ repository, profileId });
|
||||
if (!inspection.rootGit) throw new Error('The configured server project is not a root Git working tree.');
|
||||
if (inspection.trackedChanges.length) throw new Error('Tracked server-side changes block rollback. Commit, revert or migrate them first.');
|
||||
const composeFile = profile.generatedCompose ? '.forgeflow/compose.forgeflow.yml' : safeRelativeRemoteFile(profile.composeFile || 'docker-compose.yml');
|
||||
const requestId = crypto.randomUUID();
|
||||
const operation = await this.store.addOperation({
|
||||
id: requestId,
|
||||
type: 'deployment',
|
||||
action: 'rollback',
|
||||
provider: 'ssh-unraid',
|
||||
repository: repository.fullName,
|
||||
environment: profile.environment,
|
||||
profileId,
|
||||
serverId: server.id,
|
||||
remotePath,
|
||||
sha: target,
|
||||
shortSha: target.slice(0, 7),
|
||||
status: 'running',
|
||||
logs: [`Rolling back to exact commit ${target}.`]
|
||||
});
|
||||
const statusJson = JSON.stringify({
|
||||
repository: repository.fullName,
|
||||
environment: profile.environment,
|
||||
requested_sha: target,
|
||||
live_sha: target,
|
||||
request_id: requestId,
|
||||
healthy: null,
|
||||
healthcheck_url_configured: Boolean(profile.healthcheckUrl),
|
||||
rollback: true,
|
||||
deployed_at: new Date().toISOString()
|
||||
});
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
test -d "$root/.git"
|
||||
git -C "$root" fetch --prune origin ${shellQuote(profile.branch)}
|
||||
git -C "$root" cat-file -e ${shellQuote(`${target}^{commit}`)}
|
||||
current=$(git -C "$root" rev-parse HEAD)
|
||||
git -C "$root" reset --hard ${shellQuote(target)}
|
||||
cd "$root"
|
||||
docker compose -f ${shellQuote(composeFile)} config >/dev/null
|
||||
docker compose -f ${shellQuote(composeFile)} up -d --build --remove-orphans
|
||||
printf '%s' "$current" > "$root/.forgeflow/previous-sha"
|
||||
printf '%s' ${shellQuote(target)} > "$root/.forgeflow/current-sha"
|
||||
cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS'
|
||||
${statusJson}
|
||||
FORGEFLOW_STATUS
|
||||
`;
|
||||
try {
|
||||
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024 });
|
||||
const health = await this.checkHealth(profile.healthcheckUrl);
|
||||
const finalStatus = health.healthy === false ? 'failed' : 'rolled-back';
|
||||
const completed = await this.store.addOperation({
|
||||
...operation,
|
||||
status: finalStatus,
|
||||
previousSha: deploymentState.liveSha || inspection.head || null,
|
||||
health,
|
||||
error: health.healthy === false ? 'The application healthcheck did not pass after rollback.' : null,
|
||||
logs: [
|
||||
...operation.logs,
|
||||
...result.stdout.trim().split('\n').filter(Boolean).slice(-60),
|
||||
'Rollback completed.',
|
||||
health.configured ? `Healthcheck ${health.healthy ? 'passed' : 'failed'}${health.status ? ` with HTTP ${health.status}` : ''}.` : 'No desktop healthcheck URL configured.'
|
||||
]
|
||||
});
|
||||
await this.store.saveDeploymentState(profileId, {
|
||||
liveSha: target,
|
||||
previousSha: deploymentState.liveSha || inspection.head || null,
|
||||
healthy: health.healthy,
|
||||
healthStatus: health.status,
|
||||
healthLatencyMs: health.latencyMs,
|
||||
requestId,
|
||||
remotePath,
|
||||
provider: 'ssh-unraid'
|
||||
});
|
||||
if (health.healthy === false) {
|
||||
const error = new Error('Rollback completed, but the configured healthcheck failed.');
|
||||
error.code = 'ROLLBACK_HEALTHCHECK_FAILED';
|
||||
error.operationId = completed.id;
|
||||
throw error;
|
||||
}
|
||||
return completed;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ROLLBACK_HEALTHCHECK_FAILED') {
|
||||
await this.store.addOperation({ ...operation, status: 'failed', error: error.message, logs: [...operation.logs, error.message] });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshProfileState(fullName, profileId) {
|
||||
const repository = { fullName, name: fullName.split('/').pop() };
|
||||
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
||||
const script = `
|
||||
root=${shellQuote(remotePath)}
|
||||
live=""; previous=""; status=""
|
||||
[ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha")
|
||||
[ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha")
|
||||
[ -f "$root/.forgeflow/status.json" ] && status=$(base64 "$root/.forgeflow/status.json" | tr -d '\\r\\n')
|
||||
printf '__FORGEFLOW_JSON__\\n{"liveSha":"%s","previousSha":"%s","statusBase64":"%s"}\\n' "$live" "$previous" "$status"
|
||||
`;
|
||||
const result = await this.ssh.exec(server.id, bash(script), { timeout: 30_000 });
|
||||
const raw = parseInspection(result.stdout);
|
||||
let remoteStatus = null;
|
||||
try { remoteStatus = raw.statusBase64 ? JSON.parse(Buffer.from(raw.statusBase64, 'base64').toString('utf8')) : null; } catch {}
|
||||
const existing = this.store.getDeploymentState(profile.id) || {};
|
||||
return this.store.saveDeploymentState(profile.id, {
|
||||
liveSha: /^[0-9a-f]{40}$/i.test(raw.liveSha || '') ? raw.liveSha : null,
|
||||
previousSha: /^[0-9a-f]{40}$/i.test(raw.previousSha || '') ? raw.previousSha : null,
|
||||
healthy: remoteStatus?.healthy ?? existing.healthy ?? null,
|
||||
healthStatus: existing.healthStatus ?? null,
|
||||
healthLatencyMs: existing.healthLatencyMs ?? null,
|
||||
requestId: remoteStatus?.request_id || existing.requestId || null,
|
||||
remotePath,
|
||||
provider: 'ssh-unraid'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UnraidDeploymentService,
|
||||
safeRemoteFolder,
|
||||
safeRelativeRemoteFile,
|
||||
parseInspection,
|
||||
dockerIgnoreHasPath,
|
||||
checksSummary,
|
||||
bash
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { isNewerVersion } = require('../shared/semver.cjs');
|
||||
|
||||
function safeRepositoryPart(value, label) {
|
||||
const text = String(value || '').trim();
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(text)) throw new Error(`${label} contains unsupported characters.`);
|
||||
return text;
|
||||
}
|
||||
|
||||
class UpdateService {
|
||||
constructor({ store, gitea, diagnostics, appInfo, sourcePath, userDataPath }) {
|
||||
this.store = store;
|
||||
this.gitea = gitea;
|
||||
this.diagnostics = diagnostics;
|
||||
this.appInfo = appInfo;
|
||||
this.sourcePath = sourcePath;
|
||||
this.updateDirectory = path.join(userDataPath, 'updates');
|
||||
this.staged = null;
|
||||
}
|
||||
|
||||
async check() {
|
||||
const settings = this.store.data.updates || {};
|
||||
const owner = safeRepositoryPart(settings.owner || 'Jens', 'Update repository owner');
|
||||
const repo = safeRepositoryPart(settings.repo || 'ForgeFlow', 'Update repository name');
|
||||
const branchName = String(settings.branch || 'main').trim();
|
||||
const branch = await this.gitea.getBranch(owner, repo, branchName);
|
||||
const remoteSha = branch?.commit?.id || branch?.commit?.sha || branch?.commit?.commit?.id;
|
||||
if (!/^[0-9a-f]{40}$/i.test(String(remoteSha || ''))) throw new Error('Gitea did not return a full commit SHA for the update branch.');
|
||||
|
||||
const file = await this.gitea.getRepositoryFile({ owner, repo, filePath: 'package.json', ref: remoteSha });
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(file.decoded); }
|
||||
catch { throw new Error('The remote ForgeFlow package.json is not valid JSON.'); }
|
||||
if (manifest.name !== 'forgeflow') throw new Error('The configured update repository is not a ForgeFlow source repository.');
|
||||
const remoteVersion = String(manifest.version || '').trim();
|
||||
const currentVersion = String(this.appInfo.version || '').trim();
|
||||
const available = isNewerVersion(remoteVersion, currentVersion);
|
||||
const result = {
|
||||
checkedAt: new Date().toISOString(),
|
||||
owner,
|
||||
repo,
|
||||
branch: branchName,
|
||||
currentVersion,
|
||||
remoteVersion,
|
||||
remoteSha,
|
||||
shortSha: remoteSha.slice(0, 7),
|
||||
available,
|
||||
packaged: Boolean(this.appInfo.packaged),
|
||||
mode: this.appInfo.packaged ? 'packaged' : 'source'
|
||||
};
|
||||
this.store.data.updates.lastCheckedAt = result.checkedAt;
|
||||
await this.store.save();
|
||||
await this.diagnostics?.info('updates.checked', {
|
||||
repository: `${owner}/${repo}`,
|
||||
branch: branchName,
|
||||
currentVersion,
|
||||
remoteVersion,
|
||||
remoteSha,
|
||||
available,
|
||||
mode: result.mode
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async download(expected = null) {
|
||||
const update = expected?.remoteSha ? expected : await this.check();
|
||||
if (!update.available) return { ...update, downloaded: false, reason: 'up-to-date' };
|
||||
if (this.appInfo.packaged) {
|
||||
const error = new Error('This developer release uses source updates. Install a signed packaged release before using binary auto-update.');
|
||||
error.code = 'PACKAGED_UPDATE_NOT_CONFIGURED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
await fs.mkdir(this.updateDirectory, { recursive: true });
|
||||
const archiveUrl = `${this.store.data.gitea.baseUrl.replace(/\/+$/, '')}/${encodeURIComponent(update.owner)}/${encodeURIComponent(update.repo)}/archive/${update.remoteSha}.zip`;
|
||||
const archive = await this.gitea.downloadAuthenticated(archiveUrl);
|
||||
if (archive.length < 1000 || archive[0] !== 0x50 || archive[1] !== 0x4b) throw new Error('The downloaded update is not a valid ZIP archive.');
|
||||
const sha256 = crypto.createHash('sha256').update(archive).digest('hex');
|
||||
const archivePath = path.join(this.updateDirectory, `ForgeFlow-${update.remoteVersion}-${update.shortSha}.zip`);
|
||||
const metadataPath = `${archivePath}.json`;
|
||||
await fs.writeFile(archivePath, archive, { mode: 0o600 });
|
||||
const metadata = { ...update, archivePath, sha256, downloadedAt: new Date().toISOString() };
|
||||
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
||||
this.staged = metadata;
|
||||
await this.diagnostics?.info('updates.downloaded', {
|
||||
remoteVersion: update.remoteVersion,
|
||||
remoteSha: update.remoteSha,
|
||||
bytes: archive.length,
|
||||
sha256
|
||||
});
|
||||
return { ...metadata, downloaded: true };
|
||||
}
|
||||
|
||||
async apply(staged = null) {
|
||||
const update = staged?.archivePath ? staged : this.staged;
|
||||
if (!update?.archivePath) throw new Error('Download an update before applying it.');
|
||||
if (process.platform !== 'win32') throw new Error('The integrated source updater currently supports Windows only.');
|
||||
const stat = await fs.stat(update.archivePath).catch(() => null);
|
||||
if (!stat?.isFile()) throw new Error('The staged update archive is no longer available.');
|
||||
|
||||
const scriptPath = path.join(this.sourcePath, 'scripts', 'apply-source-update.ps1');
|
||||
const scriptStat = await fs.stat(scriptPath).catch(() => null);
|
||||
if (!scriptStat?.isFile()) throw new Error('The source update helper is missing.');
|
||||
const logPath = path.join(this.updateDirectory, `apply-${Date.now()}.log`);
|
||||
const args = [
|
||||
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath,
|
||||
'-SourcePath', this.sourcePath,
|
||||
'-ArchivePath', update.archivePath,
|
||||
'-ExpectedVersion', update.remoteVersion,
|
||||
'-ExpectedSha256', update.sha256,
|
||||
'-ParentPid', String(process.pid),
|
||||
'-LogPath', logPath
|
||||
];
|
||||
const child = spawn('powershell.exe', args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false,
|
||||
cwd: this.sourcePath
|
||||
});
|
||||
child.unref();
|
||||
await this.diagnostics?.info('updates.apply-launched', {
|
||||
remoteVersion: update.remoteVersion,
|
||||
remoteSha: update.remoteSha,
|
||||
logPath
|
||||
});
|
||||
return { launched: true, version: update.remoteVersion, logPath };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UpdateService, safeRepositoryPart };
|
||||
+1055
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 |
@@ -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>
|
||||
@@ -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); }
|
||||
});
|
||||
})();
|
||||
@@ -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; }
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
function parseBranchHeader(line, branch) {
|
||||
if (line.startsWith('# branch.oid ')) branch.oid = line.slice(13).trim();
|
||||
if (line.startsWith('# branch.head ')) branch.head = line.slice(14).trim();
|
||||
if (line.startsWith('# branch.upstream ')) branch.upstream = line.slice(18).trim();
|
||||
if (line.startsWith('# branch.ab ')) {
|
||||
const match = line.match(/\+(\d+)\s+-(\d+)/);
|
||||
if (match) {
|
||||
branch.ahead = Number(match[1]);
|
||||
branch.behind = Number(match[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(code) {
|
||||
const map = {
|
||||
M: 'modified', A: 'added', D: 'deleted', R: 'renamed', C: 'copied',
|
||||
U: 'conflict', T: 'type-changed', '?': 'untracked', '!': 'ignored', '.': 'clean', ' ': 'clean'
|
||||
};
|
||||
return map[code] || 'changed';
|
||||
}
|
||||
|
||||
function buildFile(path, originalPath, xy, kind) {
|
||||
const indexCode = xy?.[0] || '.';
|
||||
const worktreeCode = xy?.[1] || '.';
|
||||
const conflict = kind === 'u' || indexCode === 'U' || worktreeCode === 'U';
|
||||
const untracked = kind === '?';
|
||||
return {
|
||||
path,
|
||||
originalPath: originalPath || null,
|
||||
indexCode,
|
||||
worktreeCode,
|
||||
staged: !untracked && indexCode !== '.' && indexCode !== ' ',
|
||||
unstaged: untracked || (worktreeCode !== '.' && worktreeCode !== ' '),
|
||||
untracked,
|
||||
conflict,
|
||||
status: conflict ? 'conflict' : untracked ? 'untracked' : statusLabel(worktreeCode !== '.' ? worktreeCode : indexCode)
|
||||
};
|
||||
}
|
||||
|
||||
function parsePorcelainV2(output) {
|
||||
const branch = { oid: null, head: null, upstream: null, ahead: 0, behind: 0 };
|
||||
const files = [];
|
||||
const entries = String(output || '').split('\0');
|
||||
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const entry = entries[index];
|
||||
if (!entry) continue;
|
||||
if (entry.startsWith('# ')) {
|
||||
parseBranchHeader(entry, branch);
|
||||
continue;
|
||||
}
|
||||
|
||||
const kind = entry[0];
|
||||
if (kind === '1') {
|
||||
const parts = entry.split(' ');
|
||||
const xy = parts[1];
|
||||
const path = parts.slice(8).join(' ');
|
||||
files.push(buildFile(path, null, xy, kind));
|
||||
} else if (kind === '2') {
|
||||
const parts = entry.split(' ');
|
||||
const xy = parts[1];
|
||||
const path = parts.slice(9).join(' ');
|
||||
const originalPath = entries[index + 1] || null;
|
||||
index += 1;
|
||||
files.push(buildFile(path, originalPath, xy, kind));
|
||||
} else if (kind === 'u') {
|
||||
const parts = entry.split(' ');
|
||||
const xy = parts[1];
|
||||
const path = parts.slice(10).join(' ');
|
||||
files.push(buildFile(path, null, xy, kind));
|
||||
} else if (kind === '?' || kind === '!') {
|
||||
const path = entry.slice(2);
|
||||
if (kind === '?') files.push(buildFile(path, null, '??', kind));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
branch,
|
||||
files,
|
||||
counts: {
|
||||
changed: files.length,
|
||||
staged: files.filter((file) => file.staged).length,
|
||||
unstaged: files.filter((file) => file.unstaged).length,
|
||||
conflicts: files.filter((file) => file.conflict).length,
|
||||
untracked: files.filter((file) => file.untracked).length
|
||||
},
|
||||
clean: files.length === 0
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { parsePorcelainV2, statusLabel };
|
||||
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
|
||||
function stripGitSuffix(value) {
|
||||
return value.replace(/\.git$/i, '').replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
function normalizeRemoteUrl(remote) {
|
||||
const raw = String(remote || '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const scp = raw.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
|
||||
if (scp && !raw.includes('://') && !/^[a-zA-Z]:[\\/]/.test(raw)) {
|
||||
return { host: scp[1].toLowerCase(), path: stripGitSuffix(scp[2]).toLowerCase() };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return { host: url.hostname.toLowerCase(), path: stripGitSuffix(url.pathname).toLowerCase() };
|
||||
} catch {
|
||||
return { host: '', path: stripGitSuffix(raw.replace(/\\/g, '/')).toLowerCase() };
|
||||
}
|
||||
}
|
||||
|
||||
function repositoryKey(repository) {
|
||||
return String(repository?.full_name || `${repository?.owner?.login || repository?.owner || ''}/${repository?.name || ''}`)
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function matchRemoteToRepository(remote, repositories) {
|
||||
const normalized = normalizeRemoteUrl(remote);
|
||||
if (!normalized) return null;
|
||||
return repositories.find((repository) => {
|
||||
const key = repositoryKey(repository);
|
||||
return normalized.path === key || normalized.path.endsWith(`/${key}`);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
module.exports = { normalizeRemoteUrl, repositoryKey, matchRemoteToRepository };
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
|
||||
function parseVersion(value) {
|
||||
const match = String(value || '').trim().replace(/^v/i, '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
raw: String(value).trim(),
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
prerelease: match[4] || ''
|
||||
};
|
||||
}
|
||||
|
||||
function compareVersions(leftValue, rightValue) {
|
||||
const left = parseVersion(leftValue);
|
||||
const right = parseVersion(rightValue);
|
||||
if (!left || !right) throw new Error('Both versions must use semantic versioning (for example 1.2.3).');
|
||||
for (const key of ['major', 'minor', 'patch']) {
|
||||
if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1;
|
||||
}
|
||||
if (left.prerelease === right.prerelease) return 0;
|
||||
if (!left.prerelease) return 1;
|
||||
if (!right.prerelease) return -1;
|
||||
return left.prerelease.localeCompare(right.prerelease, undefined, { numeric: true }) > 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
function isNewerVersion(candidate, current) {
|
||||
return compareVersions(candidate, current) > 0;
|
||||
}
|
||||
|
||||
module.exports = { parseVersion, compareVersions, isNewerVersion };
|
||||
@@ -0,0 +1,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
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
function uniqueCandidates(candidates) {
|
||||
const seen = new Set();
|
||||
return candidates.filter((candidate) => {
|
||||
const key = JSON.stringify([candidate.file, candidate.args]);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function npmProbeCandidates(options = {}) {
|
||||
const platform = options.platform || process.platform;
|
||||
const env = options.env || process.env;
|
||||
const execPath = options.execPath || process.execPath;
|
||||
const candidates = [];
|
||||
|
||||
// npm exposes the exact CLI entry point while running an npm script. Calling
|
||||
// it through Node avoids Windows' inability to exec .cmd shims directly.
|
||||
if (env.npm_execpath) {
|
||||
candidates.push({
|
||||
file: env.npm_node_execpath || execPath,
|
||||
args: [env.npm_execpath, '--version'],
|
||||
source: 'npm_execpath'
|
||||
});
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
candidates.push({
|
||||
file: env.ComSpec || env.COMSPEC || 'cmd.exe',
|
||||
args: ['/d', '/s', '/c', 'npm --version'],
|
||||
source: 'windows-command-shim'
|
||||
});
|
||||
} else {
|
||||
candidates.push({ file: 'npm', args: ['--version'], source: 'path' });
|
||||
}
|
||||
|
||||
return uniqueCandidates(candidates);
|
||||
}
|
||||
|
||||
module.exports = { npmProbeCandidates };
|
||||
@@ -0,0 +1,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
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
|
||||
const zlib = require('node:zlib');
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function dosDateTime(date = new Date()) {
|
||||
const year = Math.max(1980, date.getFullYear());
|
||||
const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
|
||||
const day = date.getDate();
|
||||
const month = date.getMonth() + 1;
|
||||
const dosDate = ((year - 1980) << 9) | (month << 5) | day;
|
||||
return { time, date: dosDate };
|
||||
}
|
||||
|
||||
function createZip(entries) {
|
||||
const localParts = [];
|
||||
const centralParts = [];
|
||||
let offset = 0;
|
||||
const stamp = dosDateTime();
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = Buffer.from(String(entry.name).replace(/\\/g, '/').replace(/^\/+/, ''), 'utf8');
|
||||
const source = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(String(entry.data ?? ''), 'utf8');
|
||||
const compressed = zlib.deflateRawSync(source, { level: 6 });
|
||||
const checksum = crc32(source);
|
||||
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x0800, 6);
|
||||
local.writeUInt16LE(8, 8);
|
||||
local.writeUInt16LE(stamp.time, 10);
|
||||
local.writeUInt16LE(stamp.date, 12);
|
||||
local.writeUInt32LE(checksum, 14);
|
||||
local.writeUInt32LE(compressed.length, 18);
|
||||
local.writeUInt32LE(source.length, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
local.writeUInt16LE(0, 28);
|
||||
localParts.push(local, name, compressed);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(0x0800, 8);
|
||||
central.writeUInt16LE(8, 10);
|
||||
central.writeUInt16LE(stamp.time, 12);
|
||||
central.writeUInt16LE(stamp.date, 14);
|
||||
central.writeUInt32LE(checksum, 16);
|
||||
central.writeUInt32LE(compressed.length, 20);
|
||||
central.writeUInt32LE(source.length, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt16LE(0, 30);
|
||||
central.writeUInt16LE(0, 32);
|
||||
central.writeUInt16LE(0, 34);
|
||||
central.writeUInt16LE(0, 36);
|
||||
central.writeUInt32LE(0, 38);
|
||||
central.writeUInt32LE(offset, 42);
|
||||
centralParts.push(central, name);
|
||||
offset += local.length + name.length + compressed.length;
|
||||
}
|
||||
|
||||
const centralDirectory = Buffer.concat(centralParts);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(0, 4);
|
||||
end.writeUInt16LE(0, 6);
|
||||
end.writeUInt16LE(entries.length, 8);
|
||||
end.writeUInt16LE(entries.length, 10);
|
||||
end.writeUInt32LE(centralDirectory.length, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
end.writeUInt16LE(0, 20);
|
||||
return Buffer.concat([...localParts, centralDirectory, end]);
|
||||
}
|
||||
|
||||
module.exports = { createZip, crc32 };
|
||||
Reference in New Issue
Block a user