UNPKG

mcp-product-manager

Version:

MCP Orchestrator for task and project management with web interface

542 lines 23.6 kB
/** * SPAPS Integration Wizard API Route * * This route provides step-by-step SPAPS SDK integration guidance * with validation and concrete file generation for AI agents. */ import { Router } from 'express'; import { getStepDefinition } from './wizard/step-definitions.js'; import { getFilesToCreate, getFilesToModify } from './wizard/file-generators.js'; import { getValidationCommands, validateStep } from './wizard/validation.js'; import { analyzeProject, determineIntegrationApproach } from './wizard/project-analyzer.js'; import WizardStateManager from './wizard/state-manager.js'; import { writeFileSync, mkdirSync, existsSync, readFileSync, copyFileSync, readdirSync, statSync } from 'fs'; import { join, dirname } from 'path'; import path from 'path'; const router = Router(); class SPAPSWizard { constructor() { this.TOTAL_STEPS = 12; this.stateManager = new WizardStateManager(); } async initialize(params) { // Validate project path before proceeding const projectPath = params.project_path || process.cwd(); if (!this.isValidProjectPath(projectPath)) { throw new Error(`Invalid or unsafe project path: ${projectPath}. Must be within user workspace.`); } const state = this.stateManager.createInitialState(params); // Analyze project const analysis = await analyzeProject(state.projectPath); state.detectedFramework = analysis.framework; state.hasAuth = analysis.hasAuth; state.hasPayments = analysis.hasPayments; state.uiLibrary = analysis.uiLibrary; // Determine integration approach state.integrationApproach = determineIntegrationApproach(analysis); await this.stateManager.saveState(state); return await this.getStep(1, state, { lightweight: params.lightweight, write_files: params.write_files }); } async continue(params) { const state = await this.stateManager.loadState(params.project_path || process.cwd()); if (!state) { throw new Error('No wizard session found. Call action=start first.'); } // Validate current step with better error handling let validation; try { validation = await this.validateCurrentStep(state); } catch (error) { console.error('Validation error:', error); // Skip validation if it fails with runtime error validation = { passed: true, skipped: true, error: error.message }; } if (!validation.passed && !validation.skipped) { return { error: "Current step validation failed", step: state.currentStep, validation, action_required: "Complete current step requirements before proceeding" }; } // Mark current step completed and move to next if (!state.completedSteps.includes(state.currentStep)) { state.completedSteps.push(state.currentStep); } state.currentStep++; state.lastActivity = new Date().toISOString(); if (state.currentStep > this.TOTAL_STEPS) { await this.stateManager.saveState(state); return this.generateCompletionSummary(state); } await this.stateManager.saveState(state); // Pass options from params or detect MCP mode return await this.getStep(state.currentStep, state, { lightweight: params.lightweight || process.env.MCP_MODE === 'true', write_files: params.write_files }); } async getStatus(params) { const state = await this.stateManager.loadState(params.project_path || process.cwd()); if (!state) { return { error: 'No wizard session found' }; } return { session_id: state.sessionId, project_path: state.projectPath, project_type: state.projectType, current_step: state.currentStep, total_steps: state.totalSteps, completed_steps: state.completedSteps, progress_percentage: Math.round((state.completedSteps.length / this.TOTAL_STEPS) * 100), detected_framework: state.detectedFramework, has_auth: state.hasAuth, has_payments: state.hasPayments, ui_library: state.uiLibrary, integration_approach: state.integrationApproach, started_at: state.startedAt, last_activity: state.lastActivity }; } async validate(params) { const state = await this.stateManager.loadState(params.project_path || process.cwd()); if (!state) { throw new Error('No wizard session found'); } const stepNumber = params.current_step || state.currentStep; const validation = await validateStep(stepNumber, state, params.validation_data); // Update state with validation results state.validationResults[stepNumber] = validation; await this.stateManager.saveState(state); return validation; } async writeStepFiles(stepNumber, state, projectPath, options = {}) { // Security: Validate project path if (!this.isValidProjectPath(projectPath)) { throw new Error('Invalid project path. Path must be within user workspace.'); } const files = await getFilesToCreate(stepNumber, state.detectedFramework, state.projectType, state.uiLibrary); const filesToModify = getFilesToModify(stepNumber); const written = []; const modified = []; const errors = []; // Use staging directory for safety const stagingDir = options.useStagingDir !== false ? join(projectPath, '.spaps-wizard', 'staging', `step-${stepNumber}`) : projectPath; // Create staging directory if using it if (stagingDir !== projectPath && !existsSync(stagingDir)) { mkdirSync(stagingDir, { recursive: true }); } // Write new files for (const [filePath, content] of Object.entries(files)) { try { // Security: Prevent path traversal if (filePath.includes('..') || path.isAbsolute(filePath)) { throw new Error('Invalid file path: potential path traversal'); } const fullPath = join(stagingDir, filePath); const dir = dirname(fullPath); // Create directory if it doesn't exist if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } // Write file writeFileSync(fullPath, content, 'utf8'); written.push(filePath); } catch (error) { errors.push({ file: filePath, error: error.message }); } } // Modify existing files (only in staging mode for safety) if (filesToModify && stagingDir !== projectPath) { for (const [filePath, modification] of Object.entries(filesToModify)) { try { // Security: Prevent path traversal if (filePath.includes('..') || path.isAbsolute(filePath)) { throw new Error('Invalid file path: potential path traversal'); } const fullPath = join(stagingDir, filePath); if (modification.operation === 'merge' && existsSync(fullPath)) { // Read existing file const existing = readFileSync(fullPath, 'utf8'); let parsed; // Handle JSON files (like package.json) if (filePath.endsWith('.json')) { parsed = JSON.parse(existing); // Deep merge the content for (const [key, value] of Object.entries(modification.content)) { if (typeof value === 'object' && !Array.isArray(value)) { parsed[key] = { ...(parsed[key] || {}), ...value }; } else { parsed[key] = value; } } writeFileSync(fullPath, JSON.stringify(parsed, null, 2), 'utf8'); modified.push(filePath); } } else if (!existsSync(fullPath) && modification.content) { // If file doesn't exist and we have content, create it const dir = dirname(fullPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const content = filePath.endsWith('.json') ? JSON.stringify(modification.content, null, 2) : modification.content; writeFileSync(fullPath, content, 'utf8'); written.push(filePath); } } catch (error) { errors.push({ file: filePath, error: error.message }); } } } return { files_written: written, files_modified: modified, total_files: written.length + modified.length, errors: errors, staging_directory: stagingDir !== projectPath ? stagingDir : null, instructions: stagingDir !== projectPath ? `Files generated in staging directory: ${stagingDir}. Review and move to your project as needed.` : null }; } isValidProjectPath(projectPath) { // Security: Validate project path if (!projectPath) return false; // Resolve to absolute path const resolved = path.resolve(projectPath); // Prevent system directories const dangerousPaths = [ '/etc', '/usr', '/bin', '/sbin', '/var', '/sys', '/proc', 'C:\\Windows', 'C:\\Program Files', 'C:\\System' ]; for (const dangerous of dangerousPaths) { if (resolved.startsWith(dangerous)) { return false; } } // Must be in user's home directory or current working directory subtree const home = process.env.HOME || process.env.USERPROFILE; const cwd = process.cwd(); if (home && resolved.startsWith(home)) return true; if (resolved.startsWith(cwd)) return true; if (resolved.startsWith('/tmp') || resolved.startsWith('/var/tmp')) return true; return false; } async getStep(stepNumber, state, options = {}) { const stepDefinition = getStepDefinition(stepNumber); // Changed: Default to staging mode for safety // Only write directly if explicitly requested with write_files: 'direct' if (options.write_files) { const useStagingDir = options.write_files !== 'direct'; const writeResult = await this.writeStepFiles(stepNumber, state, state.projectPath, { useStagingDir }); return { current_step: stepNumber, total_steps: this.TOTAL_STEPS, step_title: stepDefinition.title, progress: `${state.completedSteps.length}/${this.TOTAL_STEPS} completed`, todos: stepDefinition.todoList, files_written: writeResult.files_written, files_modified: writeResult.files_modified, total_files: writeResult.total_files, errors: writeResult.errors, staging_directory: writeResult.staging_directory, instructions: writeResult.instructions || (useStagingDir ? `Files written to staging. Review in ${writeResult.staging_directory} before moving to project.` : 'Files written directly to project.'), validation_commands: getValidationCommands(stepNumber).slice(0, 3), next_action: stepNumber < this.TOTAL_STEPS ? `Files in staging. Review and call continue for step ${stepNumber + 1}` : `Complete! Review files in staging directory.`, session_id: state.sessionId, safety_mode: useStagingDir ? 'staging' : 'direct' }; } // For MCP or when lightweight mode is requested, return minimal response if (options.lightweight || process.env.MCP_MODE === 'true') { const files = await getFilesToCreate(stepNumber, state.detectedFramework, state.projectType, state.uiLibrary); const fileList = Object.keys(files); return { current_step: stepNumber, total_steps: this.TOTAL_STEPS, step_title: stepDefinition.title, progress: `${state.completedSteps.length}/${this.TOTAL_STEPS} completed`, todos: stepDefinition.todoList, // Instead of full content, return metadata files_to_create: { count: fileList.length, files: fileList, total_size: fileList.reduce((sum, f) => sum + (files[f]?.length || 0), 0), fetch_action: `Use action: "get_files" with step: ${stepNumber} to retrieve content` }, files_to_modify: getFilesToModify(stepNumber), validation_commands: getValidationCommands(stepNumber).slice(0, 3), // Limit validation commands next_action: stepNumber < this.TOTAL_STEPS ? `Call continue with write_files: true to write files` : `Complete!`, session_id: state.sessionId }; } // Full response for REST API return { current_step: stepNumber, total_steps: this.TOTAL_STEPS, step_title: stepDefinition.title, progress: `${state.completedSteps.length}/${this.TOTAL_STEPS} completed`, // Direct TodoWrite input todos: stepDefinition.todoList, // Concrete files to create/modify files_to_create: await getFilesToCreate(stepNumber, state.detectedFramework, state.projectType, state.uiLibrary), files_to_modify: getFilesToModify(stepNumber), // Executable validation commands validation_commands: getValidationCommands(stepNumber), // What to do next next_action: stepNumber < this.TOTAL_STEPS ? `Call mcp__product-manager__spaps_integration_wizard({action: "continue"}) when validation passes` : `Integration complete! Call action: "status" for summary`, // Context project_type: state.projectType, detected_framework: state.detectedFramework, integration_approach: state.integrationApproach, common_mistakes: stepDefinition.commonMistakes, session_id: state.sessionId }; } async validateCurrentStep(state) { const validation = await validateStep(state.currentStep, state); state.validationResults[state.currentStep] = validation; await this.stateManager.saveState(state); return validation; } generateCompletionSummary(state) { return { status: "completed", message: "🎉 SPAPS Integration Wizard completed successfully!", summary: { project_path: state.projectPath, project_type: state.projectType, framework: state.detectedFramework, total_steps: this.TOTAL_STEPS, completed_steps: state.completedSteps.length, session_id: state.sessionId, integration_approach: state.integrationApproach }, achievements: [ "✅ Project setup and environment configured", "✅ SPAPS SDK properly initialized", "✅ Multi-method authentication implemented", "✅ Payment integration complete", "✅ Admin panel and whitelist operations", "✅ Error handling and UI polish", "✅ Comprehensive testing and documentation" ], next_steps: [ "Test all SPAPS features in your application", "Review security configurations for production", "Set up monitoring and error tracking", "Deploy to staging environment for final testing" ], resources: [ "Documentation: https://docs.sweetpotato.dev", "Support: Create issues at GitHub repository", "Community: Join our Discord server" ] }; } } const wizard = new SPAPSWizard(); // Add method to fetch files for a specific step wizard.getStepFiles = async function (params) { const { step, project_path } = params; const state = await this.stateManager.loadState(project_path || process.cwd()); if (!state) { throw new Error('No wizard session found'); } const files = await getFilesToCreate(step, state.detectedFramework, state.projectType, state.uiLibrary); return { step, files_count: Object.keys(files).length, files: files }; }; // Preview what files would be created/modified without writing wizard.previewStep = async function (params) { const { step, project_path } = params; const state = await this.stateManager.loadState(project_path || process.cwd()); if (!state) { throw new Error('No wizard session found'); } const stepNum = step || state.currentStep; const files = await getFilesToCreate(stepNum, state.detectedFramework, state.projectType, state.uiLibrary); const modifications = getFilesToModify(stepNum); return { step: stepNum, mode: 'preview', files_to_create: Object.keys(files).map(f => ({ path: f, size: files[f].length, type: f.split('.').pop() })), files_to_modify: Object.keys(modifications || {}).map(f => ({ path: f, operation: modifications[f].operation })), staging_directory: join(project_path || process.cwd(), '.spaps-wizard', 'staging', `step-${stepNum}`), instructions: 'This is a preview. Use write_files: true to generate files in staging, or write_files: "direct" to write directly to project.' }; }; // Move files from staging to actual project wizard.writeStagedFiles = async function (params) { const { step, project_path, target_path } = params; const projectPath = project_path || process.cwd(); const targetPath = target_path || projectPath; // Validate paths if (!this.isValidProjectPath(targetPath)) { throw new Error('Invalid target path'); } const stagingDir = join(projectPath, '.spaps-wizard', 'staging', `step-${step}`); if (!existsSync(stagingDir)) { throw new Error(`No staged files found for step ${step}`); } // Copy files from staging to target const copied = []; const errors = []; const copyRecursive = (src, dest) => { const stats = statSync(src); if (stats.isDirectory()) { if (!existsSync(dest)) { mkdirSync(dest, { recursive: true }); } readdirSync(src).forEach(file => { copyRecursive(join(src, file), join(dest, file)); }); } else { copyFileSync(src, dest); copied.push(path.relative(stagingDir, src)); } }; try { copyRecursive(stagingDir, targetPath); } catch (error) { errors.push(error.message); } return { files_copied: copied, target_directory: targetPath, errors, message: errors.length > 0 ? 'Some files could not be copied. Check errors.' : `Successfully copied ${copied.length} files to ${targetPath}` }; }; router.post('/api/spaps/wizard', async (req, res) => { try { const { action, ...params } = req.body; let result; switch (action) { case 'start': result = await wizard.initialize(params); break; case 'continue': result = await wizard.continue(params); break; case 'status': result = await wizard.getStatus(params); break; case 'validate': result = await wizard.validate(params); break; case 'get_files': result = await wizard.getStepFiles(params); break; case 'preview': // New action: Preview what would be written without actually writing result = await wizard.previewStep(params); break; case 'write_staged': // New action: Move staged files to actual project result = await wizard.writeStagedFiles(params); break; default: return res.status(400).json({ error: "Unknown action", available_actions: ["start", "continue", "status", "validate", "get_files", "preview", "write_staged"] }); } res.json({ success: true, ...result }); } catch (error) { console.error('[SPAPS Wizard Error]', error); console.error('[Stack]', error.stack); res.status(500).json({ success: false, error: error.message }); } }); // Export the tool definition for MCP compatibility const tool = { name: 'spaps_integration_wizard', description: 'Step-by-step SPAPS integration wizard with validation and project analysis', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['start', 'continue', 'status', 'validate'], description: 'Wizard action to perform' }, project_type: { type: 'string', enum: ['new', 'existing'], description: 'Type of project being integrated' }, project_path: { type: 'string', description: 'Path to project directory (defaults to current working directory)' }, current_step: { type: 'number', minimum: 1, maximum: 12, description: 'Current step number (1-12)' }, validation_results: { type: 'object', description: 'Results from step validation' } }, required: ['action'] } }; export { tool }; export default router; //# sourceMappingURL=wizard.js.map