'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 };