mcp-product-manager
Version:
MCP Orchestrator for task and project management with web interface
542 lines • 23.6 kB
JavaScript
/**
* 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