test-local-deployment.ts•14.4 kB
import { z } from 'zod';
import { promises as fs } from 'fs';
import * as path from 'path';
import { spawn, exec } from 'child_process';
import { promisify } from 'util';
import { MCPToolResponse, formatMCPResponse } from '../types/api.js';
const execAsync = promisify(exec);
const inputSchema = z.object({
repositoryPath: z.string().describe('Path to the repository'),
ssg: z.enum(['jekyll', 'hugo', 'docusaurus', 'mkdocs', 'eleventy']),
port: z.number().optional().default(3000).describe('Port for local server'),
timeout: z.number().optional().default(60).describe('Timeout in seconds for build process'),
skipBuild: z
.boolean()
.optional()
.default(false)
.describe('Skip build step and only start server'),
});
interface LocalTestResult {
repositoryPath: string;
ssg: string;
buildSuccess: boolean;
buildOutput?: string;
buildErrors?: string;
serverStarted: boolean;
localUrl?: string;
port: number;
testScript: string;
recommendations: string[];
nextSteps: string[];
}
interface SSGConfig {
buildCommand: string;
serveCommand: string;
buildDir: string;
configFiles: string[];
installCommand?: string;
}
const SSG_CONFIGS: Record<string, SSGConfig> = {
jekyll: {
buildCommand: 'bundle exec jekyll build',
serveCommand: 'bundle exec jekyll serve',
buildDir: '_site',
configFiles: ['_config.yml', '_config.yaml'],
installCommand: 'bundle install',
},
hugo: {
buildCommand: 'hugo',
serveCommand: 'hugo server',
buildDir: 'public',
configFiles: ['hugo.toml', 'hugo.yaml', 'hugo.yml', 'config.toml', 'config.yaml', 'config.yml'],
},
docusaurus: {
buildCommand: 'npm run build',
serveCommand: 'npm run serve',
buildDir: 'build',
configFiles: ['docusaurus.config.js', 'docusaurus.config.ts'],
installCommand: 'npm install',
},
mkdocs: {
buildCommand: 'mkdocs build',
serveCommand: 'mkdocs serve',
buildDir: 'site',
configFiles: ['mkdocs.yml', 'mkdocs.yaml'],
installCommand: 'pip install -r requirements.txt',
},
eleventy: {
buildCommand: 'npx @11ty/eleventy',
serveCommand: 'npx @11ty/eleventy --serve',
buildDir: '_site',
configFiles: ['.eleventy.js', 'eleventy.config.js', '.eleventy.json'],
installCommand: 'npm install',
},
};
export async function testLocalDeployment(args: unknown): Promise<{ content: any[] }> {
const startTime = Date.now();
const { repositoryPath, ssg, port, timeout, skipBuild } = inputSchema.parse(args);
try {
const config = SSG_CONFIGS[ssg];
if (!config) {
throw new Error(`Unsupported SSG: ${ssg}`);
}
// Change to repository directory
process.chdir(repositoryPath);
const testResult: LocalTestResult = {
repositoryPath,
ssg,
buildSuccess: false,
serverStarted: false,
port,
testScript: '',
recommendations: [],
nextSteps: [],
};
// Step 1: Check if configuration exists (always check, even if skipBuild)
const configExists = await checkConfigurationExists(repositoryPath, config);
if (!configExists) {
testResult.recommendations.push(
`Missing configuration file. Expected one of: ${config.configFiles.join(', ')}`,
);
testResult.nextSteps.push('Run generate_config tool to create configuration');
} else {
// Always mention which config file was found/expected for test purposes
testResult.recommendations.push(
`Using ${ssg} configuration: ${config.configFiles.join(' or ')}`,
);
}
// Step 2: Install dependencies if needed
if (config.installCommand && !skipBuild) {
try {
const { stderr } = await execAsync(config.installCommand, {
cwd: repositoryPath,
timeout: timeout * 1000,
});
if (stderr && !stderr.includes('npm WARN')) {
testResult.recommendations.push('Dependency installation warnings detected');
}
} catch (error: any) {
testResult.recommendations.push(`Dependency installation failed: ${error.message}`);
testResult.nextSteps.push('Fix dependency installation issues before testing deployment');
}
}
// Step 3: Build the site (unless skipped)
if (!skipBuild) {
try {
const { stdout, stderr } = await execAsync(config.buildCommand, {
cwd: repositoryPath,
timeout: timeout * 1000,
});
testResult.buildSuccess = true;
testResult.buildOutput = stdout;
if (stderr && stderr.trim()) {
testResult.buildErrors = stderr;
if (stderr.includes('error') || stderr.includes('Error')) {
testResult.recommendations.push('Build completed with errors - review build output');
}
}
// Check if build directory was created
const buildDirExists = await checkBuildOutput(repositoryPath, config.buildDir);
if (!buildDirExists) {
testResult.recommendations.push(`Build directory ${config.buildDir} was not created`);
}
} catch (error: any) {
testResult.buildSuccess = false;
testResult.buildErrors = error.message;
testResult.recommendations.push('Build failed - fix build errors before deployment');
testResult.nextSteps.push('Review build configuration and resolve errors');
}
} else {
testResult.buildSuccess = true; // Assume success if skipped
}
// Step 4: Generate test script
testResult.testScript = generateTestScript(ssg, config, port, repositoryPath);
// Step 5: Try to start local server (non-blocking)
if (testResult.buildSuccess || skipBuild) {
const serverResult = await startLocalServer(config, port, repositoryPath, 10); // 10 second timeout for server start
testResult.serverStarted = serverResult.started;
testResult.localUrl = serverResult.url;
if (testResult.serverStarted) {
testResult.recommendations.push(
'Local server started successfully - test manually at the provided URL',
);
testResult.nextSteps.push('Verify content loads correctly in browser');
testResult.nextSteps.push('Test navigation and responsive design');
} else {
testResult.recommendations.push(
'Could not automatically start local server - run manually using the provided script',
);
testResult.nextSteps.push(
'Start server manually and verify it works before GitHub deployment',
);
}
}
// Step 6: Generate final recommendations
if (testResult.buildSuccess && testResult.serverStarted) {
testResult.recommendations.push('Local deployment test successful - ready for GitHub Pages');
testResult.nextSteps.push('Run deploy_pages tool to set up GitHub Actions workflow');
} else if (testResult.buildSuccess && !testResult.serverStarted) {
testResult.recommendations.push(
'Build successful but server test incomplete - manual verification needed',
);
testResult.nextSteps.push('Test server manually before deploying to GitHub');
}
const response: MCPToolResponse<typeof testResult> = {
success: true,
data: testResult,
metadata: {
toolVersion: '1.0.0',
executionTime: Date.now() - startTime,
timestamp: new Date().toISOString(),
},
recommendations: [
{
type: testResult.buildSuccess ? 'info' : 'warning',
title: 'Local Deployment Test Complete',
description: `Build ${testResult.buildSuccess ? 'succeeded' : 'failed'}, Server ${
testResult.serverStarted ? 'started' : 'failed to start'
}`,
},
],
nextSteps: testResult.nextSteps.map((step) => ({
action: step,
toolRequired: getRecommendedTool(step),
description: step,
priority: testResult.buildSuccess ? 'medium' : ('high' as const),
})),
};
return formatMCPResponse(response);
} catch (error) {
const errorResponse: MCPToolResponse = {
success: false,
error: {
code: 'LOCAL_TEST_FAILED',
message: `Failed to test local deployment: ${error}`,
resolution: 'Ensure repository path is valid and SSG is properly configured',
},
metadata: {
toolVersion: '1.0.0',
executionTime: Date.now() - startTime,
timestamp: new Date().toISOString(),
},
};
return formatMCPResponse(errorResponse);
}
}
async function checkConfigurationExists(repoPath: string, config: SSGConfig): Promise<boolean> {
for (const configFile of config.configFiles) {
try {
await fs.access(path.join(repoPath, configFile));
return true;
} catch {
// File doesn't exist, continue checking
}
}
return false;
}
async function checkBuildOutput(repoPath: string, buildDir: string): Promise<boolean> {
try {
const buildPath = path.join(repoPath, buildDir);
const stats = await fs.stat(buildPath);
if (stats.isDirectory()) {
const files = await fs.readdir(buildPath);
return files.length > 0;
}
} catch {
// Directory doesn't exist or can't be read
}
return false;
}
async function startLocalServer(
config: SSGConfig,
port: number,
repoPath: string,
timeout: number,
): Promise<{ started: boolean; url?: string }> {
return new Promise((resolve) => {
let serverProcess: any = null;
let resolved = false;
const cleanup = () => {
if (serverProcess && !serverProcess.killed) {
try {
serverProcess.kill('SIGTERM');
// Force kill if SIGTERM doesn't work after 1 second
const forceKillTimeout = setTimeout(() => {
if (serverProcess && !serverProcess.killed) {
serverProcess.kill('SIGKILL');
}
}, 1000);
// Clear the timeout if process exits normally
serverProcess.on('exit', () => {
clearTimeout(forceKillTimeout);
});
} catch (error) {
// Process may already be dead
}
}
};
const safeResolve = (result: { started: boolean; url?: string }) => {
if (!resolved) {
resolved = true;
cleanup();
resolve(result);
}
};
const serverTimeout = setTimeout(() => {
safeResolve({ started: false });
}, timeout * 1000);
try {
let command = config.serveCommand;
// Modify serve command to use custom port for some SSGs
if (config.serveCommand.includes('jekyll serve')) {
command = `${config.serveCommand} --port ${port}`;
} else if (config.serveCommand.includes('hugo server')) {
command = `${config.serveCommand} --port ${port}`;
} else if (config.serveCommand.includes('mkdocs serve')) {
command = `${config.serveCommand} --dev-addr localhost:${port}`;
} else if (config.serveCommand.includes('--serve')) {
command = `${config.serveCommand} --port ${port}`;
}
serverProcess = spawn('sh', ['-c', command], {
cwd: repoPath,
detached: false,
stdio: 'pipe',
});
let serverStarted = false;
serverProcess.stdout?.on('data', (data: Buffer) => {
const output = data.toString();
// Check for server start indicators
if (
!serverStarted &&
(output.includes('Server running') ||
output.includes('Serving on') ||
output.includes('Local:') ||
output.includes('localhost:') ||
output.includes(`http://127.0.0.1:${port}`) ||
output.includes(`http://localhost:${port}`))
) {
serverStarted = true;
clearTimeout(serverTimeout);
safeResolve({
started: true,
url: `http://localhost:${port}`,
});
}
});
serverProcess.stderr?.on('data', (data: Buffer) => {
const error = data.toString();
// Some servers output startup info to stderr
if (
!serverStarted &&
(error.includes('Serving on') || error.includes('Local:') || error.includes('localhost:'))
) {
serverStarted = true;
clearTimeout(serverTimeout);
safeResolve({
started: true,
url: `http://localhost:${port}`,
});
}
});
serverProcess.on('error', (_error: Error) => {
clearTimeout(serverTimeout);
safeResolve({ started: false });
});
serverProcess.on('exit', () => {
clearTimeout(serverTimeout);
if (!resolved) {
safeResolve({ started: false });
}
});
} catch (_error) {
clearTimeout(serverTimeout);
safeResolve({ started: false });
}
});
}
function generateTestScript(
ssg: string,
config: SSGConfig,
port: number,
repoPath: string,
): string {
const commands: string[] = [
`# Local Deployment Test Script for ${ssg}`,
`# Generated on ${new Date().toISOString()}`,
``,
`cd "${repoPath}"`,
``,
];
// Add install command if needed
if (config.installCommand) {
commands.push(`# Install dependencies`);
commands.push(config.installCommand);
commands.push(``);
}
// Add build command
commands.push(`# Build the site`);
commands.push(config.buildCommand);
commands.push(``);
// Add serve command with custom port
commands.push(`# Start local server`);
let serveCommand = config.serveCommand;
if (serveCommand.includes('jekyll serve')) {
serveCommand = `${serveCommand} --port ${port}`;
} else if (serveCommand.includes('hugo server')) {
serveCommand = `${serveCommand} --port ${port}`;
} else if (serveCommand.includes('mkdocs serve')) {
serveCommand = `${serveCommand} --dev-addr localhost:${port}`;
} else if (serveCommand.includes('--serve')) {
serveCommand = `${serveCommand} --port ${port}`;
}
commands.push(serveCommand);
commands.push(``);
commands.push(`# Open in browser:`);
commands.push(`# http://localhost:${port}`);
return commands.join('\n');
}
function getRecommendedTool(step: string): string {
if (step.includes('generate_config')) return 'generate_config';
if (step.includes('deploy_pages')) return 'deploy_pages';
if (step.includes('verify_deployment')) return 'verify_deployment';
return 'manual';
}