chore: import upstream snapshot with attribution
Build and Push Docker Images / Build Docker Image (push) Has been cancelled
Build and Push Docker Images / Build Railway Docker Image (push) Has been cancelled
Build and Publish n8n Docker Image / test-image (push) Has been cancelled
Dependency Compatibility Check / Fresh Install Dependency Check (push) Has been cancelled
Build and Publish n8n Docker Image / build-and-push (push) Has been cancelled
Build and Publish n8n Docker Image / create-release (push) Has been cancelled
Automated Release / Detect Version Change (push) Has been cancelled
Automated Release / Generate Release Notes (push) Has been cancelled
Automated Release / Create GitHub Release (push) Has been cancelled
Automated Release / Package MCPB Bundle (push) Has been cancelled
Automated Release / Build and Verify (push) Has been cancelled
Automated Release / Publish to NPM (push) Has been cancelled
Automated Release / Build and Push Docker Images (push) Has been cancelled
Automated Release / Update Documentation (push) Has been cancelled
Automated Release / Notify Release Completion (push) Has been cancelled
Secret Scan / secretlint (push) Has been cancelled
Test Suite / test (push) Has been cancelled
Test Suite / cjs-runtime (push) Has been cancelled
Test Suite / publish-results (push) Has been cancelled
Build and Push Docker Images / Build Docker Image (push) Has been cancelled
Build and Push Docker Images / Build Railway Docker Image (push) Has been cancelled
Build and Publish n8n Docker Image / test-image (push) Has been cancelled
Dependency Compatibility Check / Fresh Install Dependency Check (push) Has been cancelled
Build and Publish n8n Docker Image / build-and-push (push) Has been cancelled
Build and Publish n8n Docker Image / create-release (push) Has been cancelled
Automated Release / Detect Version Change (push) Has been cancelled
Automated Release / Generate Release Notes (push) Has been cancelled
Automated Release / Create GitHub Release (push) Has been cancelled
Automated Release / Package MCPB Bundle (push) Has been cancelled
Automated Release / Build and Verify (push) Has been cancelled
Automated Release / Publish to NPM (push) Has been cancelled
Automated Release / Build and Push Docker Images (push) Has been cancelled
Automated Release / Update Documentation (push) Has been cancelled
Automated Release / Notify Release Completion (push) Has been cancelled
Secret Scan / secretlint (push) Has been cancelled
Test Suite / test (push) Has been cancelled
Test Suite / cjs-runtime (push) Has been cancelled
Test Suite / publish-results (push) Has been cancelled
This commit is contained in:
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# Analyze potential optimization savings
|
||||
|
||||
echo "🔍 Analyzing Docker Optimization Potential"
|
||||
echo "=========================================="
|
||||
|
||||
# Check current database size
|
||||
if [ -f data/nodes.db ]; then
|
||||
DB_SIZE=$(du -h data/nodes.db | cut -f1)
|
||||
echo "Current database size: $DB_SIZE"
|
||||
fi
|
||||
|
||||
# Check node_modules size
|
||||
if [ -d node_modules ]; then
|
||||
echo -e "\n📦 Package sizes:"
|
||||
echo "Total node_modules: $(du -sh node_modules | cut -f1)"
|
||||
echo "n8n packages:"
|
||||
for pkg in n8n n8n-core n8n-workflow @n8n/n8n-nodes-langchain; do
|
||||
if [ -d "node_modules/$pkg" ]; then
|
||||
SIZE=$(du -sh "node_modules/$pkg" 2>/dev/null | cut -f1 || echo "N/A")
|
||||
echo " - $pkg: $SIZE"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Check runtime dependencies
|
||||
echo -e "\n🎯 Runtime-only dependencies:"
|
||||
RUNTIME_DEPS="@modelcontextprotocol/sdk better-sqlite3 sql.js express dotenv"
|
||||
RUNTIME_SIZE=0
|
||||
for dep in $RUNTIME_DEPS; do
|
||||
if [ -d "node_modules/$dep" ]; then
|
||||
SIZE=$(du -sh "node_modules/$dep" 2>/dev/null | cut -f1 || echo "0")
|
||||
echo " - $dep: $SIZE"
|
||||
fi
|
||||
done
|
||||
|
||||
# Estimate savings
|
||||
echo -e "\n💡 Optimization potential:"
|
||||
echo "- Current image: 2.61GB"
|
||||
echo "- Estimated optimized: ~200MB"
|
||||
echo "- Savings: ~92%"
|
||||
|
||||
# Show what would be removed
|
||||
echo -e "\n🗑️ Would remove in optimization:"
|
||||
echo "- n8n packages (>2GB)"
|
||||
echo "- Build dependencies"
|
||||
echo "- Documentation files"
|
||||
echo "- Test files"
|
||||
echo "- Source maps"
|
||||
|
||||
# Check if optimized database exists
|
||||
if [ -f data/nodes-optimized.db ]; then
|
||||
OPT_SIZE=$(du -h data/nodes-optimized.db | cut -f1)
|
||||
echo -e "\n✅ Optimized database exists: $OPT_SIZE"
|
||||
fi
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Database Schema Coverage Audit Script
|
||||
*
|
||||
* Audits the database to determine how many nodes have complete schema information
|
||||
* for resourceLocator mode validation. This helps assess the coverage of our
|
||||
* schema-driven validation approach.
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
|
||||
const dbPath = path.join(__dirname, '../data/nodes.db');
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
console.log('=== Schema Coverage Audit ===\n');
|
||||
|
||||
// Query 1: How many nodes have resourceLocator properties?
|
||||
const totalResourceLocator = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM nodes
|
||||
WHERE properties_schema LIKE '%resourceLocator%'
|
||||
`).get() as { count: number };
|
||||
|
||||
console.log(`Nodes with resourceLocator properties: ${totalResourceLocator.count}`);
|
||||
|
||||
// Query 2: Of those, how many have modes defined?
|
||||
const withModes = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM nodes
|
||||
WHERE properties_schema LIKE '%resourceLocator%'
|
||||
AND properties_schema LIKE '%modes%'
|
||||
`).get() as { count: number };
|
||||
|
||||
console.log(`Nodes with modes defined: ${withModes.count}`);
|
||||
|
||||
// Query 3: Which nodes have resourceLocator but NO modes?
|
||||
const withoutModes = db.prepare(`
|
||||
SELECT node_type, display_name
|
||||
FROM nodes
|
||||
WHERE properties_schema LIKE '%resourceLocator%'
|
||||
AND properties_schema NOT LIKE '%modes%'
|
||||
LIMIT 10
|
||||
`).all() as Array<{ node_type: string; display_name: string }>;
|
||||
|
||||
console.log(`\nSample nodes WITHOUT modes (showing 10):`);
|
||||
withoutModes.forEach(node => {
|
||||
console.log(` - ${node.display_name} (${node.node_type})`);
|
||||
});
|
||||
|
||||
// Calculate coverage percentage
|
||||
const coverage = totalResourceLocator.count > 0
|
||||
? (withModes.count / totalResourceLocator.count) * 100
|
||||
: 0;
|
||||
|
||||
console.log(`\nSchema coverage: ${coverage.toFixed(1)}% of resourceLocator nodes have modes defined`);
|
||||
|
||||
// Query 4: Get some examples of nodes WITH modes for verification
|
||||
console.log('\nSample nodes WITH modes (showing 5):');
|
||||
const withModesExamples = db.prepare(`
|
||||
SELECT node_type, display_name
|
||||
FROM nodes
|
||||
WHERE properties_schema LIKE '%resourceLocator%'
|
||||
AND properties_schema LIKE '%modes%'
|
||||
LIMIT 5
|
||||
`).all() as Array<{ node_type: string; display_name: string }>;
|
||||
|
||||
withModesExamples.forEach(node => {
|
||||
console.log(` - ${node.display_name} (${node.node_type})`);
|
||||
});
|
||||
|
||||
// Summary
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(`Total nodes in database: ${db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any as { count: number }.count}`);
|
||||
console.log(`Nodes with resourceLocator: ${totalResourceLocator.count}`);
|
||||
console.log(`Nodes with complete mode schemas: ${withModes.count}`);
|
||||
console.log(`Nodes without mode schemas: ${totalResourceLocator.count - withModes.count}`);
|
||||
console.log(`\nImplication: Schema-driven validation will apply to ${withModes.count} nodes.`);
|
||||
console.log(`For the remaining ${totalResourceLocator.count - withModes.count} nodes, validation will be skipped (graceful degradation).`);
|
||||
|
||||
db.close();
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Backfill script to populate structural hashes for existing workflow mutations
|
||||
*
|
||||
* Purpose: Generates workflow_structure_hash_before and workflow_structure_hash_after
|
||||
* for all existing mutations to enable cross-referencing with telemetry_workflows
|
||||
*
|
||||
* Usage: npx tsx scripts/backfill-mutation-hashes.ts
|
||||
*
|
||||
* Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
*/
|
||||
|
||||
import { WorkflowSanitizer } from '../src/telemetry/workflow-sanitizer.js';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
// Initialize Supabase client
|
||||
const supabaseUrl = process.env.SUPABASE_URL || '';
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || '';
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
console.error('Error: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables are required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
interface MutationRecord {
|
||||
id: string;
|
||||
workflow_before: any;
|
||||
workflow_after: any;
|
||||
workflow_structure_hash_before: string | null;
|
||||
workflow_structure_hash_after: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all mutations that need structural hashes
|
||||
*/
|
||||
async function fetchMutationsToBackfill(): Promise<MutationRecord[]> {
|
||||
console.log('Fetching mutations without structural hashes...');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('workflow_mutations')
|
||||
.select('id, workflow_before, workflow_after, workflow_structure_hash_before, workflow_structure_hash_after')
|
||||
.is('workflow_structure_hash_before', null);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to fetch mutations: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log(`Found ${data?.length || 0} mutations to backfill`);
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate structural hash for a workflow
|
||||
*/
|
||||
function generateStructuralHash(workflow: any): string {
|
||||
try {
|
||||
return WorkflowSanitizer.generateWorkflowHash(workflow);
|
||||
} catch (error) {
|
||||
console.error('Error generating hash:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single mutation with structural hashes
|
||||
*/
|
||||
async function updateMutation(id: string, structureHashBefore: string, structureHashAfter: string): Promise<boolean> {
|
||||
const { error } = await supabase
|
||||
.from('workflow_mutations')
|
||||
.update({
|
||||
workflow_structure_hash_before: structureHashBefore,
|
||||
workflow_structure_hash_after: structureHashAfter,
|
||||
})
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
console.error(`Failed to update mutation ${id}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process mutations in batches
|
||||
*/
|
||||
async function backfillMutations() {
|
||||
const startTime = Date.now();
|
||||
console.log('Starting backfill process...\n');
|
||||
|
||||
// Fetch mutations
|
||||
const mutations = await fetchMutationsToBackfill();
|
||||
|
||||
if (mutations.length === 0) {
|
||||
console.log('No mutations need backfilling. All done!');
|
||||
return;
|
||||
}
|
||||
|
||||
let processedCount = 0;
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const errors: Array<{ id: string; error: string }> = [];
|
||||
|
||||
// Process each mutation
|
||||
for (const mutation of mutations) {
|
||||
try {
|
||||
// Generate structural hashes
|
||||
const structureHashBefore = generateStructuralHash(mutation.workflow_before);
|
||||
const structureHashAfter = generateStructuralHash(mutation.workflow_after);
|
||||
|
||||
if (!structureHashBefore || !structureHashAfter) {
|
||||
console.warn(`Skipping mutation ${mutation.id}: Failed to generate hashes`);
|
||||
errors.push({ id: mutation.id, error: 'Failed to generate hashes' });
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update database
|
||||
const success = await updateMutation(mutation.id, structureHashBefore, structureHashAfter);
|
||||
|
||||
if (success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorCount++;
|
||||
errors.push({ id: mutation.id, error: 'Database update failed' });
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
|
||||
// Progress update every 100 mutations
|
||||
if (processedCount % 100 === 0) {
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
const rate = (processedCount / (Date.now() - startTime) * 1000).toFixed(1);
|
||||
console.log(
|
||||
`Progress: ${processedCount}/${mutations.length} (${((processedCount / mutations.length) * 100).toFixed(1)}%) | ` +
|
||||
`Success: ${successCount} | Errors: ${errorCount} | Rate: ${rate}/s | Elapsed: ${elapsed}s`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error processing mutation ${mutation.id}:`, error);
|
||||
errors.push({ id: mutation.id, error: String(error) });
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Final summary
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('BACKFILL COMPLETE');
|
||||
console.log('='.repeat(80));
|
||||
console.log(`Total mutations processed: ${processedCount}`);
|
||||
console.log(`Successfully updated: ${successCount}`);
|
||||
console.log(`Errors: ${errorCount}`);
|
||||
console.log(`Duration: ${duration}s`);
|
||||
console.log(`Average rate: ${(processedCount / (Date.now() - startTime) * 1000).toFixed(1)} mutations/s`);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log('\nErrors encountered:');
|
||||
errors.slice(0, 10).forEach(({ id, error }) => {
|
||||
console.log(` - ${id}: ${error}`);
|
||||
});
|
||||
if (errors.length > 10) {
|
||||
console.log(` ... and ${errors.length - 10} more errors`);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify cross-reference matches
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('VERIFYING CROSS-REFERENCE MATCHES');
|
||||
console.log('='.repeat(80));
|
||||
|
||||
const { data: statsData, error: statsError } = await supabase.rpc('get_mutation_crossref_stats');
|
||||
|
||||
if (statsError) {
|
||||
console.error('Failed to get cross-reference stats:', statsError.message);
|
||||
} else if (statsData && statsData.length > 0) {
|
||||
const stats = statsData[0];
|
||||
console.log(`Total mutations: ${stats.total_mutations}`);
|
||||
console.log(`Before matches: ${stats.before_matches} (${stats.before_match_rate}%)`);
|
||||
console.log(`After matches: ${stats.after_matches} (${stats.after_match_rate}%)`);
|
||||
console.log(`Both matches: ${stats.both_matches}`);
|
||||
}
|
||||
|
||||
console.log('\nBackfill process completed successfully! ✓');
|
||||
}
|
||||
|
||||
// Run the backfill
|
||||
backfillMutations().catch((error) => {
|
||||
console.error('Fatal error during backfill:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# Optimized Docker build script - no n8n dependencies!
|
||||
|
||||
set -e
|
||||
|
||||
# Enable BuildKit
|
||||
export DOCKER_BUILDKIT=1
|
||||
export COMPOSE_DOCKER_CLI_BUILD=1
|
||||
|
||||
echo "🚀 Building n8n-mcp (runtime-only, no n8n deps)..."
|
||||
echo "💡 This build assumes database is pre-built"
|
||||
|
||||
# Check if nodes.db exists
|
||||
if [ ! -f "data/nodes.db" ]; then
|
||||
echo "⚠️ Warning: data/nodes.db not found!"
|
||||
echo " Run 'npm run rebuild' first to create the database"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build with BuildKit
|
||||
echo "📦 Building Docker image..."
|
||||
|
||||
docker build \
|
||||
--progress=plain \
|
||||
--cache-from type=gha \
|
||||
--cache-from type=registry,ref=ghcr.io/czlonkowski/n8n-mcp:buildcache \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
-t "n8n-mcp:latest" \
|
||||
.
|
||||
|
||||
# Show image size
|
||||
echo ""
|
||||
echo "📊 Image size:"
|
||||
docker images n8n-mcp:latest --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
|
||||
|
||||
# Test the build
|
||||
echo ""
|
||||
echo "🧪 Testing build..."
|
||||
docker run --rm n8n-mcp:latest node -e "console.log('Build OK - Runtime dependencies only!')"
|
||||
|
||||
# Estimate size savings
|
||||
echo ""
|
||||
echo "💰 Size comparison:"
|
||||
echo " Old approach (with n8n deps): ~1.5GB"
|
||||
echo " New approach (runtime only): ~280MB"
|
||||
echo " Savings: ~82% smaller!"
|
||||
|
||||
echo ""
|
||||
echo "✅ Build complete!"
|
||||
echo ""
|
||||
echo "🎯 Next steps:"
|
||||
echo " - Use 'docker run -p 3000:3000 -e AUTH_TOKEN=your-token n8n-mcp:latest' to run"
|
||||
echo " - Use 'docker-compose up' for production deployment"
|
||||
echo " - Remember to rebuild database locally before pushing!"
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# Demonstrate the optimization concept
|
||||
|
||||
echo "🎯 Demonstrating Docker Optimization"
|
||||
echo "===================================="
|
||||
|
||||
# Create a demo directory structure
|
||||
DEMO_DIR="optimization-demo"
|
||||
rm -rf $DEMO_DIR
|
||||
mkdir -p $DEMO_DIR
|
||||
|
||||
# Copy only runtime files
|
||||
echo -e "\n📦 Creating minimal runtime package..."
|
||||
cat > $DEMO_DIR/package.json << 'EOF'
|
||||
{
|
||||
"name": "n8n-mcp-optimized",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "dist/mcp/index.js",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"sql.js": "^1.13.0",
|
||||
"express": "^5.1.0",
|
||||
"dotenv": "^16.5.0"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Copy built files
|
||||
echo "📁 Copying built application..."
|
||||
cp -r dist $DEMO_DIR/
|
||||
cp -r data $DEMO_DIR/
|
||||
mkdir -p $DEMO_DIR/src/database
|
||||
cp src/database/schema*.sql $DEMO_DIR/src/database/
|
||||
|
||||
# Calculate sizes
|
||||
echo -e "\n📊 Size comparison:"
|
||||
echo "Original project: $(du -sh . | cut -f1)"
|
||||
echo "Optimized runtime: $(du -sh $DEMO_DIR | cut -f1)"
|
||||
|
||||
# Show what's included
|
||||
echo -e "\n✅ Optimized package includes:"
|
||||
echo "- Pre-built SQLite database with all node info"
|
||||
echo "- Compiled JavaScript (dist/)"
|
||||
echo "- Minimal runtime dependencies"
|
||||
echo "- No n8n packages needed!"
|
||||
|
||||
# Create a simple test
|
||||
echo -e "\n🧪 Testing database content..."
|
||||
if command -v sqlite3 &> /dev/null; then
|
||||
NODE_COUNT=$(sqlite3 data/nodes.db "SELECT COUNT(*) FROM nodes;" 2>/dev/null || echo "0")
|
||||
AI_COUNT=$(sqlite3 data/nodes.db "SELECT COUNT(*) FROM nodes WHERE is_ai_tool = 1;" 2>/dev/null || echo "0")
|
||||
echo "- Total nodes in database: $NODE_COUNT"
|
||||
echo "- AI-capable nodes: $AI_COUNT"
|
||||
else
|
||||
echo "- SQLite CLI not installed, skipping count"
|
||||
fi
|
||||
|
||||
echo -e "\n💡 This demonstrates that we can run n8n-MCP with:"
|
||||
echo "- ~50MB of runtime dependencies (vs 1.6GB)"
|
||||
echo "- Pre-built database (11MB)"
|
||||
echo "- No n8n packages at runtime"
|
||||
echo "- Total optimized size: ~200MB (vs 2.6GB)"
|
||||
|
||||
# Cleanup
|
||||
echo -e "\n🧹 Cleaning up demo..."
|
||||
rm -rf $DEMO_DIR
|
||||
|
||||
echo -e "\n✨ Optimization concept demonstrated!"
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# Simple deployment script for n8n-MCP HTTP server
|
||||
# For private, single-user deployments only
|
||||
|
||||
set -e
|
||||
|
||||
echo "n8n-MCP HTTP Deployment Script"
|
||||
echo "=============================="
|
||||
echo ""
|
||||
|
||||
# Check if .env exists
|
||||
if [ ! -f .env ]; then
|
||||
echo "Creating .env file..."
|
||||
cp .env.example .env
|
||||
echo ""
|
||||
echo "⚠️ Please edit .env file and set:"
|
||||
echo " - AUTH_TOKEN (generate with: openssl rand -base64 32)"
|
||||
echo " - MCP_MODE=http"
|
||||
echo " - PORT (default 3000)"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if AUTH_TOKEN is set
|
||||
if ! grep -q "AUTH_TOKEN=.*[a-zA-Z0-9]" .env; then
|
||||
echo "ERROR: AUTH_TOKEN not set in .env file"
|
||||
echo "Generate one with: openssl rand -base64 32"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build and start
|
||||
echo "Building project..."
|
||||
npm run build
|
||||
|
||||
echo ""
|
||||
echo "Starting HTTP server..."
|
||||
echo "Use Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
# Start with production settings
|
||||
NODE_ENV=production npm run start:http
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Deployment script for n8n Documentation MCP Server
|
||||
# Target: n8ndocumentation.aiservices.pl
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 n8n Documentation MCP Server - VM Deployment"
|
||||
echo "=============================================="
|
||||
|
||||
# Configuration
|
||||
SERVER_USER=${SERVER_USER:-root}
|
||||
SERVER_HOST=${SERVER_HOST:-n8ndocumentation.aiservices.pl}
|
||||
APP_DIR="/opt/n8n-mcp"
|
||||
SERVICE_NAME="n8n-docs-mcp"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Check if .env exists
|
||||
if [ ! -f .env ]; then
|
||||
echo -e "${RED}❌ .env file not found. Please create it from .env.example${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check required environment variables
|
||||
source .env
|
||||
if [ "$MCP_DOMAIN" != "n8ndocumentation.aiservices.pl" ]; then
|
||||
echo -e "${YELLOW}⚠️ Warning: MCP_DOMAIN is not set to n8ndocumentation.aiservices.pl${NC}"
|
||||
read -p "Continue anyway? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$MCP_AUTH_TOKEN" ] || [ "$MCP_AUTH_TOKEN" == "your-secure-auth-token-here" ]; then
|
||||
echo -e "${RED}❌ MCP_AUTH_TOKEN not set or using default value${NC}"
|
||||
echo "Generate a secure token with: openssl rand -hex 32"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ Configuration validated${NC}"
|
||||
|
||||
# Build the project locally
|
||||
echo -e "\n${YELLOW}Building project...${NC}"
|
||||
npm run build
|
||||
|
||||
# Create deployment package
|
||||
echo -e "\n${YELLOW}Creating deployment package...${NC}"
|
||||
rm -rf deploy-package
|
||||
mkdir -p deploy-package
|
||||
|
||||
# Copy necessary files
|
||||
cp -r dist deploy-package/
|
||||
cp -r data deploy-package/
|
||||
cp package*.json deploy-package/
|
||||
cp .env deploy-package/
|
||||
cp ecosystem.config.js deploy-package/ 2>/dev/null || true
|
||||
|
||||
# Create tarball
|
||||
tar -czf deploy-package.tar.gz deploy-package
|
||||
|
||||
echo -e "${GREEN}✅ Deployment package created${NC}"
|
||||
|
||||
# Upload to server
|
||||
echo -e "\n${YELLOW}Uploading to server...${NC}"
|
||||
scp deploy-package.tar.gz $SERVER_USER@$SERVER_HOST:/tmp/
|
||||
|
||||
# Deploy on server
|
||||
echo -e "\n${YELLOW}Deploying on server...${NC}"
|
||||
ssh $SERVER_USER@$SERVER_HOST << 'ENDSSH'
|
||||
set -e
|
||||
|
||||
# Create app directory
|
||||
mkdir -p /opt/n8n-mcp
|
||||
cd /opt/n8n-mcp
|
||||
|
||||
# Stop existing service if running
|
||||
pm2 stop n8n-docs-mcp 2>/dev/null || true
|
||||
|
||||
# Extract deployment package
|
||||
tar -xzf /tmp/deploy-package.tar.gz --strip-components=1
|
||||
rm /tmp/deploy-package.tar.gz
|
||||
|
||||
# Install production dependencies
|
||||
npm ci --only=production
|
||||
|
||||
# Create PM2 ecosystem file if not exists
|
||||
if [ ! -f ecosystem.config.js ]; then
|
||||
cat > ecosystem.config.js << 'EOF'
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'n8n-docs-mcp',
|
||||
script: './dist/index-http.js',
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '1G',
|
||||
env: {
|
||||
NODE_ENV: 'production'
|
||||
},
|
||||
error_file: './logs/error.log',
|
||||
out_file: './logs/out.log',
|
||||
log_file: './logs/combined.log',
|
||||
time: true
|
||||
}]
|
||||
};
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Create logs directory
|
||||
mkdir -p logs
|
||||
|
||||
# Start with PM2
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
echo ""
|
||||
echo "Service status:"
|
||||
pm2 status n8n-docs-mcp
|
||||
ENDSSH
|
||||
|
||||
# Clean up local files
|
||||
rm -rf deploy-package deploy-package.tar.gz
|
||||
|
||||
echo -e "\n${GREEN}🎉 Deployment successful!${NC}"
|
||||
echo -e "\nServer endpoints:"
|
||||
echo -e " Health: https://$SERVER_HOST/health"
|
||||
echo -e " Stats: https://$SERVER_HOST/stats"
|
||||
echo -e " MCP: https://$SERVER_HOST/mcp"
|
||||
echo -e "\nClaude Desktop configuration:"
|
||||
echo -e " {
|
||||
\"mcpServers\": {
|
||||
\"n8n-nodes-remote\": {
|
||||
\"command\": \"npx\",
|
||||
\"args\": [
|
||||
\"-y\",
|
||||
\"@modelcontextprotocol/client-http\",
|
||||
\"https://$SERVER_HOST/mcp\"
|
||||
],
|
||||
\"env\": {
|
||||
\"MCP_AUTH_TOKEN\": \"$MCP_AUTH_TOKEN\"
|
||||
}
|
||||
}
|
||||
}
|
||||
}"
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Export Webhook Workflow JSONs
|
||||
*
|
||||
* Generates the 4 webhook workflow JSON files needed for integration testing.
|
||||
* These workflows must be imported into n8n and activated manually.
|
||||
*/
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { exportAllWebhookWorkflows } from '../tests/integration/n8n-api/utils/webhook-workflows';
|
||||
|
||||
const OUTPUT_DIR = join(process.cwd(), 'workflows-for-import');
|
||||
|
||||
// Create output directory
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
|
||||
// Generate all workflow JSONs
|
||||
const workflows = exportAllWebhookWorkflows();
|
||||
|
||||
// Write each workflow to a separate file
|
||||
Object.entries(workflows).forEach(([method, workflow]) => {
|
||||
const filename = `webhook-${method.toLowerCase()}.json`;
|
||||
const filepath = join(OUTPUT_DIR, filename);
|
||||
|
||||
writeFileSync(filepath, JSON.stringify(workflow, null, 2), 'utf-8');
|
||||
|
||||
console.log(`✓ Generated: ${filename}`);
|
||||
});
|
||||
|
||||
console.log(`\n✓ All workflow JSONs written to: ${OUTPUT_DIR}`);
|
||||
console.log('\nNext steps:');
|
||||
console.log('1. Import each JSON file into your n8n instance');
|
||||
console.log('2. Activate each workflow in the n8n UI');
|
||||
console.log('3. Copy the webhook URLs from each workflow (open workflow → Webhook node → copy URL)');
|
||||
console.log('4. Add them to your .env file:');
|
||||
console.log(' N8N_TEST_WEBHOOK_GET_URL=https://your-n8n.com/webhook/mcp-test-get');
|
||||
console.log(' N8N_TEST_WEBHOOK_POST_URL=https://your-n8n.com/webhook/mcp-test-post');
|
||||
console.log(' N8N_TEST_WEBHOOK_PUT_URL=https://your-n8n.com/webhook/mcp-test-put');
|
||||
console.log(' N8N_TEST_WEBHOOK_DELETE_URL=https://your-n8n.com/webhook/mcp-test-delete');
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Extract changelog content for a specific version
|
||||
* Used by GitHub Actions to extract release notes
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function extractChangelog(version, changelogPath) {
|
||||
try {
|
||||
if (!fs.existsSync(changelogPath)) {
|
||||
console.error(`Changelog file not found at ${changelogPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(changelogPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the start of this version's section
|
||||
const versionHeaderRegex = new RegExp(`^## \\[${version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`);
|
||||
let startIndex = -1;
|
||||
let endIndex = -1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (versionHeaderRegex.test(lines[i])) {
|
||||
startIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startIndex === -1) {
|
||||
console.error(`No changelog entries found for version ${version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find the end of this version's section (next version or end of file)
|
||||
for (let i = startIndex + 1; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('## [') && !lines[i].includes('Unreleased')) {
|
||||
endIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex === -1) {
|
||||
endIndex = lines.length;
|
||||
}
|
||||
|
||||
// Extract the section content
|
||||
const sectionLines = lines.slice(startIndex, endIndex);
|
||||
|
||||
// Remove the version header and any trailing empty lines
|
||||
let contentLines = sectionLines.slice(1);
|
||||
while (contentLines.length > 0 && contentLines[contentLines.length - 1].trim() === '') {
|
||||
contentLines.pop();
|
||||
}
|
||||
|
||||
if (contentLines.length === 0) {
|
||||
console.error(`No content found for version ${version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const releaseNotes = contentLines.join('\n').trim();
|
||||
|
||||
// Write to stdout for GitHub Actions
|
||||
console.log(releaseNotes);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error extracting changelog: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const version = process.argv[2];
|
||||
const changelogPath = process.argv[3];
|
||||
|
||||
if (!version || !changelogPath) {
|
||||
console.error('Usage: extract-changelog.js <version> <changelog-path>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
extractChangelog(version, changelogPath);
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env node
|
||||
const dotenv = require('dotenv');
|
||||
const { NodeDocumentationService } = require('../dist/services/node-documentation-service');
|
||||
const { NodeSourceExtractor } = require('../dist/utils/node-source-extractor');
|
||||
const { logger } = require('../dist/utils/logger');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
async function extractNodesFromDocker() {
|
||||
logger.info('🐳 Starting Docker-based node extraction...');
|
||||
|
||||
// Add Docker volume paths to environment for NodeSourceExtractor
|
||||
const dockerVolumePaths = [
|
||||
process.env.N8N_MODULES_PATH || '/n8n-modules',
|
||||
process.env.N8N_CUSTOM_PATH || '/n8n-custom',
|
||||
];
|
||||
|
||||
logger.info(`Docker volume paths: ${dockerVolumePaths.join(', ')}`);
|
||||
|
||||
// Check if volumes are mounted
|
||||
for (const volumePath of dockerVolumePaths) {
|
||||
try {
|
||||
await fs.access(volumePath);
|
||||
logger.info(`✅ Volume mounted: ${volumePath}`);
|
||||
|
||||
// List what's in the volume
|
||||
const entries = await fs.readdir(volumePath);
|
||||
logger.info(`Contents of ${volumePath}: ${entries.slice(0, 10).join(', ')}${entries.length > 10 ? '...' : ''}`);
|
||||
} catch (error) {
|
||||
logger.warn(`❌ Volume not accessible: ${volumePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize services
|
||||
const docService = new NodeDocumentationService();
|
||||
const extractor = new NodeSourceExtractor();
|
||||
|
||||
// Extend the extractor's search paths with Docker volumes
|
||||
extractor.n8nBasePaths.unshift(...dockerVolumePaths);
|
||||
|
||||
// Clear existing nodes to ensure we only have latest versions
|
||||
logger.info('🧹 Clearing existing nodes...');
|
||||
const db = docService.db;
|
||||
db.prepare('DELETE FROM nodes').run();
|
||||
|
||||
logger.info('🔍 Searching for n8n nodes in Docker volumes...');
|
||||
|
||||
// Known n8n packages to extract
|
||||
const n8nPackages = [
|
||||
'n8n-nodes-base',
|
||||
'@n8n/n8n-nodes-langchain',
|
||||
'n8n-nodes-extras',
|
||||
];
|
||||
|
||||
let totalExtracted = 0;
|
||||
let ifNodeVersion = null;
|
||||
|
||||
for (const packageName of n8nPackages) {
|
||||
logger.info(`\n📦 Processing package: ${packageName}`);
|
||||
|
||||
try {
|
||||
// Find package in Docker volumes
|
||||
let packagePath = null;
|
||||
|
||||
for (const volumePath of dockerVolumePaths) {
|
||||
const possiblePaths = [
|
||||
path.join(volumePath, packageName),
|
||||
path.join(volumePath, '.pnpm', `${packageName}@*`, 'node_modules', packageName),
|
||||
];
|
||||
|
||||
for (const testPath of possiblePaths) {
|
||||
try {
|
||||
// Use glob pattern to find pnpm packages
|
||||
if (testPath.includes('*')) {
|
||||
const baseDir = path.dirname(testPath.split('*')[0]);
|
||||
const entries = await fs.readdir(baseDir);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.includes(packageName.replace('/', '+'))) {
|
||||
const fullPath = path.join(baseDir, entry, 'node_modules', packageName);
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
packagePath = fullPath;
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await fs.access(testPath);
|
||||
packagePath = testPath;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (packagePath) break;
|
||||
}
|
||||
|
||||
if (!packagePath) {
|
||||
logger.warn(`Package ${packageName} not found in Docker volumes`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Found package at: ${packagePath}`);
|
||||
|
||||
// Check package version
|
||||
try {
|
||||
const packageJsonPath = path.join(packagePath, 'package.json');
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
|
||||
logger.info(`Package version: ${packageJson.version}`);
|
||||
} catch {}
|
||||
|
||||
// Find nodes directory
|
||||
const nodesPath = path.join(packagePath, 'dist', 'nodes');
|
||||
|
||||
try {
|
||||
await fs.access(nodesPath);
|
||||
logger.info(`Scanning nodes directory: ${nodesPath}`);
|
||||
|
||||
// Extract all nodes from this package
|
||||
const nodeEntries = await scanForNodes(nodesPath);
|
||||
logger.info(`Found ${nodeEntries.length} nodes in ${packageName}`);
|
||||
|
||||
for (const nodeEntry of nodeEntries) {
|
||||
try {
|
||||
const nodeName = nodeEntry.name.replace('.node.js', '');
|
||||
const nodeType = `${packageName}.${nodeName}`;
|
||||
|
||||
logger.info(`Extracting: ${nodeType}`);
|
||||
|
||||
// Extract source info
|
||||
const sourceInfo = await extractor.extractNodeSource(nodeType);
|
||||
|
||||
// Check if this is the If node
|
||||
if (nodeName === 'If') {
|
||||
// Look for version in the source code
|
||||
const versionMatch = sourceInfo.sourceCode.match(/version:\s*(\d+)/);
|
||||
if (versionMatch) {
|
||||
ifNodeVersion = versionMatch[1];
|
||||
logger.info(`📍 Found If node version: ${ifNodeVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Store in database
|
||||
await docService.storeNode({
|
||||
nodeType: nodeType,
|
||||
name: nodeName,
|
||||
displayName: nodeName,
|
||||
description: `${nodeName} node from ${packageName}`,
|
||||
sourceCode: sourceInfo.sourceCode,
|
||||
credentialCode: sourceInfo.credentialCode,
|
||||
packageName: packageName,
|
||||
version: ifNodeVersion || '1',
|
||||
hasCredentials: !!sourceInfo.credentialCode,
|
||||
isTrigger: sourceInfo.sourceCode.includes('trigger: true') || nodeName.toLowerCase().includes('trigger'),
|
||||
isWebhook: sourceInfo.sourceCode.includes('webhook: true') || nodeName.toLowerCase().includes('webhook'),
|
||||
});
|
||||
|
||||
totalExtracted++;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to extract ${nodeEntry.name}: ${error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to scan nodes directory: ${error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to process package ${packageName}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`\n✅ Extraction complete!`);
|
||||
logger.info(`📊 Total nodes extracted: ${totalExtracted}`);
|
||||
|
||||
if (ifNodeVersion) {
|
||||
logger.info(`📍 If node version: ${ifNodeVersion}`);
|
||||
if (ifNodeVersion === '2' || ifNodeVersion === '2.2') {
|
||||
logger.info('✅ Successfully extracted latest If node (v2+)!');
|
||||
} else {
|
||||
logger.warn(`⚠️ If node version is ${ifNodeVersion}, expected v2 or higher`);
|
||||
}
|
||||
}
|
||||
|
||||
// Close database
|
||||
docService.close();
|
||||
}
|
||||
|
||||
async function scanForNodes(dirPath) {
|
||||
const nodes = [];
|
||||
|
||||
async function scan(currentPath) {
|
||||
try {
|
||||
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry.name);
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith('.node.js')) {
|
||||
nodes.push({ name: entry.name, path: fullPath });
|
||||
} else if (entry.isDirectory() && entry.name !== 'node_modules') {
|
||||
await scan(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to scan directory ${currentPath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
await scan(dirPath);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// Run extraction
|
||||
extractNodesFromDocker().catch(error => {
|
||||
logger.error('Extraction failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🐳 n8n Node Extraction via Docker"
|
||||
echo "================================="
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${GREEN}[$(date +'%H:%M:%S')]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[$(date +'%H:%M:%S')]${NC} ⚠️ $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[$(date +'%H:%M:%S')]${NC} ❌ $1"
|
||||
}
|
||||
|
||||
# Check if Docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
print_error "Docker is not running. Please start Docker and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_status "Docker is running ✅"
|
||||
|
||||
# Clean up any existing containers
|
||||
print_status "Cleaning up existing containers..."
|
||||
docker-compose -f docker-compose.extract.yml down -v 2>/dev/null || true
|
||||
|
||||
# Build the project first
|
||||
print_status "Building the project..."
|
||||
npm run build
|
||||
|
||||
# Start the extraction process
|
||||
print_status "Starting n8n container to extract latest nodes..."
|
||||
docker-compose -f docker-compose.extract.yml up -d n8n-latest
|
||||
|
||||
# Wait for n8n container to be healthy
|
||||
print_status "Waiting for n8n container to initialize..."
|
||||
ATTEMPTS=0
|
||||
MAX_ATTEMPTS=60
|
||||
|
||||
while [ $ATTEMPTS -lt $MAX_ATTEMPTS ]; do
|
||||
if docker-compose -f docker-compose.extract.yml ps | grep -q "healthy"; then
|
||||
print_status "n8n container is ready ✅"
|
||||
break
|
||||
fi
|
||||
|
||||
ATTEMPTS=$((ATTEMPTS + 1))
|
||||
echo -n "."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ $ATTEMPTS -eq $MAX_ATTEMPTS ]; then
|
||||
print_error "n8n container failed to become healthy"
|
||||
docker-compose -f docker-compose.extract.yml logs n8n-latest
|
||||
docker-compose -f docker-compose.extract.yml down -v
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the extraction
|
||||
print_status "Running node extraction..."
|
||||
docker-compose -f docker-compose.extract.yml run --rm node-extractor
|
||||
|
||||
# Check the results
|
||||
print_status "Checking extraction results..."
|
||||
if [ -f "./data/nodes-fresh.db" ]; then
|
||||
NODE_COUNT=$(sqlite3 ./data/nodes-fresh.db "SELECT COUNT(*) FROM nodes;" 2>/dev/null || echo "0")
|
||||
IF_VERSION=$(sqlite3 ./data/nodes-fresh.db "SELECT version FROM nodes WHERE name='n8n-nodes-base.If' LIMIT 1;" 2>/dev/null || echo "not found")
|
||||
|
||||
print_status "Extracted $NODE_COUNT nodes"
|
||||
print_status "If node version: $IF_VERSION"
|
||||
|
||||
# Check if we got the If node source code and look for version
|
||||
IF_SOURCE=$(sqlite3 ./data/nodes-fresh.db "SELECT source_code FROM nodes WHERE name='n8n-nodes-base.If' LIMIT 1;" 2>/dev/null || echo "")
|
||||
if [[ $IF_SOURCE =~ version:[[:space:]]*([0-9]+) ]]; then
|
||||
IF_CODE_VERSION="${BASH_REMATCH[1]}"
|
||||
print_status "If node version from source code: v$IF_CODE_VERSION"
|
||||
|
||||
if [ "$IF_CODE_VERSION" -ge "2" ]; then
|
||||
print_status "✅ Successfully extracted latest If node (v$IF_CODE_VERSION)!"
|
||||
else
|
||||
print_warning "If node is still v$IF_CODE_VERSION, expected v2 or higher"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
print_error "Database file not found after extraction"
|
||||
fi
|
||||
|
||||
# Clean up
|
||||
print_status "Cleaning up Docker containers..."
|
||||
docker-compose -f docker-compose.extract.yml down -v
|
||||
|
||||
print_status "✨ Extraction complete!"
|
||||
|
||||
# Offer to restart the MCP server
|
||||
echo ""
|
||||
read -p "Would you like to restart the MCP server with the new nodes? (y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_status "Restarting MCP server..."
|
||||
# Kill any existing server process
|
||||
pkill -f "node.*dist/index.js" || true
|
||||
|
||||
# Start the server
|
||||
npm start &
|
||||
print_status "MCP server restarted with fresh node database"
|
||||
fi
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🐳 Simple n8n Node Extraction via Docker"
|
||||
echo "======================================="
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${GREEN}[$(date +'%H:%M:%S')]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[$(date +'%H:%M:%S')]${NC} ⚠️ $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[$(date +'%H:%M:%S')]${NC} ❌ $1"
|
||||
}
|
||||
|
||||
# Check if Docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
print_error "Docker is not running. Please start Docker and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_status "Docker is running ✅"
|
||||
|
||||
# Build the project first
|
||||
print_status "Building the project..."
|
||||
npm run build
|
||||
|
||||
# Create a temporary directory for extraction
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
print_status "Created temporary directory: $TEMP_DIR"
|
||||
|
||||
# Run Docker container to copy node files
|
||||
print_status "Running n8n container to extract nodes..."
|
||||
docker run --rm -d --name n8n-temp n8nio/n8n:latest sleep 300
|
||||
|
||||
# Wait a bit for container to start
|
||||
sleep 5
|
||||
|
||||
# Copy n8n modules from container
|
||||
print_status "Copying n8n modules from container..."
|
||||
docker cp n8n-temp:/usr/local/lib/node_modules/n8n/node_modules "$TEMP_DIR/node_modules" || {
|
||||
print_error "Failed to copy node_modules"
|
||||
docker stop n8n-temp
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Stop the container
|
||||
docker stop n8n-temp
|
||||
|
||||
# Run our extraction script locally
|
||||
print_status "Running extraction script..."
|
||||
NODE_ENV=development \
|
||||
NODE_DB_PATH=./data/nodes-fresh.db \
|
||||
N8N_MODULES_PATH="$TEMP_DIR/node_modules" \
|
||||
node scripts/extract-from-docker.js
|
||||
|
||||
# Clean up
|
||||
print_status "Cleaning up temporary files..."
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
# Check the results
|
||||
print_status "Checking extraction results..."
|
||||
if [ -f "./data/nodes-fresh.db" ]; then
|
||||
NODE_COUNT=$(sqlite3 ./data/nodes-fresh.db "SELECT COUNT(*) FROM nodes;" 2>/dev/null || echo "0")
|
||||
print_status "Extracted $NODE_COUNT nodes"
|
||||
|
||||
# Check if we got the If node source code and look for version
|
||||
IF_SOURCE=$(sqlite3 ./data/nodes-fresh.db "SELECT source_code FROM nodes WHERE node_type='n8n-nodes-base.If' LIMIT 1;" 2>/dev/null || echo "")
|
||||
if [[ $IF_SOURCE =~ version:[[:space:]]*([0-9]+) ]]; then
|
||||
IF_CODE_VERSION="${BASH_REMATCH[1]}"
|
||||
print_status "If node version from source code: v$IF_CODE_VERSION"
|
||||
|
||||
if [ "$IF_CODE_VERSION" -ge "2" ]; then
|
||||
print_status "✅ Successfully extracted latest If node (v$IF_CODE_VERSION)!"
|
||||
else
|
||||
print_warning "If node is still v$IF_CODE_VERSION, expected v2 or higher"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
print_error "Database file not found after extraction"
|
||||
fi
|
||||
|
||||
print_status "✨ Extraction complete!"
|
||||
|
||||
# Offer to restart the MCP server
|
||||
echo ""
|
||||
read -p "Would you like to restart the MCP server with the new nodes? (y/n) " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_status "Restarting MCP server..."
|
||||
# Kill any existing server process
|
||||
pkill -f "node.*dist/index.js" || true
|
||||
|
||||
# Start the server
|
||||
npm start &
|
||||
print_status "MCP server restarted with fresh node database"
|
||||
fi
|
||||
@@ -0,0 +1,675 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
|
||||
/**
|
||||
* Generate detailed test reports in multiple formats
|
||||
*/
|
||||
class TestReportGenerator {
|
||||
constructor() {
|
||||
this.results = {
|
||||
tests: null,
|
||||
coverage: null,
|
||||
benchmarks: null,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
repository: process.env.GITHUB_REPOSITORY || 'n8n-mcp',
|
||||
sha: process.env.GITHUB_SHA || 'unknown',
|
||||
branch: process.env.GITHUB_REF || 'unknown',
|
||||
runId: process.env.GITHUB_RUN_ID || 'local',
|
||||
runNumber: process.env.GITHUB_RUN_NUMBER || '0',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
loadTestResults() {
|
||||
const testResultPath = resolve(process.cwd(), 'test-results/results.json');
|
||||
if (existsSync(testResultPath)) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(testResultPath, 'utf-8'));
|
||||
this.results.tests = this.processTestResults(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading test results:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processTestResults(data) {
|
||||
const processedResults = {
|
||||
summary: {
|
||||
total: data.numTotalTests || 0,
|
||||
passed: data.numPassedTests || 0,
|
||||
failed: data.numFailedTests || 0,
|
||||
skipped: data.numSkippedTests || 0,
|
||||
duration: data.duration || 0,
|
||||
success: (data.numFailedTests || 0) === 0
|
||||
},
|
||||
testSuites: [],
|
||||
failedTests: []
|
||||
};
|
||||
|
||||
// Process test suites
|
||||
if (data.testResults) {
|
||||
for (const suite of data.testResults) {
|
||||
const suiteInfo = {
|
||||
name: suite.name,
|
||||
duration: suite.duration || 0,
|
||||
tests: {
|
||||
total: suite.numPassingTests + suite.numFailingTests + suite.numPendingTests,
|
||||
passed: suite.numPassingTests || 0,
|
||||
failed: suite.numFailingTests || 0,
|
||||
skipped: suite.numPendingTests || 0
|
||||
},
|
||||
status: suite.numFailingTests === 0 ? 'passed' : 'failed'
|
||||
};
|
||||
|
||||
processedResults.testSuites.push(suiteInfo);
|
||||
|
||||
// Collect failed tests
|
||||
if (suite.testResults) {
|
||||
for (const test of suite.testResults) {
|
||||
if (test.status === 'failed') {
|
||||
processedResults.failedTests.push({
|
||||
suite: suite.name,
|
||||
test: test.title,
|
||||
duration: test.duration || 0,
|
||||
error: test.failureMessages ? test.failureMessages.join('\n') : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processedResults;
|
||||
}
|
||||
|
||||
loadCoverageResults() {
|
||||
const coveragePath = resolve(process.cwd(), 'coverage/coverage-summary.json');
|
||||
if (existsSync(coveragePath)) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(coveragePath, 'utf-8'));
|
||||
this.results.coverage = this.processCoverageResults(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading coverage results:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processCoverageResults(data) {
|
||||
const coverage = {
|
||||
summary: {
|
||||
lines: data.total.lines.pct,
|
||||
statements: data.total.statements.pct,
|
||||
functions: data.total.functions.pct,
|
||||
branches: data.total.branches.pct,
|
||||
average: 0
|
||||
},
|
||||
files: []
|
||||
};
|
||||
|
||||
// Calculate average
|
||||
coverage.summary.average = (
|
||||
coverage.summary.lines +
|
||||
coverage.summary.statements +
|
||||
coverage.summary.functions +
|
||||
coverage.summary.branches
|
||||
) / 4;
|
||||
|
||||
// Process file coverage
|
||||
for (const [filePath, fileData] of Object.entries(data)) {
|
||||
if (filePath !== 'total') {
|
||||
coverage.files.push({
|
||||
path: filePath,
|
||||
lines: fileData.lines.pct,
|
||||
statements: fileData.statements.pct,
|
||||
functions: fileData.functions.pct,
|
||||
branches: fileData.branches.pct,
|
||||
uncoveredLines: fileData.lines.total - fileData.lines.covered
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort files by coverage (lowest first)
|
||||
coverage.files.sort((a, b) => a.lines - b.lines);
|
||||
|
||||
return coverage;
|
||||
}
|
||||
|
||||
loadBenchmarkResults() {
|
||||
const benchmarkPath = resolve(process.cwd(), 'benchmark-results.json');
|
||||
if (existsSync(benchmarkPath)) {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(benchmarkPath, 'utf-8'));
|
||||
this.results.benchmarks = this.processBenchmarkResults(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading benchmark results:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processBenchmarkResults(data) {
|
||||
const benchmarks = {
|
||||
timestamp: data.timestamp,
|
||||
results: []
|
||||
};
|
||||
|
||||
for (const file of data.files || []) {
|
||||
for (const group of file.groups || []) {
|
||||
for (const benchmark of group.benchmarks || []) {
|
||||
benchmarks.results.push({
|
||||
file: file.filepath,
|
||||
group: group.name,
|
||||
name: benchmark.name,
|
||||
ops: benchmark.result.hz,
|
||||
mean: benchmark.result.mean,
|
||||
min: benchmark.result.min,
|
||||
max: benchmark.result.max,
|
||||
p75: benchmark.result.p75,
|
||||
p99: benchmark.result.p99,
|
||||
samples: benchmark.result.samples
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by ops/sec (highest first)
|
||||
benchmarks.results.sort((a, b) => b.ops - a.ops);
|
||||
|
||||
return benchmarks;
|
||||
}
|
||||
|
||||
generateMarkdownReport() {
|
||||
let report = '# n8n-mcp Test Report\n\n';
|
||||
report += `Generated: ${this.results.metadata.timestamp}\n\n`;
|
||||
|
||||
// Metadata
|
||||
report += '## Build Information\n\n';
|
||||
report += `- **Repository**: ${this.results.metadata.repository}\n`;
|
||||
report += `- **Commit**: ${this.results.metadata.sha.substring(0, 7)}\n`;
|
||||
report += `- **Branch**: ${this.results.metadata.branch}\n`;
|
||||
report += `- **Run**: #${this.results.metadata.runNumber}\n\n`;
|
||||
|
||||
// Test Results
|
||||
if (this.results.tests) {
|
||||
const { summary, testSuites, failedTests } = this.results.tests;
|
||||
const emoji = summary.success ? '✅' : '❌';
|
||||
|
||||
report += `## ${emoji} Test Results\n\n`;
|
||||
report += `### Summary\n\n`;
|
||||
report += `- **Total Tests**: ${summary.total}\n`;
|
||||
report += `- **Passed**: ${summary.passed} (${((summary.passed / summary.total) * 100).toFixed(1)}%)\n`;
|
||||
report += `- **Failed**: ${summary.failed}\n`;
|
||||
report += `- **Skipped**: ${summary.skipped}\n`;
|
||||
report += `- **Duration**: ${(summary.duration / 1000).toFixed(2)}s\n\n`;
|
||||
|
||||
// Test Suites
|
||||
if (testSuites.length > 0) {
|
||||
report += '### Test Suites\n\n';
|
||||
report += '| Suite | Status | Tests | Duration |\n';
|
||||
report += '|-------|--------|-------|----------|\n';
|
||||
|
||||
for (const suite of testSuites) {
|
||||
const status = suite.status === 'passed' ? '✅' : '❌';
|
||||
const tests = `${suite.tests.passed}/${suite.tests.total}`;
|
||||
const duration = `${(suite.duration / 1000).toFixed(2)}s`;
|
||||
report += `| ${suite.name} | ${status} | ${tests} | ${duration} |\n`;
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
// Failed Tests
|
||||
if (failedTests.length > 0) {
|
||||
report += '### Failed Tests\n\n';
|
||||
for (const failed of failedTests) {
|
||||
report += `#### ${failed.suite} > ${failed.test}\n\n`;
|
||||
report += '```\n';
|
||||
report += failed.error;
|
||||
report += '\n```\n\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage Results
|
||||
if (this.results.coverage) {
|
||||
const { summary, files } = this.results.coverage;
|
||||
const emoji = summary.average >= 80 ? '✅' : summary.average >= 60 ? '⚠️' : '❌';
|
||||
|
||||
report += `## ${emoji} Coverage Report\n\n`;
|
||||
report += '### Summary\n\n';
|
||||
report += `- **Lines**: ${summary.lines.toFixed(2)}%\n`;
|
||||
report += `- **Statements**: ${summary.statements.toFixed(2)}%\n`;
|
||||
report += `- **Functions**: ${summary.functions.toFixed(2)}%\n`;
|
||||
report += `- **Branches**: ${summary.branches.toFixed(2)}%\n`;
|
||||
report += `- **Average**: ${summary.average.toFixed(2)}%\n\n`;
|
||||
|
||||
// Files with low coverage
|
||||
const lowCoverageFiles = files.filter(f => f.lines < 80).slice(0, 10);
|
||||
if (lowCoverageFiles.length > 0) {
|
||||
report += '### Files with Low Coverage\n\n';
|
||||
report += '| File | Lines | Uncovered Lines |\n';
|
||||
report += '|------|-------|----------------|\n';
|
||||
|
||||
for (const file of lowCoverageFiles) {
|
||||
const fileName = file.path.split('/').pop();
|
||||
report += `| ${fileName} | ${file.lines.toFixed(1)}% | ${file.uncoveredLines} |\n`;
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark Results
|
||||
if (this.results.benchmarks && this.results.benchmarks.results.length > 0) {
|
||||
report += '## ⚡ Benchmark Results\n\n';
|
||||
report += '### Top Performers\n\n';
|
||||
report += '| Benchmark | Ops/sec | Mean (ms) | Samples |\n';
|
||||
report += '|-----------|---------|-----------|----------|\n';
|
||||
|
||||
for (const bench of this.results.benchmarks.results.slice(0, 10)) {
|
||||
const opsFormatted = bench.ops.toLocaleString('en-US', { maximumFractionDigits: 0 });
|
||||
const meanFormatted = (bench.mean * 1000).toFixed(3);
|
||||
report += `| ${bench.name} | ${opsFormatted} | ${meanFormatted} | ${bench.samples} |\n`;
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
generateJsonReport() {
|
||||
return JSON.stringify(this.results, null, 2);
|
||||
}
|
||||
|
||||
generateHtmlReport() {
|
||||
const htmlTemplate = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>n8n-mcp Test Report</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
.metadata {
|
||||
opacity: 0.9;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.section {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.section h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #eee;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.stat-card {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
.stat-card .value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
.stat-card .label {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.success { color: #28a745; }
|
||||
.warning { color: #ffc107; }
|
||||
.danger { color: #dc3545; }
|
||||
.failed-test {
|
||||
background-color: #fff5f5;
|
||||
border: 1px solid #feb2b2;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.failed-test h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #c53030;
|
||||
}
|
||||
.error-message {
|
||||
background-color: #1a202c;
|
||||
color: #e2e8f0;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #28a745 0%, #20c997 100%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.coverage-low { background: linear-gradient(90deg, #dc3545 0%, #f86734 100%); }
|
||||
.coverage-medium { background: linear-gradient(90deg, #ffc107 0%, #ffb347 100%); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>n8n-mcp Test Report</h1>
|
||||
<div class="metadata">
|
||||
<div>Repository: ${this.results.metadata.repository}</div>
|
||||
<div>Commit: ${this.results.metadata.sha.substring(0, 7)}</div>
|
||||
<div>Run: #${this.results.metadata.runNumber}</div>
|
||||
<div>Generated: ${new Date(this.results.metadata.timestamp).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.generateTestResultsHtml()}
|
||||
${this.generateCoverageHtml()}
|
||||
${this.generateBenchmarkHtml()}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return htmlTemplate;
|
||||
}
|
||||
|
||||
generateTestResultsHtml() {
|
||||
if (!this.results.tests) return '';
|
||||
|
||||
const { summary, testSuites, failedTests } = this.results.tests;
|
||||
const successRate = ((summary.passed / summary.total) * 100).toFixed(1);
|
||||
const statusClass = summary.success ? 'success' : 'danger';
|
||||
const statusIcon = summary.success ? '✅' : '❌';
|
||||
|
||||
let html = `
|
||||
<div class="section">
|
||||
<h2>${statusIcon} Test Results</h2>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="value">${summary.total}</div>
|
||||
<div class="label">Total Tests</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value ${statusClass}">${summary.passed}</div>
|
||||
<div class="label">Passed</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value ${summary.failed > 0 ? 'danger' : ''}">${summary.failed}</div>
|
||||
<div class="label">Failed</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">${successRate}%</div>
|
||||
<div class="label">Success Rate</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">${(summary.duration / 1000).toFixed(1)}s</div>
|
||||
<div class="label">Duration</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (testSuites.length > 0) {
|
||||
html += `
|
||||
<h3>Test Suites</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Suite</th>
|
||||
<th>Status</th>
|
||||
<th>Tests</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const suite of testSuites) {
|
||||
const status = suite.status === 'passed' ? '✅' : '❌';
|
||||
const statusClass = suite.status === 'passed' ? 'success' : 'danger';
|
||||
html += `
|
||||
<tr>
|
||||
<td>${suite.name}</td>
|
||||
<td class="${statusClass}">${status}</td>
|
||||
<td>${suite.tests.passed}/${suite.tests.total}</td>
|
||||
<td>${(suite.duration / 1000).toFixed(2)}s</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
if (failedTests.length > 0) {
|
||||
html += `
|
||||
<h3>Failed Tests</h3>`;
|
||||
|
||||
for (const failed of failedTests) {
|
||||
html += `
|
||||
<div class="failed-test">
|
||||
<h4>${failed.suite} > ${failed.test}</h4>
|
||||
<div class="error-message">${this.escapeHtml(failed.error)}</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
generateCoverageHtml() {
|
||||
if (!this.results.coverage) return '';
|
||||
|
||||
const { summary, files } = this.results.coverage;
|
||||
const coverageClass = summary.average >= 80 ? 'success' : summary.average >= 60 ? 'warning' : 'danger';
|
||||
const progressClass = summary.average >= 80 ? '' : summary.average >= 60 ? 'coverage-medium' : 'coverage-low';
|
||||
|
||||
let html = `
|
||||
<div class="section">
|
||||
<h2>📊 Coverage Report</h2>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="value ${coverageClass}">${summary.average.toFixed(1)}%</div>
|
||||
<div class="label">Average Coverage</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">${summary.lines.toFixed(1)}%</div>
|
||||
<div class="label">Lines</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">${summary.statements.toFixed(1)}%</div>
|
||||
<div class="label">Statements</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">${summary.functions.toFixed(1)}%</div>
|
||||
<div class="label">Functions</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="value">${summary.branches.toFixed(1)}%</div>
|
||||
<div class="label">Branches</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill ${progressClass}" style="width: ${summary.average}%"></div>
|
||||
</div>`;
|
||||
|
||||
const lowCoverageFiles = files.filter(f => f.lines < 80).slice(0, 10);
|
||||
if (lowCoverageFiles.length > 0) {
|
||||
html += `
|
||||
<h3>Files with Low Coverage</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Lines</th>
|
||||
<th>Statements</th>
|
||||
<th>Functions</th>
|
||||
<th>Branches</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const file of lowCoverageFiles) {
|
||||
const fileName = file.path.split('/').pop();
|
||||
html += `
|
||||
<tr>
|
||||
<td>${fileName}</td>
|
||||
<td class="${file.lines < 50 ? 'danger' : file.lines < 80 ? 'warning' : ''}">${file.lines.toFixed(1)}%</td>
|
||||
<td>${file.statements.toFixed(1)}%</td>
|
||||
<td>${file.functions.toFixed(1)}%</td>
|
||||
<td>${file.branches.toFixed(1)}%</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
generateBenchmarkHtml() {
|
||||
if (!this.results.benchmarks || this.results.benchmarks.results.length === 0) return '';
|
||||
|
||||
let html = `
|
||||
<div class="section">
|
||||
<h2>⚡ Benchmark Results</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benchmark</th>
|
||||
<th>Operations/sec</th>
|
||||
<th>Mean Time (ms)</th>
|
||||
<th>Min (ms)</th>
|
||||
<th>Max (ms)</th>
|
||||
<th>Samples</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const bench of this.results.benchmarks.results.slice(0, 20)) {
|
||||
const opsFormatted = bench.ops.toLocaleString('en-US', { maximumFractionDigits: 0 });
|
||||
const meanFormatted = (bench.mean * 1000).toFixed(3);
|
||||
const minFormatted = (bench.min * 1000).toFixed(3);
|
||||
const maxFormatted = (bench.max * 1000).toFixed(3);
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td>${bench.name}</td>
|
||||
<td><strong>${opsFormatted}</strong></td>
|
||||
<td>${meanFormatted}</td>
|
||||
<td>${minFormatted}</td>
|
||||
<td>${maxFormatted}</td>
|
||||
<td>${bench.samples}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>`;
|
||||
|
||||
if (this.results.benchmarks.results.length > 20) {
|
||||
html += `<p><em>Showing top 20 of ${this.results.benchmarks.results.length} benchmarks</em></p>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
}
|
||||
|
||||
async generate() {
|
||||
// Load all results
|
||||
this.loadTestResults();
|
||||
this.loadCoverageResults();
|
||||
this.loadBenchmarkResults();
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = resolve(process.cwd(), 'test-reports');
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate reports in different formats
|
||||
const markdownReport = this.generateMarkdownReport();
|
||||
const jsonReport = this.generateJsonReport();
|
||||
const htmlReport = this.generateHtmlReport();
|
||||
|
||||
// Write reports
|
||||
writeFileSync(resolve(outputDir, 'report.md'), markdownReport);
|
||||
writeFileSync(resolve(outputDir, 'report.json'), jsonReport);
|
||||
writeFileSync(resolve(outputDir, 'report.html'), htmlReport);
|
||||
|
||||
console.log('Test reports generated successfully:');
|
||||
console.log('- test-reports/report.md');
|
||||
console.log('- test-reports/report.json');
|
||||
console.log('- test-reports/report.html');
|
||||
}
|
||||
}
|
||||
|
||||
// Run the generator
|
||||
const generator = new TestReportGenerator();
|
||||
generator.generate().catch(console.error);
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate release notes for the initial release
|
||||
* Used by GitHub Actions when no previous tag exists
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
function generateInitialReleaseNotes(version) {
|
||||
try {
|
||||
// Get total commit count
|
||||
const commitCount = execSync('git rev-list --count HEAD', { encoding: 'utf8' }).trim();
|
||||
|
||||
// Generate release notes
|
||||
const releaseNotes = [
|
||||
'### 🎉 Initial Release',
|
||||
'',
|
||||
`This is the initial release of n8n-mcp v${version}.`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'**Release Statistics:**',
|
||||
`- Commit count: ${commitCount}`,
|
||||
'- First release setup'
|
||||
];
|
||||
|
||||
return releaseNotes.join('\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error generating initial release notes: ${error.message}`);
|
||||
return `Failed to generate initial release notes: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const version = process.argv[2];
|
||||
|
||||
if (!version) {
|
||||
console.error('Usage: generate-initial-release-notes.js <version>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const releaseNotes = generateInitialReleaseNotes(version);
|
||||
console.log(releaseNotes);
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* generate-mcpb-manifest.js
|
||||
*
|
||||
* Generates manifest.json for the MCPB (MCP Bundle) used for one-click install
|
||||
* in Claude Desktop and other MCP hosts.
|
||||
*
|
||||
* The version and the advertised tool list are derived from the single sources
|
||||
* of truth (package.json and the live MCP tool registry) so the bundle metadata
|
||||
* can never drift from the server, the way the hand-maintained manifest did.
|
||||
*
|
||||
* The project must be built first: the tool list is read from dist/mcp/*.js.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/generate-mcpb-manifest.js # write manifest.json
|
||||
* node scripts/generate-mcpb-manifest.js --check # exit 1 if manifest.json is stale
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const MANIFEST_PATH = path.join(ROOT, 'manifest.json');
|
||||
|
||||
/**
|
||||
* Load the full tool registry from the built output. Both arrays are plain
|
||||
* data (their only import is a TypeScript type), so requiring them has no
|
||||
* side effects.
|
||||
*/
|
||||
function loadTools() {
|
||||
const docToolsPath = path.join(ROOT, 'dist/mcp/tools.js');
|
||||
const mgmtToolsPath = path.join(ROOT, 'dist/mcp/tools-n8n-manager.js');
|
||||
|
||||
for (const p of [docToolsPath, mgmtToolsPath]) {
|
||||
if (!fs.existsSync(p)) {
|
||||
console.error(`✖ ${path.relative(ROOT, p)} not found. Run "npm run build" first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const docTools = require(docToolsPath).n8nDocumentationToolsFinal;
|
||||
const mgmtTools = require(mgmtToolsPath).n8nManagementTools;
|
||||
return [...docTools, ...mgmtTools];
|
||||
}
|
||||
|
||||
/** Reduce a (possibly multi-line, multi-sentence) tool description to one tidy line. */
|
||||
function shortDescription(description) {
|
||||
const oneLine = String(description).replace(/\s+/g, ' ').trim();
|
||||
const match = oneLine.match(/^(.*?[.!?])(\s|$)/);
|
||||
let sentence = match ? match[1] : oneLine;
|
||||
if (sentence.length > 160) {
|
||||
sentence = sentence.slice(0, 157).trimEnd() + '...';
|
||||
}
|
||||
return sentence;
|
||||
}
|
||||
|
||||
function buildManifest() {
|
||||
const pkg = require(path.join(ROOT, 'package.json'));
|
||||
const tools = loadTools().map(t => ({
|
||||
name: t.name,
|
||||
description: shortDescription(t.description),
|
||||
}));
|
||||
|
||||
return {
|
||||
manifest_version: '0.3',
|
||||
name: 'n8n-mcp',
|
||||
display_name: 'n8n-MCP',
|
||||
version: pkg.version,
|
||||
description:
|
||||
'MCP server providing AI assistants with comprehensive access to n8n node ' +
|
||||
'documentation and workflow management capabilities',
|
||||
author: {
|
||||
name: 'Romuald Członkowski',
|
||||
url: 'https://www.aiadvisors.pl/en',
|
||||
},
|
||||
repository: {
|
||||
type: 'git',
|
||||
url: 'https://github.com/czlonkowski/n8n-mcp',
|
||||
},
|
||||
homepage: 'https://www.n8n-mcp.com/',
|
||||
documentation: 'https://github.com/czlonkowski/n8n-mcp#readme',
|
||||
support: 'https://github.com/czlonkowski/n8n-mcp/issues',
|
||||
license: 'MIT',
|
||||
keywords: ['n8n', 'mcp', 'workflow', 'automation', 'ai', 'documentation', 'model-context-protocol'],
|
||||
privacy_policies: ['https://n8n.io/legal/privacy/'],
|
||||
server: {
|
||||
type: 'node',
|
||||
// Required by the v0.3 schema. This bundle launches via npx (see mcp_config
|
||||
// below), so entry_point is documentation-only; it must still resolve on disk
|
||||
// at pack time, which is why the bundle is packed after "npm run build".
|
||||
entry_point: 'dist/mcp/index.js',
|
||||
mcp_config: {
|
||||
command: 'npx',
|
||||
args: ['-y', 'n8n-mcp'],
|
||||
env: {
|
||||
MCP_MODE: 'stdio',
|
||||
LOG_LEVEL: 'error',
|
||||
DISABLE_CONSOLE_OUTPUT: 'true',
|
||||
N8N_API_URL: '${user_config.n8n_api_url}',
|
||||
N8N_API_KEY: '${user_config.n8n_api_key}',
|
||||
N8N_MCP_TELEMETRY_DISABLED: '${user_config.n8n_mcp_telemetry_disabled}',
|
||||
},
|
||||
},
|
||||
},
|
||||
user_config: {
|
||||
n8n_api_url: {
|
||||
type: 'string',
|
||||
title: 'n8n Instance URL',
|
||||
description:
|
||||
'URL of your n8n instance (e.g., https://your-n8n.com or http://localhost:5678). ' +
|
||||
'Leave empty for documentation-only mode.',
|
||||
required: false,
|
||||
},
|
||||
n8n_api_key: {
|
||||
type: 'string',
|
||||
title: 'n8n API Key',
|
||||
description: 'API key from your n8n instance Settings > API. Required for workflow management features.',
|
||||
required: false,
|
||||
sensitive: true,
|
||||
},
|
||||
n8n_mcp_telemetry_disabled: {
|
||||
type: 'boolean',
|
||||
title: 'Disable Telemetry',
|
||||
description: 'Enable to turn off anonymous usage telemetry. Telemetry is on by default.',
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
compatibility: {
|
||||
claude_desktop: '>=0.10.0',
|
||||
platforms: ['darwin', 'win32', 'linux'],
|
||||
runtimes: {
|
||||
// npx needs a host Node install; declaring it lets hosts fail fast with a
|
||||
// clear message instead of npx erroring at launch.
|
||||
node: '>=20',
|
||||
},
|
||||
},
|
||||
tools_generated: false,
|
||||
tools,
|
||||
};
|
||||
}
|
||||
|
||||
function serialize(manifest) {
|
||||
return JSON.stringify(manifest, null, 2) + '\n';
|
||||
}
|
||||
|
||||
function main() {
|
||||
const check = process.argv.includes('--check');
|
||||
const output = serialize(buildManifest());
|
||||
|
||||
if (check) {
|
||||
const existing = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : '';
|
||||
if (existing !== output) {
|
||||
console.error('✖ manifest.json is out of date. Run: npm run generate:mcpb-manifest');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✓ manifest.json is up to date');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(MANIFEST_PATH, output);
|
||||
const manifest = buildManifest();
|
||||
console.log(`✓ Wrote manifest.json (version ${manifest.version}, ${manifest.tools.length} tools)`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generate release notes from commit messages between two tags
|
||||
* Used by GitHub Actions to create automated release notes
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function generateReleaseNotes(previousTag, currentTag) {
|
||||
try {
|
||||
console.log(`Generating release notes from ${previousTag} to ${currentTag}`);
|
||||
|
||||
// Get commits between tags
|
||||
const gitLogCommand = `git log --pretty=format:"%H|%s|%an|%ae|%ad" --date=short --no-merges ${previousTag}..${currentTag}`;
|
||||
const commitsOutput = execSync(gitLogCommand, { encoding: 'utf8' });
|
||||
|
||||
if (!commitsOutput.trim()) {
|
||||
console.log('No commits found between tags');
|
||||
return 'No changes in this release.';
|
||||
}
|
||||
|
||||
const commits = commitsOutput.trim().split('\n').map(line => {
|
||||
const [hash, subject, author, email, date] = line.split('|');
|
||||
return { hash, subject, author, email, date };
|
||||
});
|
||||
|
||||
// Categorize commits
|
||||
const categories = {
|
||||
'feat': { title: '✨ Features', commits: [] },
|
||||
'fix': { title: '🐛 Bug Fixes', commits: [] },
|
||||
'docs': { title: '📚 Documentation', commits: [] },
|
||||
'refactor': { title: '♻️ Refactoring', commits: [] },
|
||||
'test': { title: '🧪 Testing', commits: [] },
|
||||
'perf': { title: '⚡ Performance', commits: [] },
|
||||
'style': { title: '💅 Styling', commits: [] },
|
||||
'ci': { title: '🔧 CI/CD', commits: [] },
|
||||
'build': { title: '📦 Build', commits: [] },
|
||||
'chore': { title: '🔧 Maintenance', commits: [] },
|
||||
'other': { title: '📝 Other Changes', commits: [] }
|
||||
};
|
||||
|
||||
commits.forEach(commit => {
|
||||
const subject = commit.subject.toLowerCase();
|
||||
let categorized = false;
|
||||
|
||||
// Check for conventional commit prefixes
|
||||
for (const [prefix, category] of Object.entries(categories)) {
|
||||
if (prefix !== 'other' && subject.startsWith(`${prefix}:`)) {
|
||||
category.commits.push(commit);
|
||||
categorized = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If not categorized, put in other
|
||||
if (!categorized) {
|
||||
categories.other.commits.push(commit);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate release notes
|
||||
const releaseNotes = [];
|
||||
|
||||
for (const [key, category] of Object.entries(categories)) {
|
||||
if (category.commits.length > 0) {
|
||||
releaseNotes.push(`### ${category.title}`);
|
||||
releaseNotes.push('');
|
||||
|
||||
category.commits.forEach(commit => {
|
||||
// Clean up the subject by removing the prefix if it exists
|
||||
let cleanSubject = commit.subject;
|
||||
const colonIndex = cleanSubject.indexOf(':');
|
||||
if (colonIndex !== -1 && cleanSubject.substring(0, colonIndex).match(/^(feat|fix|docs|refactor|test|perf|style|ci|build|chore)$/)) {
|
||||
cleanSubject = cleanSubject.substring(colonIndex + 1).trim();
|
||||
// Capitalize first letter
|
||||
cleanSubject = cleanSubject.charAt(0).toUpperCase() + cleanSubject.slice(1);
|
||||
}
|
||||
|
||||
releaseNotes.push(`- ${cleanSubject} (${commit.hash.substring(0, 7)})`);
|
||||
});
|
||||
|
||||
releaseNotes.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Add commit statistics
|
||||
const totalCommits = commits.length;
|
||||
const contributors = [...new Set(commits.map(c => c.author))];
|
||||
|
||||
releaseNotes.push('---');
|
||||
releaseNotes.push('');
|
||||
releaseNotes.push(`**Release Statistics:**`);
|
||||
releaseNotes.push(`- ${totalCommits} commit${totalCommits !== 1 ? 's' : ''}`);
|
||||
releaseNotes.push(`- ${contributors.length} contributor${contributors.length !== 1 ? 's' : ''}`);
|
||||
|
||||
if (contributors.length <= 5) {
|
||||
releaseNotes.push(`- Contributors: ${contributors.join(', ')}`);
|
||||
}
|
||||
|
||||
return releaseNotes.join('\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error generating release notes: ${error.message}`);
|
||||
return `Failed to generate release notes: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const previousTag = process.argv[2];
|
||||
const currentTag = process.argv[3];
|
||||
|
||||
if (!previousTag || !currentTag) {
|
||||
console.error('Usage: generate-release-notes.js <previous-tag> <current-tag>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const releaseNotes = generateReleaseNotes(previousTag, currentTag);
|
||||
console.log(releaseNotes);
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/**
|
||||
* Generate a markdown summary of test results for PR comments
|
||||
*/
|
||||
function generateTestSummary() {
|
||||
const results = {
|
||||
tests: null,
|
||||
coverage: null,
|
||||
benchmarks: null,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Read test results
|
||||
const testResultPath = resolve(process.cwd(), 'test-results/results.json');
|
||||
if (existsSync(testResultPath)) {
|
||||
try {
|
||||
const testData = JSON.parse(readFileSync(testResultPath, 'utf-8'));
|
||||
const totalTests = testData.numTotalTests || 0;
|
||||
const passedTests = testData.numPassedTests || 0;
|
||||
const failedTests = testData.numFailedTests || 0;
|
||||
const skippedTests = testData.numSkippedTests || 0;
|
||||
const duration = testData.duration || 0;
|
||||
|
||||
results.tests = {
|
||||
total: totalTests,
|
||||
passed: passedTests,
|
||||
failed: failedTests,
|
||||
skipped: skippedTests,
|
||||
duration: duration,
|
||||
success: failedTests === 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error reading test results:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Read coverage results
|
||||
const coveragePath = resolve(process.cwd(), 'coverage/coverage-summary.json');
|
||||
if (existsSync(coveragePath)) {
|
||||
try {
|
||||
const coverageData = JSON.parse(readFileSync(coveragePath, 'utf-8'));
|
||||
const total = coverageData.total;
|
||||
|
||||
results.coverage = {
|
||||
lines: total.lines.pct,
|
||||
statements: total.statements.pct,
|
||||
functions: total.functions.pct,
|
||||
branches: total.branches.pct
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error reading coverage results:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Read benchmark results
|
||||
const benchmarkPath = resolve(process.cwd(), 'benchmark-results.json');
|
||||
if (existsSync(benchmarkPath)) {
|
||||
try {
|
||||
const benchmarkData = JSON.parse(readFileSync(benchmarkPath, 'utf-8'));
|
||||
const benchmarks = [];
|
||||
|
||||
for (const file of benchmarkData.files || []) {
|
||||
for (const group of file.groups || []) {
|
||||
for (const benchmark of group.benchmarks || []) {
|
||||
benchmarks.push({
|
||||
name: `${group.name} - ${benchmark.name}`,
|
||||
mean: benchmark.result.mean,
|
||||
ops: benchmark.result.hz
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.benchmarks = benchmarks;
|
||||
} catch (error) {
|
||||
console.error('Error reading benchmark results:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate markdown summary
|
||||
let summary = '## Test Results Summary\n\n';
|
||||
|
||||
// Test results
|
||||
if (results.tests) {
|
||||
const { total, passed, failed, skipped, duration, success } = results.tests;
|
||||
const emoji = success ? '✅' : '❌';
|
||||
const status = success ? 'PASSED' : 'FAILED';
|
||||
|
||||
summary += `### ${emoji} Tests ${status}\n\n`;
|
||||
summary += `| Metric | Value |\n`;
|
||||
summary += `|--------|-------|\n`;
|
||||
summary += `| Total Tests | ${total} |\n`;
|
||||
summary += `| Passed | ${passed} |\n`;
|
||||
summary += `| Failed | ${failed} |\n`;
|
||||
summary += `| Skipped | ${skipped} |\n`;
|
||||
summary += `| Duration | ${(duration / 1000).toFixed(2)}s |\n\n`;
|
||||
}
|
||||
|
||||
// Coverage results
|
||||
if (results.coverage) {
|
||||
const { lines, statements, functions, branches } = results.coverage;
|
||||
const avgCoverage = (lines + statements + functions + branches) / 4;
|
||||
const emoji = avgCoverage >= 80 ? '✅' : avgCoverage >= 60 ? '⚠️' : '❌';
|
||||
|
||||
summary += `### ${emoji} Coverage Report\n\n`;
|
||||
summary += `| Type | Coverage |\n`;
|
||||
summary += `|------|----------|\n`;
|
||||
summary += `| Lines | ${lines.toFixed(2)}% |\n`;
|
||||
summary += `| Statements | ${statements.toFixed(2)}% |\n`;
|
||||
summary += `| Functions | ${functions.toFixed(2)}% |\n`;
|
||||
summary += `| Branches | ${branches.toFixed(2)}% |\n`;
|
||||
summary += `| **Average** | **${avgCoverage.toFixed(2)}%** |\n\n`;
|
||||
}
|
||||
|
||||
// Benchmark results
|
||||
if (results.benchmarks && results.benchmarks.length > 0) {
|
||||
summary += `### ⚡ Benchmark Results\n\n`;
|
||||
summary += `| Benchmark | Ops/sec | Mean (ms) |\n`;
|
||||
summary += `|-----------|---------|------------|\n`;
|
||||
|
||||
for (const bench of results.benchmarks.slice(0, 10)) { // Show top 10
|
||||
const opsFormatted = bench.ops.toLocaleString('en-US', { maximumFractionDigits: 0 });
|
||||
const meanFormatted = (bench.mean * 1000).toFixed(3);
|
||||
summary += `| ${bench.name} | ${opsFormatted} | ${meanFormatted} |\n`;
|
||||
}
|
||||
|
||||
if (results.benchmarks.length > 10) {
|
||||
summary += `\n*...and ${results.benchmarks.length - 10} more benchmarks*\n`;
|
||||
}
|
||||
summary += '\n';
|
||||
}
|
||||
|
||||
// Links to artifacts
|
||||
const runId = process.env.GITHUB_RUN_ID;
|
||||
const runNumber = process.env.GITHUB_RUN_NUMBER;
|
||||
const sha = process.env.GITHUB_SHA;
|
||||
|
||||
if (runId) {
|
||||
summary += `### 📊 Artifacts\n\n`;
|
||||
summary += `- 📄 [Test Results](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${runId})\n`;
|
||||
summary += `- 📊 [Coverage Report](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${runId})\n`;
|
||||
summary += `- ⚡ [Benchmark Results](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${runId})\n\n`;
|
||||
}
|
||||
|
||||
// Metadata
|
||||
summary += `---\n`;
|
||||
summary += `*Generated at ${new Date().toUTCString()}*\n`;
|
||||
if (sha) {
|
||||
summary += `*Commit: ${sha.substring(0, 7)}*\n`;
|
||||
}
|
||||
if (runNumber) {
|
||||
summary += `*Run: #${runNumber}*\n`;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Generate and output summary
|
||||
const summary = generateTestSummary();
|
||||
console.log(summary);
|
||||
|
||||
// Also write to file for artifact
|
||||
import { writeFileSync } from 'fs';
|
||||
writeFileSync('test-summary.md', summary);
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* HTTP-to-stdio bridge for n8n-MCP
|
||||
* Connects to n8n-MCP HTTP server and bridges stdio communication
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const readline = require('readline');
|
||||
|
||||
// Use MCP_URL from environment or construct from HOST/PORT if available
|
||||
const defaultHost = process.env.HOST || 'localhost';
|
||||
const defaultPort = process.env.PORT || '3000';
|
||||
const MCP_URL = process.env.MCP_URL || `http://${defaultHost}:${defaultPort}/mcp`;
|
||||
const AUTH_TOKEN = process.env.AUTH_TOKEN || process.argv[2];
|
||||
|
||||
if (!AUTH_TOKEN) {
|
||||
console.error('Error: AUTH_TOKEN environment variable or first argument required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
const url = new URL(MCP_URL);
|
||||
|
||||
// Create readline interface for stdio
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
// Buffer for incomplete JSON messages
|
||||
let buffer = '';
|
||||
|
||||
// Handle incoming stdio messages
|
||||
rl.on('line', async (line) => {
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
|
||||
// Forward to HTTP server
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 80,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
'Accept': 'application/json, text/event-stream'
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let responseData = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
responseData += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
// Try to parse as JSON
|
||||
const response = JSON.parse(responseData);
|
||||
console.log(JSON.stringify(response));
|
||||
} catch (e) {
|
||||
// Handle SSE format
|
||||
const lines = responseData.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.substring(6));
|
||||
console.log(JSON.stringify(data));
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.error(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: `HTTP request failed: ${error.message}`
|
||||
},
|
||||
id: message.id || null
|
||||
}));
|
||||
});
|
||||
|
||||
req.write(JSON.stringify(message));
|
||||
req.end();
|
||||
} catch (error) {
|
||||
// Not valid JSON, ignore
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process termination
|
||||
process.on('SIGTERM', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Minimal MCP HTTP Client for Node.js v16 compatibility
|
||||
* This bypasses mcp-remote and its TransformStream dependency
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const readline = require('readline');
|
||||
|
||||
// Get configuration from command line arguments
|
||||
const url = process.argv[2];
|
||||
const authToken = process.env.MCP_AUTH_TOKEN;
|
||||
|
||||
if (!url) {
|
||||
console.error('Usage: node mcp-http-client.js <server-url>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!authToken) {
|
||||
console.error('Error: MCP_AUTH_TOKEN environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
const parsedUrl = new URL(url);
|
||||
const isHttps = parsedUrl.protocol === 'https:';
|
||||
const httpModule = isHttps ? https : http;
|
||||
|
||||
// Create readline interface for stdio
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
// Buffer for incomplete JSON messages
|
||||
let buffer = '';
|
||||
|
||||
// Function to send JSON-RPC request
|
||||
function sendRequest(request) {
|
||||
const requestBody = JSON.stringify(request);
|
||||
|
||||
const options = {
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port || (isHttps ? 443 : 80),
|
||||
path: parsedUrl.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(requestBody),
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = httpModule.request(options, (res) => {
|
||||
let responseData = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
responseData += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(responseData);
|
||||
// Ensure the response has the correct structure
|
||||
if (response.jsonrpc && (response.result !== undefined || response.error !== undefined)) {
|
||||
console.log(JSON.stringify(response));
|
||||
} else {
|
||||
// Wrap non-JSON-RPC responses
|
||||
console.log(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: request.id || null,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal error',
|
||||
data: response
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: request.id || null,
|
||||
error: {
|
||||
code: -32700,
|
||||
message: 'Parse error',
|
||||
data: err.message
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.log(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: request.id || null,
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Transport error',
|
||||
data: err.message
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
req.write(requestBody);
|
||||
req.end();
|
||||
}
|
||||
|
||||
// Process incoming JSON-RPC messages from stdin
|
||||
rl.on('line', (line) => {
|
||||
// Try to parse each line as a complete JSON-RPC message
|
||||
try {
|
||||
const request = JSON.parse(line);
|
||||
|
||||
// Forward the request to the HTTP server
|
||||
sendRequest(request);
|
||||
} catch (err) {
|
||||
// Log parse errors to stdout in JSON-RPC format
|
||||
console.log(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: null,
|
||||
error: {
|
||||
code: -32700,
|
||||
message: 'Parse error',
|
||||
data: err.message
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process termination
|
||||
process.on('SIGINT', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import * as path from 'path';
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { logger } from '../src/utils/logger';
|
||||
|
||||
/**
|
||||
* Migrate existing database to add FTS5 support for nodes
|
||||
*/
|
||||
async function migrateNodesFTS() {
|
||||
logger.info('Starting nodes FTS5 migration...');
|
||||
|
||||
const dbPath = path.join(process.cwd(), 'data', 'nodes.db');
|
||||
const db = await createDatabaseAdapter(dbPath);
|
||||
|
||||
try {
|
||||
// Check if nodes_fts already exists
|
||||
const tableExists = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='nodes_fts'
|
||||
`).get();
|
||||
|
||||
if (tableExists) {
|
||||
logger.info('nodes_fts table already exists, skipping migration');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Creating nodes_fts virtual table...');
|
||||
|
||||
// Create the FTS5 virtual table
|
||||
db.prepare(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
|
||||
node_type,
|
||||
display_name,
|
||||
description,
|
||||
documentation,
|
||||
operations,
|
||||
content=nodes,
|
||||
content_rowid=rowid,
|
||||
tokenize='porter'
|
||||
)
|
||||
`).run();
|
||||
|
||||
// Populate the FTS table with existing data
|
||||
logger.info('Populating nodes_fts with existing data...');
|
||||
|
||||
const nodes = db.prepare('SELECT rowid, * FROM nodes').all() as any[];
|
||||
logger.info(`Migrating ${nodes.length} nodes to FTS index...`);
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO nodes_fts(rowid, node_type, display_name, description, documentation, operations)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const node of nodes) {
|
||||
insertStmt.run(
|
||||
node.rowid,
|
||||
node.node_type,
|
||||
node.display_name,
|
||||
node.description || '',
|
||||
node.documentation || '',
|
||||
node.operations || ''
|
||||
);
|
||||
}
|
||||
|
||||
// Create triggers to keep FTS in sync
|
||||
logger.info('Creating synchronization triggers...');
|
||||
|
||||
db.prepare(`
|
||||
CREATE TRIGGER IF NOT EXISTS nodes_fts_insert AFTER INSERT ON nodes
|
||||
BEGIN
|
||||
INSERT INTO nodes_fts(rowid, node_type, display_name, description, documentation, operations)
|
||||
VALUES (new.rowid, new.node_type, new.display_name, new.description, new.documentation, new.operations);
|
||||
END
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TRIGGER IF NOT EXISTS nodes_fts_update AFTER UPDATE ON nodes
|
||||
BEGIN
|
||||
UPDATE nodes_fts
|
||||
SET node_type = new.node_type,
|
||||
display_name = new.display_name,
|
||||
description = new.description,
|
||||
documentation = new.documentation,
|
||||
operations = new.operations
|
||||
WHERE rowid = new.rowid;
|
||||
END
|
||||
`).run();
|
||||
|
||||
db.prepare(`
|
||||
CREATE TRIGGER IF NOT EXISTS nodes_fts_delete AFTER DELETE ON nodes
|
||||
BEGIN
|
||||
DELETE FROM nodes_fts WHERE rowid = old.rowid;
|
||||
END
|
||||
`).run();
|
||||
|
||||
// Test the FTS search
|
||||
logger.info('Testing FTS search...');
|
||||
|
||||
const testResults = db.prepare(`
|
||||
SELECT n.* FROM nodes n
|
||||
JOIN nodes_fts ON n.rowid = nodes_fts.rowid
|
||||
WHERE nodes_fts MATCH 'webhook'
|
||||
ORDER BY rank
|
||||
LIMIT 5
|
||||
`).all();
|
||||
|
||||
logger.info(`FTS test search found ${testResults.length} results for 'webhook'`);
|
||||
|
||||
// Persist if using sql.js
|
||||
if ('persist' in db) {
|
||||
logger.info('Persisting database changes...');
|
||||
(db as any).persist();
|
||||
}
|
||||
|
||||
logger.info('✅ FTS5 migration completed successfully!');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Migration failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration
|
||||
migrateNodesFTS().catch(error => {
|
||||
logger.error('Migration error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env tsx
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// This is a helper script to migrate tool documentation to the new structure
|
||||
// It creates a template file for each tool that needs to be migrated
|
||||
|
||||
const toolsByCategory = {
|
||||
discovery: [
|
||||
'search_nodes',
|
||||
'list_nodes',
|
||||
'list_ai_tools',
|
||||
'get_database_statistics'
|
||||
],
|
||||
configuration: [
|
||||
'get_node_info',
|
||||
'get_node_essentials',
|
||||
'get_node_documentation',
|
||||
'search_node_properties',
|
||||
'get_node_as_tool_info',
|
||||
'get_property_dependencies'
|
||||
],
|
||||
validation: [
|
||||
'validate_node_minimal',
|
||||
'validate_node_operation',
|
||||
'validate_workflow',
|
||||
'validate_workflow_connections',
|
||||
'validate_workflow_expressions'
|
||||
],
|
||||
templates: [
|
||||
'get_node_for_task',
|
||||
'list_tasks',
|
||||
'list_node_templates',
|
||||
'get_template',
|
||||
'search_templates',
|
||||
'get_templates_for_task'
|
||||
],
|
||||
workflow_management: [
|
||||
'n8n_create_workflow',
|
||||
'n8n_get_workflow',
|
||||
'n8n_get_workflow_details',
|
||||
'n8n_get_workflow_structure',
|
||||
'n8n_get_workflow_minimal',
|
||||
'n8n_update_full_workflow',
|
||||
'n8n_update_partial_workflow',
|
||||
'n8n_delete_workflow',
|
||||
'n8n_list_workflows',
|
||||
'n8n_validate_workflow',
|
||||
'n8n_trigger_webhook_workflow',
|
||||
'n8n_get_execution',
|
||||
'n8n_list_executions',
|
||||
'n8n_delete_execution'
|
||||
],
|
||||
system: [
|
||||
'tools_documentation',
|
||||
'n8n_diagnostic',
|
||||
'n8n_health_check',
|
||||
'n8n_list_available_tools'
|
||||
],
|
||||
special: [
|
||||
'code_node_guide'
|
||||
]
|
||||
};
|
||||
|
||||
const template = (toolName: string, category: string) => `import { ToolDocumentation } from '../types';
|
||||
|
||||
export const ${toCamelCase(toolName)}Doc: ToolDocumentation = {
|
||||
name: '${toolName}',
|
||||
category: '${category}',
|
||||
essentials: {
|
||||
description: 'TODO: Add description from old file',
|
||||
keyParameters: ['TODO'],
|
||||
example: '${toolName}({TODO})',
|
||||
performance: 'TODO',
|
||||
tips: [
|
||||
'TODO: Add tips'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: 'TODO: Add full description',
|
||||
parameters: {
|
||||
// TODO: Add parameters
|
||||
},
|
||||
returns: 'TODO: Add return description',
|
||||
examples: [
|
||||
'${toolName}({TODO}) - TODO'
|
||||
],
|
||||
useCases: [
|
||||
'TODO: Add use cases'
|
||||
],
|
||||
performance: 'TODO: Add performance description',
|
||||
bestPractices: [
|
||||
'TODO: Add best practices'
|
||||
],
|
||||
pitfalls: [
|
||||
'TODO: Add pitfalls'
|
||||
],
|
||||
relatedTools: ['TODO']
|
||||
}
|
||||
};`;
|
||||
|
||||
function toCamelCase(str: string): string {
|
||||
return str.split('_').map((part, index) =>
|
||||
index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)
|
||||
).join('');
|
||||
}
|
||||
|
||||
function toKebabCase(str: string): string {
|
||||
return str.replace(/_/g, '-');
|
||||
}
|
||||
|
||||
// Create template files for tools that don't exist yet
|
||||
Object.entries(toolsByCategory).forEach(([category, tools]) => {
|
||||
tools.forEach(toolName => {
|
||||
const fileName = toKebabCase(toolName) + '.ts';
|
||||
const filePath = path.join('src/mcp/tool-docs', category, fileName);
|
||||
|
||||
// Skip if file already exists
|
||||
if (fs.existsSync(filePath)) {
|
||||
console.log(`✓ ${filePath} already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the file with template
|
||||
fs.writeFileSync(filePath, template(toolName, category));
|
||||
console.log(`✨ Created ${filePath}`);
|
||||
});
|
||||
|
||||
// Create index file for the category
|
||||
const indexPath = path.join('src/mcp/tool-docs', category, 'index.ts');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
const indexContent = tools.map(toolName =>
|
||||
`export { ${toCamelCase(toolName)}Doc } from './${toKebabCase(toolName)}';`
|
||||
).join('\n');
|
||||
|
||||
fs.writeFileSync(indexPath, indexContent);
|
||||
console.log(`✨ Created ${indexPath}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n📝 Migration templates created!');
|
||||
console.log('Next steps:');
|
||||
console.log('1. Copy documentation from the old tools-documentation.ts file');
|
||||
console.log('2. Update each template file with the actual documentation');
|
||||
console.log('3. Update src/mcp/tool-docs/index.ts to import all tools');
|
||||
console.log('4. Replace the old tools-documentation.ts with the new one');
|
||||
@@ -0,0 +1,29 @@
|
||||
[Unit]
|
||||
Description=n8n Documentation MCP Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nodejs
|
||||
WorkingDirectory=/opt/n8n-mcp
|
||||
ExecStart=/usr/bin/node /opt/n8n-mcp/dist/index-http.js
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Environment
|
||||
Environment="NODE_ENV=production"
|
||||
EnvironmentFile=/opt/n8n-mcp/.env
|
||||
|
||||
# Security
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/n8n-mcp/data /opt/n8n-mcp/logs /opt/n8n-mcp/temp
|
||||
|
||||
# Logging
|
||||
StandardOutput=append:/opt/n8n-mcp/logs/service.log
|
||||
StandardError=append:/opt/n8n-mcp/logs/service-error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,75 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name n8ndocumentation.aiservices.pl;
|
||||
|
||||
# Redirect HTTP to HTTPS
|
||||
location / {
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name n8ndocumentation.aiservices.pl;
|
||||
|
||||
# SSL configuration (managed by Certbot)
|
||||
ssl_certificate /etc/letsencrypt/live/n8ndocumentation.aiservices.pl/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/n8ndocumentation.aiservices.pl/privkey.pem;
|
||||
|
||||
# SSL security settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/n8n-mcp-access.log;
|
||||
error_log /var/log/nginx/n8n-mcp-error.log;
|
||||
|
||||
# Proxy settings
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts for MCP operations
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# Increase buffer sizes for large responses
|
||||
proxy_buffer_size 16k;
|
||||
proxy_buffers 8 16k;
|
||||
proxy_busy_buffers_size 32k;
|
||||
}
|
||||
|
||||
# Rate limiting for API endpoints
|
||||
location /mcp {
|
||||
limit_req zone=mcp_limit burst=10 nodelay;
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Larger timeouts for MCP
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
|
||||
# Rate limiting zone
|
||||
limit_req_zone $binary_remote_addr zone=mcp_limit:10m rate=10r/s;
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Pre-build FTS5 indexes for the database
|
||||
* This ensures FTS5 tables are created before the database is deployed to Docker
|
||||
*/
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { logger } from '../src/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
|
||||
async function prebuildFTS5() {
|
||||
console.log('🔍 Pre-building FTS5 indexes...\n');
|
||||
|
||||
const dbPath = './data/nodes.db';
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error('❌ Database not found at', dbPath);
|
||||
console.error(' Please run npm run rebuild first');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = await createDatabaseAdapter(dbPath);
|
||||
|
||||
// Check FTS5 support
|
||||
const hasFTS5 = db.checkFTS5Support();
|
||||
|
||||
if (!hasFTS5) {
|
||||
console.log('ℹ️ FTS5 not supported in this SQLite build');
|
||||
console.log(' Skipping FTS5 pre-build');
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ FTS5 is supported');
|
||||
|
||||
try {
|
||||
// Create FTS5 virtual table for templates
|
||||
console.log('\n📋 Creating FTS5 table for templates...');
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS templates_fts USING fts5(
|
||||
name, description, content=templates
|
||||
);
|
||||
`);
|
||||
|
||||
// Create triggers to keep FTS5 in sync
|
||||
console.log('🔗 Creating synchronization triggers...');
|
||||
|
||||
db.exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS templates_ai AFTER INSERT ON templates BEGIN
|
||||
INSERT INTO templates_fts(rowid, name, description)
|
||||
VALUES (new.id, new.name, new.description);
|
||||
END;
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS templates_au AFTER UPDATE ON templates BEGIN
|
||||
UPDATE templates_fts SET name = new.name, description = new.description
|
||||
WHERE rowid = new.id;
|
||||
END;
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS templates_ad AFTER DELETE ON templates BEGIN
|
||||
DELETE FROM templates_fts WHERE rowid = old.id;
|
||||
END;
|
||||
`);
|
||||
|
||||
// Rebuild FTS5 index from existing data
|
||||
console.log('🔄 Rebuilding FTS5 index from existing templates...');
|
||||
|
||||
// Clear existing FTS data
|
||||
db.exec('DELETE FROM templates_fts');
|
||||
|
||||
// Repopulate from templates table
|
||||
db.exec(`
|
||||
INSERT INTO templates_fts(rowid, name, description)
|
||||
SELECT id, name, description FROM templates
|
||||
`);
|
||||
|
||||
// Get counts
|
||||
const templateCount = db.prepare('SELECT COUNT(*) as count FROM templates').get() as { count: number };
|
||||
const ftsCount = db.prepare('SELECT COUNT(*) as count FROM templates_fts').get() as { count: number };
|
||||
|
||||
console.log(`\n✅ FTS5 pre-build complete!`);
|
||||
console.log(` Templates: ${templateCount.count}`);
|
||||
console.log(` FTS5 entries: ${ftsCount.count}`);
|
||||
|
||||
// Test FTS5 search
|
||||
console.log('\n🧪 Testing FTS5 search...');
|
||||
const testResults = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM templates t
|
||||
JOIN templates_fts ON t.id = templates_fts.rowid
|
||||
WHERE templates_fts MATCH 'webhook'
|
||||
`).get() as { count: number };
|
||||
|
||||
console.log(` Found ${testResults.count} templates matching "webhook"`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error pre-building FTS5:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
db.close();
|
||||
console.log('\n✅ Database is ready for Docker deployment!');
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
prebuildFTS5().catch(console.error);
|
||||
}
|
||||
|
||||
export { prebuildFTS5 };
|
||||
Executable
+400
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Pre-release preparation script
|
||||
* Validates and prepares everything needed for a successful release
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync, spawnSync } = require('child_process');
|
||||
const readline = require('readline');
|
||||
|
||||
// Color codes
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m'
|
||||
};
|
||||
|
||||
function log(message, color = 'reset') {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function success(message) {
|
||||
log(`✅ ${message}`, 'green');
|
||||
}
|
||||
|
||||
function warning(message) {
|
||||
log(`⚠️ ${message}`, 'yellow');
|
||||
}
|
||||
|
||||
function error(message) {
|
||||
log(`❌ ${message}`, 'red');
|
||||
}
|
||||
|
||||
function info(message) {
|
||||
log(`ℹ️ ${message}`, 'blue');
|
||||
}
|
||||
|
||||
function header(title) {
|
||||
log(`\n${'='.repeat(60)}`, 'cyan');
|
||||
log(`🚀 ${title}`, 'cyan');
|
||||
log(`${'='.repeat(60)}`, 'cyan');
|
||||
}
|
||||
|
||||
class ReleasePreparation {
|
||||
constructor() {
|
||||
this.rootDir = path.resolve(__dirname, '..');
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
}
|
||||
|
||||
async askQuestion(question) {
|
||||
return new Promise((resolve) => {
|
||||
this.rl.question(question, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version and ask for new version
|
||||
*/
|
||||
async getVersionInfo() {
|
||||
const packageJson = require(path.join(this.rootDir, 'package.json'));
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
log(`\nCurrent version: ${currentVersion}`, 'blue');
|
||||
|
||||
const newVersion = await this.askQuestion('\nEnter new version (e.g., 2.10.0): ');
|
||||
|
||||
if (!newVersion || !this.isValidSemver(newVersion)) {
|
||||
error('Invalid semantic version format');
|
||||
throw new Error('Invalid version');
|
||||
}
|
||||
|
||||
if (this.compareVersions(newVersion, currentVersion) <= 0) {
|
||||
error('New version must be greater than current version');
|
||||
throw new Error('Version not incremented');
|
||||
}
|
||||
|
||||
return { currentVersion, newVersion };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate semantic version format (strict semver compliance)
|
||||
*/
|
||||
isValidSemver(version) {
|
||||
// Strict semantic versioning regex
|
||||
const semverRegex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
||||
return semverRegex.test(version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semantic versions
|
||||
*/
|
||||
compareVersions(v1, v2) {
|
||||
const parseVersion = (v) => v.split('-')[0].split('.').map(Number);
|
||||
const [v1Parts, v2Parts] = [parseVersion(v1), parseVersion(v2)];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (v1Parts[i] > v2Parts[i]) return 1;
|
||||
if (v1Parts[i] < v2Parts[i]) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update version in package files
|
||||
*/
|
||||
updateVersions(newVersion) {
|
||||
log('\n📝 Updating version in package files...', 'blue');
|
||||
|
||||
// Update package.json
|
||||
const packageJsonPath = path.join(this.rootDir, 'package.json');
|
||||
const packageJson = require(packageJsonPath);
|
||||
packageJson.version = newVersion;
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
|
||||
success('Updated package.json');
|
||||
|
||||
// Sync to runtime package
|
||||
try {
|
||||
execSync('npm run sync:runtime-version', { cwd: this.rootDir, stdio: 'pipe' });
|
||||
success('Synced package.runtime.json');
|
||||
} catch (err) {
|
||||
warning('Could not sync runtime version automatically');
|
||||
|
||||
// Manual sync
|
||||
const runtimeJsonPath = path.join(this.rootDir, 'package.runtime.json');
|
||||
if (fs.existsSync(runtimeJsonPath)) {
|
||||
const runtimeJson = require(runtimeJsonPath);
|
||||
runtimeJson.version = newVersion;
|
||||
fs.writeFileSync(runtimeJsonPath, JSON.stringify(runtimeJson, null, 2) + '\n');
|
||||
success('Manually synced package.runtime.json');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update changelog
|
||||
*/
|
||||
async updateChangelog(newVersion) {
|
||||
const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md');
|
||||
|
||||
if (!fs.existsSync(changelogPath)) {
|
||||
warning('Changelog file not found, skipping update');
|
||||
return;
|
||||
}
|
||||
|
||||
log('\n📋 Updating changelog...', 'blue');
|
||||
|
||||
const content = fs.readFileSync(changelogPath, 'utf8');
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Check if version already exists in changelog
|
||||
const versionRegex = new RegExp(`^## \\[${newVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm');
|
||||
if (versionRegex.test(content)) {
|
||||
info(`Version ${newVersion} already exists in changelog`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the Unreleased section
|
||||
const unreleasedMatch = content.match(/^## \[Unreleased\]\s*\n([\s\S]*?)(?=\n## \[|$)/m);
|
||||
|
||||
if (unreleasedMatch) {
|
||||
const unreleasedContent = unreleasedMatch[1].trim();
|
||||
|
||||
if (unreleasedContent) {
|
||||
log('\nFound content in Unreleased section:', 'blue');
|
||||
log(unreleasedContent.substring(0, 200) + '...', 'yellow');
|
||||
|
||||
const moveContent = await this.askQuestion('\nMove this content to the new version? (y/n): ');
|
||||
|
||||
if (moveContent.toLowerCase() === 'y') {
|
||||
// Move unreleased content to new version
|
||||
const newVersionSection = `## [${newVersion}] - ${today}\n\n${unreleasedContent}\n\n`;
|
||||
const updatedContent = content.replace(
|
||||
/^## \[Unreleased\]\s*\n[\s\S]*?(?=\n## \[)/m,
|
||||
`## [Unreleased]\n\n${newVersionSection}## [`
|
||||
);
|
||||
|
||||
fs.writeFileSync(changelogPath, updatedContent);
|
||||
success(`Moved unreleased content to version ${newVersion}`);
|
||||
} else {
|
||||
// Just add empty version section
|
||||
const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`;
|
||||
const updatedContent = content.replace(
|
||||
/^## \[Unreleased\]\s*\n/m,
|
||||
`## [Unreleased]\n\n${newVersionSection}`
|
||||
);
|
||||
|
||||
fs.writeFileSync(changelogPath, updatedContent);
|
||||
warning(`Added empty version section for ${newVersion} - please fill in the changes`);
|
||||
}
|
||||
} else {
|
||||
// Add empty version section
|
||||
const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`;
|
||||
const updatedContent = content.replace(
|
||||
/^## \[Unreleased\]\s*\n/m,
|
||||
`## [Unreleased]\n\n${newVersionSection}`
|
||||
);
|
||||
|
||||
fs.writeFileSync(changelogPath, updatedContent);
|
||||
warning(`Added empty version section for ${newVersion} - please fill in the changes`);
|
||||
}
|
||||
} else {
|
||||
warning('Could not find Unreleased section in changelog');
|
||||
}
|
||||
|
||||
info('Please review and edit the changelog before committing');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run tests and build
|
||||
*/
|
||||
async runChecks() {
|
||||
log('\n🧪 Running pre-release checks...', 'blue');
|
||||
|
||||
try {
|
||||
// Run tests
|
||||
log('Running tests...', 'blue');
|
||||
execSync('npm test', { cwd: this.rootDir, stdio: 'inherit' });
|
||||
success('All tests passed');
|
||||
|
||||
// Run build
|
||||
log('Building project...', 'blue');
|
||||
execSync('npm run build', { cwd: this.rootDir, stdio: 'inherit' });
|
||||
success('Build completed');
|
||||
|
||||
// Rebuild database
|
||||
log('Rebuilding database...', 'blue');
|
||||
execSync('npm run rebuild', { cwd: this.rootDir, stdio: 'inherit' });
|
||||
success('Database rebuilt');
|
||||
|
||||
// Run type checking
|
||||
log('Type checking...', 'blue');
|
||||
execSync('npm run typecheck', { cwd: this.rootDir, stdio: 'inherit' });
|
||||
success('Type checking passed');
|
||||
|
||||
} catch (err) {
|
||||
error('Pre-release checks failed');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create git commit
|
||||
*/
|
||||
async createCommit(newVersion) {
|
||||
log('\n📝 Creating git commit...', 'blue');
|
||||
|
||||
try {
|
||||
// Check git status
|
||||
const status = execSync('git status --porcelain', {
|
||||
cwd: this.rootDir,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
if (!status.trim()) {
|
||||
info('No changes to commit');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show what will be committed
|
||||
log('\nFiles to be committed:', 'blue');
|
||||
execSync('git diff --name-only', { cwd: this.rootDir, stdio: 'inherit' });
|
||||
|
||||
const commit = await this.askQuestion('\nCreate commit for release? (y/n): ');
|
||||
|
||||
if (commit.toLowerCase() === 'y') {
|
||||
// Add files
|
||||
execSync('git add package.json package.runtime.json docs/CHANGELOG.md', {
|
||||
cwd: this.rootDir,
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
// Create commit
|
||||
const commitMessage = `chore: release v${newVersion}
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>`;
|
||||
|
||||
const result = spawnSync('git', ['commit', '-m', commitMessage], {
|
||||
cwd: this.rootDir,
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(`Git commit failed: ${result.stderr || result.error?.message}`);
|
||||
}
|
||||
|
||||
success(`Created commit for v${newVersion}`);
|
||||
|
||||
const push = await this.askQuestion('\nPush to trigger release workflow? (y/n): ');
|
||||
|
||||
if (push.toLowerCase() === 'y') {
|
||||
// Add confirmation for destructive operation
|
||||
warning('\n⚠️ DESTRUCTIVE OPERATION WARNING ⚠️');
|
||||
warning('This will trigger a PUBLIC RELEASE that cannot be undone!');
|
||||
warning('The following will happen automatically:');
|
||||
warning('• Create GitHub release with tag');
|
||||
warning('• Publish package to NPM registry');
|
||||
warning('• Build and push Docker images');
|
||||
warning('• Update documentation');
|
||||
|
||||
const confirmation = await this.askQuestion('\nType "RELEASE" (all caps) to confirm: ');
|
||||
|
||||
if (confirmation === 'RELEASE') {
|
||||
execSync('git push', { cwd: this.rootDir, stdio: 'inherit' });
|
||||
success('Pushed to remote repository');
|
||||
log('\n🎉 Release workflow will be triggered automatically!', 'green');
|
||||
log('Monitor progress at: https://github.com/czlonkowski/n8n-mcp/actions', 'blue');
|
||||
} else {
|
||||
warning('Release cancelled. Commit created but not pushed.');
|
||||
info('You can push manually later to trigger the release.');
|
||||
}
|
||||
} else {
|
||||
info('Commit created but not pushed. Push manually to trigger release.');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error(`Git operations failed: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display final instructions
|
||||
*/
|
||||
displayInstructions(newVersion) {
|
||||
header('Release Preparation Complete');
|
||||
|
||||
log('📋 What happens next:', 'blue');
|
||||
log(`1. The GitHub Actions workflow will detect the version change to v${newVersion}`, 'green');
|
||||
log('2. It will automatically:', 'green');
|
||||
log(' • Create a GitHub release with changelog content', 'green');
|
||||
log(' • Publish the npm package', 'green');
|
||||
log(' • Build and push Docker images', 'green');
|
||||
log(' • Update documentation badges', 'green');
|
||||
log('\n🔍 Monitor the release at:', 'blue');
|
||||
log(' • GitHub Actions: https://github.com/czlonkowski/n8n-mcp/actions', 'blue');
|
||||
log(' • NPM Package: https://www.npmjs.com/package/n8n-mcp', 'blue');
|
||||
log(' • Docker Images: https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp', 'blue');
|
||||
|
||||
log('\n✅ Release preparation completed successfully!', 'green');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main execution flow
|
||||
*/
|
||||
async run() {
|
||||
try {
|
||||
header('n8n-MCP Release Preparation');
|
||||
|
||||
// Get version information
|
||||
const { currentVersion, newVersion } = await this.getVersionInfo();
|
||||
|
||||
log(`\n🔄 Preparing release: ${currentVersion} → ${newVersion}`, 'magenta');
|
||||
|
||||
// Update versions
|
||||
this.updateVersions(newVersion);
|
||||
|
||||
// Update changelog
|
||||
await this.updateChangelog(newVersion);
|
||||
|
||||
// Run pre-release checks
|
||||
await this.runChecks();
|
||||
|
||||
// Create git commit
|
||||
await this.createCommit(newVersion);
|
||||
|
||||
// Display final instructions
|
||||
this.displayInstructions(newVersion);
|
||||
|
||||
} catch (err) {
|
||||
error(`Release preparation failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
this.rl.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
if (require.main === module) {
|
||||
const preparation = new ReleasePreparation();
|
||||
preparation.run().catch(err => {
|
||||
console.error('Release preparation failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = ReleasePreparation;
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env ts-node
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
|
||||
interface BatchResponse {
|
||||
id: string;
|
||||
custom_id: string;
|
||||
response: {
|
||||
status_code: number;
|
||||
body: {
|
||||
choices: Array<{
|
||||
message: {
|
||||
content: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
error: any;
|
||||
}
|
||||
|
||||
async function processBatchMetadata(batchFile: string) {
|
||||
console.log(`📥 Processing batch file: ${batchFile}`);
|
||||
|
||||
// Read the JSONL file
|
||||
const content = fs.readFileSync(batchFile, 'utf-8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
console.log(`📊 Found ${lines.length} batch responses`);
|
||||
|
||||
// Initialize database
|
||||
const db = await createDatabaseAdapter('./data/nodes.db');
|
||||
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const response: BatchResponse = JSON.parse(line);
|
||||
|
||||
// Extract template ID from custom_id (format: "template-9100")
|
||||
const templateId = parseInt(response.custom_id.replace('template-', ''));
|
||||
|
||||
// Check for errors
|
||||
if (response.error || response.response.status_code !== 200) {
|
||||
console.warn(`⚠️ Template ${templateId}: API error`, response.error);
|
||||
errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract metadata from response
|
||||
const metadataJson = response.response.body.choices[0].message.content;
|
||||
|
||||
// Validate it's valid JSON
|
||||
JSON.parse(metadataJson); // Will throw if invalid
|
||||
|
||||
// Update database
|
||||
const stmt = db.prepare(`
|
||||
UPDATE templates
|
||||
SET metadata_json = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(metadataJson, templateId);
|
||||
updated++;
|
||||
|
||||
console.log(`✅ Template ${templateId}: Updated metadata`);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Error processing line:`, error.message);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// Close database
|
||||
if ('close' in db && typeof db.close === 'function') {
|
||||
db.close();
|
||||
}
|
||||
|
||||
console.log(`\n📈 Summary:`);
|
||||
console.log(` - Updated: ${updated}`);
|
||||
console.log(` - Skipped: ${skipped}`);
|
||||
console.log(` - Errors: ${errors}`);
|
||||
console.log(` - Total: ${lines.length}`);
|
||||
}
|
||||
|
||||
// Main
|
||||
const batchFile = process.argv[2] || '/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/docs/batch_68fff7242850819091cfed64f10fb6b4_output.jsonl';
|
||||
|
||||
processBatchMetadata(batchFile)
|
||||
.then(() => {
|
||||
console.log('\n✅ Batch processing complete!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n❌ Batch processing failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
# Quick publish script that skips tests
|
||||
set -e
|
||||
|
||||
# Color codes
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "🚀 Preparing n8n-mcp for npm publish (quick mode)..."
|
||||
|
||||
# Sync version
|
||||
echo "🔄 Syncing version to package.runtime.json..."
|
||||
npm run sync:runtime-version
|
||||
|
||||
VERSION=$(node -e "console.log(require('./package.json').version)")
|
||||
echo -e "${GREEN}📌 Version: $VERSION${NC}"
|
||||
|
||||
# Prepare publish directory
|
||||
PUBLISH_DIR="npm-publish-temp"
|
||||
rm -rf $PUBLISH_DIR
|
||||
mkdir -p $PUBLISH_DIR
|
||||
|
||||
echo "📦 Copying files..."
|
||||
cp -r dist $PUBLISH_DIR/
|
||||
cp -r data $PUBLISH_DIR/
|
||||
cp README.md LICENSE .env.example $PUBLISH_DIR/
|
||||
cp .npmignore $PUBLISH_DIR/ 2>/dev/null || true
|
||||
cp package.runtime.json $PUBLISH_DIR/package.json
|
||||
|
||||
cd $PUBLISH_DIR
|
||||
|
||||
# Configure package.json
|
||||
node -e "
|
||||
const pkg = require('./package.json');
|
||||
pkg.name = 'n8n-mcp';
|
||||
pkg.description = 'Integration between n8n workflow automation and Model Context Protocol (MCP)';
|
||||
pkg.bin = { 'n8n-mcp': './dist/mcp/stdio-wrapper.js' };
|
||||
pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' };
|
||||
pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation'];
|
||||
pkg.author = 'Romuald Czlonkowski @ www.aiadvisors.pl/en';
|
||||
pkg.license = 'MIT';
|
||||
pkg.bugs = { url: 'https://github.com/czlonkowski/n8n-mcp/issues' };
|
||||
pkg.homepage = 'https://github.com/czlonkowski/n8n-mcp#readme';
|
||||
pkg.files = ['dist/**/*', 'data/nodes.db', '.env.example', 'README.md', 'LICENSE'];
|
||||
delete pkg.private;
|
||||
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "📋 Package details:"
|
||||
echo -e "${GREEN}Name:${NC} $(node -e "console.log(require('./package.json').name)")"
|
||||
echo -e "${GREEN}Version:${NC} $(node -e "console.log(require('./package.json').version)")"
|
||||
echo -e "${GREEN}Size:${NC} ~50MB"
|
||||
echo ""
|
||||
echo "✅ Ready to publish!"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚠️ Note: Tests were skipped in quick mode${NC}"
|
||||
echo ""
|
||||
echo "To publish, run:"
|
||||
echo -e " ${GREEN}cd $PUBLISH_DIR${NC}"
|
||||
echo -e " ${GREEN}npm publish --otp=YOUR_OTP_CODE${NC}"
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/bin/bash
|
||||
# Script to publish n8n-mcp with runtime-only dependencies
|
||||
|
||||
set -e
|
||||
|
||||
# Color codes for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo "🚀 Preparing n8n-mcp for npm publish..."
|
||||
|
||||
# Skip tests - they already run in CI before merge/publish
|
||||
echo "⏭️ Skipping tests (already verified in CI)"
|
||||
|
||||
# Sync version to runtime package first
|
||||
echo "🔄 Syncing version to package.runtime.json..."
|
||||
npm run sync:runtime-version
|
||||
|
||||
# Get version from main package.json
|
||||
VERSION=$(node -e "console.log(require('./package.json').version)")
|
||||
echo -e "${GREEN}📌 Version: $VERSION${NC}"
|
||||
|
||||
# Check if dist directory exists
|
||||
if [ ! -d "dist" ]; then
|
||||
echo -e "${RED}❌ Error: dist directory not found. Run 'npm run build' first.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if database exists
|
||||
if [ ! -f "data/nodes.db" ]; then
|
||||
echo -e "${RED}❌ Error: data/nodes.db not found. Run 'npm run rebuild' first.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create a temporary publish directory
|
||||
PUBLISH_DIR="npm-publish-temp"
|
||||
rm -rf $PUBLISH_DIR
|
||||
mkdir -p $PUBLISH_DIR
|
||||
|
||||
# Copy necessary files
|
||||
echo "📦 Copying files..."
|
||||
cp -r dist $PUBLISH_DIR/
|
||||
cp -r data $PUBLISH_DIR/
|
||||
cp README.md $PUBLISH_DIR/
|
||||
cp LICENSE $PUBLISH_DIR/
|
||||
cp .env.example $PUBLISH_DIR/
|
||||
cp .npmignore $PUBLISH_DIR/ 2>/dev/null || true
|
||||
|
||||
# Use runtime package.json (already has correct version from sync)
|
||||
echo "📋 Using runtime-only dependencies..."
|
||||
cp package.runtime.json $PUBLISH_DIR/package.json
|
||||
|
||||
cd $PUBLISH_DIR
|
||||
|
||||
# Add required fields from main package.json
|
||||
node -e "
|
||||
const pkg = require('./package.json');
|
||||
pkg.name = 'n8n-mcp';
|
||||
pkg.description = 'Integration between n8n workflow automation and Model Context Protocol (MCP)';
|
||||
pkg.main = 'dist/index.js';
|
||||
pkg.types = 'dist/index.d.ts';
|
||||
pkg.exports = {
|
||||
'.': {
|
||||
types: './dist/index.d.ts',
|
||||
require: './dist/index.js',
|
||||
import: './dist/index.js'
|
||||
}
|
||||
};
|
||||
pkg.bin = { 'n8n-mcp': './dist/mcp/stdio-wrapper.js' };
|
||||
pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' };
|
||||
pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation'];
|
||||
pkg.author = 'Romuald Czlonkowski @ www.aiadvisors.pl/en';
|
||||
pkg.license = 'MIT';
|
||||
pkg.bugs = { url: 'https://github.com/czlonkowski/n8n-mcp/issues' };
|
||||
pkg.homepage = 'https://github.com/czlonkowski/n8n-mcp#readme';
|
||||
pkg.files = ['dist/**/*', 'data/nodes.db', '.env.example', 'README.md', 'LICENSE'];
|
||||
// Note: node_modules are automatically included for dependencies
|
||||
delete pkg.private; // Remove private field so we can publish
|
||||
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "📋 Package details:"
|
||||
echo -e "${GREEN}Name:${NC} $(node -e "console.log(require('./package.json').name)")"
|
||||
echo -e "${GREEN}Version:${NC} $(node -e "console.log(require('./package.json').version)")"
|
||||
echo -e "${GREEN}Size:${NC} ~50MB (vs 1GB+ with dev dependencies)"
|
||||
echo -e "${GREEN}Runtime deps:${NC} 8 packages"
|
||||
|
||||
echo ""
|
||||
echo "✅ Ready to publish!"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚠️ Important: npm publishing requires OTP authentication${NC}"
|
||||
echo ""
|
||||
echo "To publish, run:"
|
||||
echo -e " ${GREEN}cd $PUBLISH_DIR${NC}"
|
||||
echo -e " ${GREEN}npm publish --otp=YOUR_OTP_CODE${NC}"
|
||||
echo ""
|
||||
echo "After publishing, clean up with:"
|
||||
echo -e " ${GREEN}cd ..${NC}"
|
||||
echo -e " ${GREEN}rm -rf $PUBLISH_DIR${NC}"
|
||||
echo ""
|
||||
echo "📝 Notes:"
|
||||
echo " - Get your OTP from your authenticator app"
|
||||
echo " - The package will be available at https://www.npmjs.com/package/n8n-mcp"
|
||||
echo " - Users can run 'npx n8n-mcp' immediately after publish"
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env ts-node
|
||||
/**
|
||||
* Quick test script to validate the essentials implementation
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
green: '\x1b[32m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m'
|
||||
};
|
||||
|
||||
function log(message: string, color: string = colors.reset) {
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
async function runMCPCommand(toolName: string, args: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: args
|
||||
},
|
||||
id: 1
|
||||
};
|
||||
|
||||
const mcp = spawn('npm', ['start'], {
|
||||
cwd: join(__dirname, '..'),
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let error = '';
|
||||
|
||||
mcp.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
mcp.stderr.on('data', (data) => {
|
||||
error += data.toString();
|
||||
});
|
||||
|
||||
mcp.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Process exited with code ${code}: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse JSON-RPC response
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim() && line.includes('"jsonrpc"')) {
|
||||
const response = JSON.parse(line);
|
||||
if (response.result) {
|
||||
resolve(JSON.parse(response.result.content[0].text));
|
||||
return;
|
||||
} else if (response.error) {
|
||||
reject(new Error(response.error.message));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
reject(new Error('No valid response found'));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Send request
|
||||
mcp.stdin.write(JSON.stringify(request) + '\n');
|
||||
mcp.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function quickTest() {
|
||||
log('\n🚀 Quick Test - n8n MCP Essentials', colors.bright + colors.cyan);
|
||||
|
||||
try {
|
||||
// Test 1: Get essentials for HTTP Request
|
||||
log('\n1️⃣ Testing get_node_essentials for HTTP Request...', colors.yellow);
|
||||
const essentials = await runMCPCommand('get_node_essentials', {
|
||||
nodeType: 'nodes-base.httpRequest'
|
||||
});
|
||||
|
||||
log('✅ Success! Got essentials:', colors.green);
|
||||
log(` Required properties: ${essentials.requiredProperties?.map((p: any) => p.name).join(', ') || 'None'}`);
|
||||
log(` Common properties: ${essentials.commonProperties?.map((p: any) => p.name).join(', ') || 'None'}`);
|
||||
log(` Examples: ${Object.keys(essentials.examples || {}).join(', ')}`);
|
||||
log(` Response size: ${JSON.stringify(essentials).length} bytes`, colors.green);
|
||||
|
||||
// Test 2: Search properties
|
||||
log('\n2️⃣ Testing search_node_properties...', colors.yellow);
|
||||
const searchResults = await runMCPCommand('search_node_properties', {
|
||||
nodeType: 'nodes-base.httpRequest',
|
||||
query: 'auth'
|
||||
});
|
||||
|
||||
log('✅ Success! Found properties:', colors.green);
|
||||
log(` Matches: ${searchResults.totalMatches}`);
|
||||
searchResults.matches?.slice(0, 3).forEach((match: any) => {
|
||||
log(` - ${match.name}: ${match.description}`);
|
||||
});
|
||||
|
||||
// Test 3: Compare sizes
|
||||
log('\n3️⃣ Comparing response sizes...', colors.yellow);
|
||||
const fullInfo = await runMCPCommand('get_node_info', {
|
||||
nodeType: 'nodes-base.httpRequest'
|
||||
});
|
||||
|
||||
const fullSize = JSON.stringify(fullInfo).length;
|
||||
const essentialSize = JSON.stringify(essentials).length;
|
||||
const reduction = ((fullSize - essentialSize) / fullSize * 100).toFixed(1);
|
||||
|
||||
log(`✅ Size comparison:`, colors.green);
|
||||
log(` Full response: ${(fullSize / 1024).toFixed(1)} KB`);
|
||||
log(` Essential response: ${(essentialSize / 1024).toFixed(1)} KB`);
|
||||
log(` Size reduction: ${reduction}% 🎉`, colors.bright + colors.green);
|
||||
|
||||
log('\n✨ All tests passed!', colors.bright + colors.green);
|
||||
|
||||
} catch (error) {
|
||||
log(`\n❌ Test failed: ${error}`, colors.red);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
quickTest().catch(console.error);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* CommonJS runtime smoke test — regression guard for #864.
|
||||
*
|
||||
* The shipped artifact is compiled to CommonJS and `require()`s its dependencies. If a
|
||||
* dependency is ESM-only (no `require` export condition), `require()` throws
|
||||
* ERR_REQUIRE_ESM and the server crashes at startup before any config is read — exactly
|
||||
* how `uuid@14` broke v2.59.1–2.59.3.
|
||||
*
|
||||
* Node >= 20.19 / >= 22.12 enable `require(ESM)` by default, which silently masks the
|
||||
* mismatch — so just requiring the artifact on a modern Node would NOT catch it. We force
|
||||
* the strict (pre-`require(ESM)`) loader with `--no-experimental-require-module` so the
|
||||
* mismatch surfaces regardless of the runner's Node version.
|
||||
*
|
||||
* That flag does not exist on older Node (added in v22.0.0, backported to v20.19.0;
|
||||
* absent in 18.x and 20.0–20.18). On those versions the strict loader is already the
|
||||
* default, so the flag is unnecessary — and passing it would error with `bad option`. We
|
||||
* probe for flag support rather than hard-coding the version matrix, so the guard is
|
||||
* strict on every supported Node (>=18) instead of depending on which Node happens to run
|
||||
* it (the meta-mistake that produced #864).
|
||||
*/
|
||||
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
const FLAG = '--no-experimental-require-module';
|
||||
const entry = path.resolve(__dirname, '..', 'dist', 'index.js');
|
||||
const program =
|
||||
`require(${JSON.stringify(entry)}); ` +
|
||||
`console.log('CJS runtime load OK (node ' + process.versions.node + ')');`;
|
||||
|
||||
// Probe: does this Node recognize the strict-loader flag? Run an empty program with it.
|
||||
const flagSupported =
|
||||
spawnSync(process.execPath, [FLAG, '-e', ''], { stdio: 'ignore' }).status === 0;
|
||||
|
||||
const args = flagSupported ? [FLAG, '-e', program] : ['-e', program];
|
||||
const result = spawnSync(process.execPath, args, { stdio: 'inherit' });
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.error(
|
||||
`\nCJS runtime smoke test FAILED (node ${process.versions.node}, strict loader forced: ${flagSupported}).`
|
||||
);
|
||||
console.error(
|
||||
'The compiled dist/ could not be require()d under the CommonJS loader — a shipped ' +
|
||||
'dependency is likely ESM-only. See #864.'
|
||||
);
|
||||
process.exit(result.status === null ? 1 : result.status);
|
||||
}
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Sync version from package.json to package.runtime.json and README.md
|
||||
* This ensures all files always have the same version
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
||||
const packageRuntimePath = path.join(__dirname, '..', 'package.runtime.json');
|
||||
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||
|
||||
try {
|
||||
// Read package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
|
||||
const version = packageJson.version;
|
||||
|
||||
// Read package.runtime.json
|
||||
const packageRuntime = JSON.parse(fs.readFileSync(packageRuntimePath, 'utf-8'));
|
||||
|
||||
// Update version if different
|
||||
if (packageRuntime.version !== version) {
|
||||
packageRuntime.version = version;
|
||||
|
||||
// Write back with proper formatting
|
||||
fs.writeFileSync(
|
||||
packageRuntimePath,
|
||||
JSON.stringify(packageRuntime, null, 2) + '\n',
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
console.log(`✅ Updated package.runtime.json version to ${version}`);
|
||||
} else {
|
||||
console.log(`✓ package.runtime.json already at version ${version}`);
|
||||
}
|
||||
|
||||
// Update README.md version badge
|
||||
let readmeContent = fs.readFileSync(readmePath, 'utf-8');
|
||||
const versionBadgeRegex = /(\[!\[Version\]\(https:\/\/img\.shields\.io\/badge\/version-)[^-]+(-.+?\)\])/;
|
||||
const newVersionBadge = `$1${version}$2`;
|
||||
const updatedReadmeContent = readmeContent.replace(versionBadgeRegex, newVersionBadge);
|
||||
|
||||
if (updatedReadmeContent !== readmeContent) {
|
||||
fs.writeFileSync(readmePath, updatedReadmeContent);
|
||||
console.log(`✅ Updated README.md version badge to ${version}`);
|
||||
} else {
|
||||
console.log(`✓ README.md already has version badge ${version}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error syncing version:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Copy markdown skill files from the sibling n8n-skills repo into
|
||||
* data/skills/ so they ship inside the n8n-mcp npm/Docker artifacts.
|
||||
*
|
||||
* Source defaults to ../n8n-skills/skills relative to this repo root.
|
||||
* Override with N8N_SKILLS_SOURCE.
|
||||
*/
|
||||
import { promises as fs } from 'fs';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const CANDIDATE_SOURCES = [
|
||||
path.resolve(REPO_ROOT, '..', 'n8n-skills', 'skills'),
|
||||
path.resolve(REPO_ROOT, '..', '..', 'n8n-skills', 'skills'),
|
||||
];
|
||||
const SOURCE = process.env.N8N_SKILLS_SOURCE
|
||||
? path.resolve(process.env.N8N_SKILLS_SOURCE)
|
||||
: CANDIDATE_SOURCES.find((p) => existsSync(p)) ?? CANDIDATE_SOURCES[0];
|
||||
const DEST = path.join(REPO_ROOT, 'data', 'skills');
|
||||
|
||||
async function copyMarkdownTree(src: string, dst: string): Promise<number> {
|
||||
const entries = await fs.readdir(src, { withFileTypes: true });
|
||||
let copied = 0;
|
||||
await fs.mkdir(dst, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const dstPath = path.join(dst, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
// Skill-creator eval workspaces (skills/*-workspace/) are local debris,
|
||||
// untracked in n8n-skills and excluded from its dist builds — skip them.
|
||||
if (entry.name.endsWith('-workspace')) continue;
|
||||
copied += await copyMarkdownTree(srcPath, dstPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
await fs.copyFile(srcPath, dstPath);
|
||||
copied++;
|
||||
}
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
async function clearDestination(dir: string): Promise<void> {
|
||||
if (!existsSync(dir)) return;
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
await fs.rm(path.join(dir, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log(`Syncing skills from: ${SOURCE}`);
|
||||
console.log(` into: ${DEST}`);
|
||||
|
||||
if (!existsSync(SOURCE)) {
|
||||
if (existsSync(DEST)) {
|
||||
console.warn(`Source not found, keeping existing ${DEST} unchanged.`);
|
||||
return;
|
||||
}
|
||||
console.error(`Source directory not found: ${SOURCE}`);
|
||||
console.error('Set N8N_SKILLS_SOURCE or clone n8n-skills next to n8n-mcp.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await clearDestination(DEST);
|
||||
const count = await copyMarkdownTree(SOURCE, DEST);
|
||||
console.log(`Synced ${count} markdown files.`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('sync-skills failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Debug test for AI validation issues
|
||||
* Reproduces the bugs found by n8n-mcp-tester
|
||||
*/
|
||||
|
||||
import { validateAISpecificNodes, buildReverseConnectionMap } from '../src/services/ai-node-validator';
|
||||
import type { WorkflowJson } from '../src/services/ai-tool-validators';
|
||||
import { NodeTypeNormalizer } from '../src/utils/node-type-normalizer';
|
||||
|
||||
console.log('=== AI Validation Debug Tests ===\n');
|
||||
|
||||
// Test 1: AI Agent with NO language model connection
|
||||
console.log('Test 1: Missing Language Model Detection');
|
||||
const workflow1: WorkflowJson = {
|
||||
name: 'Test Missing LM',
|
||||
nodes: [
|
||||
{
|
||||
id: 'ai-agent-1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [500, 300],
|
||||
parameters: {
|
||||
promptType: 'define',
|
||||
text: 'You are a helpful assistant'
|
||||
},
|
||||
typeVersion: 1.7
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
// NO connections - AI Agent is isolated
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Workflow:', JSON.stringify(workflow1, null, 2));
|
||||
|
||||
const reverseMap1 = buildReverseConnectionMap(workflow1);
|
||||
console.log('\nReverse connection map for AI Agent:');
|
||||
console.log('Entries:', Array.from(reverseMap1.entries()));
|
||||
console.log('AI Agent connections:', reverseMap1.get('AI Agent'));
|
||||
|
||||
// Check node normalization
|
||||
const normalizedType1 = NodeTypeNormalizer.normalizeToFullForm(workflow1.nodes[0].type);
|
||||
console.log(`\nNode type: ${workflow1.nodes[0].type}`);
|
||||
console.log(`Normalized type: ${normalizedType1}`);
|
||||
console.log(`Match check: ${normalizedType1 === '@n8n/n8n-nodes-langchain.agent'}`);
|
||||
|
||||
const issues1 = validateAISpecificNodes(workflow1);
|
||||
console.log('\nValidation issues:');
|
||||
console.log(JSON.stringify(issues1, null, 2));
|
||||
|
||||
const hasMissingLMError = issues1.some(
|
||||
i => i.severity === 'error' && i.code === 'MISSING_LANGUAGE_MODEL'
|
||||
);
|
||||
console.log(`\n✓ Has MISSING_LANGUAGE_MODEL error: ${hasMissingLMError}`);
|
||||
console.log(`✗ Expected: true, Got: ${hasMissingLMError}`);
|
||||
|
||||
// Test 2: AI Agent WITH language model connection
|
||||
console.log('\n\n' + '='.repeat(60));
|
||||
console.log('Test 2: AI Agent WITH Language Model (Should be valid)');
|
||||
const workflow2: WorkflowJson = {
|
||||
name: 'Test With LM',
|
||||
nodes: [
|
||||
{
|
||||
id: 'openai-1',
|
||||
name: 'OpenAI Chat Model',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
position: [200, 300],
|
||||
parameters: {
|
||||
modelName: 'gpt-4'
|
||||
},
|
||||
typeVersion: 1
|
||||
},
|
||||
{
|
||||
id: 'ai-agent-1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [500, 300],
|
||||
parameters: {
|
||||
promptType: 'define',
|
||||
text: 'You are a helpful assistant'
|
||||
},
|
||||
typeVersion: 1.7
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'OpenAI Chat Model': {
|
||||
ai_languageModel: [
|
||||
[
|
||||
{
|
||||
node: 'AI Agent',
|
||||
type: 'ai_languageModel',
|
||||
index: 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('\nConnections:', JSON.stringify(workflow2.connections, null, 2));
|
||||
|
||||
const reverseMap2 = buildReverseConnectionMap(workflow2);
|
||||
console.log('\nReverse connection map for AI Agent:');
|
||||
console.log('AI Agent connections:', reverseMap2.get('AI Agent'));
|
||||
|
||||
const issues2 = validateAISpecificNodes(workflow2);
|
||||
console.log('\nValidation issues:');
|
||||
console.log(JSON.stringify(issues2, null, 2));
|
||||
|
||||
const hasMissingLMError2 = issues2.some(
|
||||
i => i.severity === 'error' && i.code === 'MISSING_LANGUAGE_MODEL'
|
||||
);
|
||||
console.log(`\n✓ Should NOT have MISSING_LANGUAGE_MODEL error: ${!hasMissingLMError2}`);
|
||||
console.log(`Expected: false, Got: ${hasMissingLMError2}`);
|
||||
|
||||
// Test 3: AI Agent with tools but no language model
|
||||
console.log('\n\n' + '='.repeat(60));
|
||||
console.log('Test 3: AI Agent with Tools but NO Language Model');
|
||||
const workflow3: WorkflowJson = {
|
||||
name: 'Test Tools No LM',
|
||||
nodes: [
|
||||
{
|
||||
id: 'http-tool-1',
|
||||
name: 'HTTP Request Tool',
|
||||
type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
|
||||
position: [200, 300],
|
||||
parameters: {
|
||||
toolDescription: 'Calls an API',
|
||||
url: 'https://api.example.com'
|
||||
},
|
||||
typeVersion: 1.1
|
||||
},
|
||||
{
|
||||
id: 'ai-agent-1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [500, 300],
|
||||
parameters: {
|
||||
promptType: 'define',
|
||||
text: 'You are a helpful assistant'
|
||||
},
|
||||
typeVersion: 1.7
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'HTTP Request Tool': {
|
||||
ai_tool: [
|
||||
[
|
||||
{
|
||||
node: 'AI Agent',
|
||||
type: 'ai_tool',
|
||||
index: 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('\nConnections:', JSON.stringify(workflow3.connections, null, 2));
|
||||
|
||||
const reverseMap3 = buildReverseConnectionMap(workflow3);
|
||||
console.log('\nReverse connection map for AI Agent:');
|
||||
const aiAgentConns = reverseMap3.get('AI Agent');
|
||||
console.log('AI Agent connections:', aiAgentConns);
|
||||
console.log('Connection types:', aiAgentConns?.map(c => c.type));
|
||||
|
||||
const issues3 = validateAISpecificNodes(workflow3);
|
||||
console.log('\nValidation issues:');
|
||||
console.log(JSON.stringify(issues3, null, 2));
|
||||
|
||||
const hasMissingLMError3 = issues3.some(
|
||||
i => i.severity === 'error' && i.code === 'MISSING_LANGUAGE_MODEL'
|
||||
);
|
||||
const hasNoToolsInfo3 = issues3.some(
|
||||
i => i.severity === 'info' && i.message.includes('no ai_tool connections')
|
||||
);
|
||||
|
||||
console.log(`\n✓ Should have MISSING_LANGUAGE_MODEL error: ${hasMissingLMError3}`);
|
||||
console.log(`Expected: true, Got: ${hasMissingLMError3}`);
|
||||
console.log(`✗ Should NOT have "no tools" info: ${!hasNoToolsInfo3}`);
|
||||
console.log(`Expected: false, Got: ${hasNoToolsInfo3}`);
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('Summary:');
|
||||
console.log(`Test 1 (No LM): ${hasMissingLMError ? 'PASS ✓' : 'FAIL ✗'}`);
|
||||
console.log(`Test 2 (With LM): ${!hasMissingLMError2 ? 'PASS ✓' : 'FAIL ✗'}`);
|
||||
console.log(`Test 3 (Tools, No LM): ${hasMissingLMError3 && !hasNoToolsInfo3 ? 'PASS ✓' : 'FAIL ✗'}`);
|
||||
Executable
+203
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Test script for Code node enhancements
|
||||
* Tests:
|
||||
* 1. Code node documentation in tools_documentation
|
||||
* 2. Enhanced validation for Code nodes
|
||||
* 3. Code node examples
|
||||
* 4. Code node task templates
|
||||
*/
|
||||
|
||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator.js';
|
||||
import { ExampleGenerator } from '../src/services/example-generator.js';
|
||||
import { TaskTemplates } from '../src/services/task-templates.js';
|
||||
import { getToolDocumentation } from '../src/mcp/tools-documentation.js';
|
||||
|
||||
console.log('🧪 Testing Code Node Enhancements\n');
|
||||
|
||||
// Test 1: Code node documentation
|
||||
console.log('1️⃣ Testing Code Node Documentation');
|
||||
console.log('=====================================');
|
||||
const codeNodeDocs = getToolDocumentation('code_node_guide', 'essentials');
|
||||
console.log('✅ Code node documentation available');
|
||||
console.log('First 500 chars:', codeNodeDocs.substring(0, 500) + '...\n');
|
||||
|
||||
// Test 2: Code node validation
|
||||
console.log('2️⃣ Testing Code Node Validation');
|
||||
console.log('=====================================');
|
||||
|
||||
// Test cases
|
||||
const validationTests = [
|
||||
{
|
||||
name: 'Empty code',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: ''
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'No return statement',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: 'const data = items;'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Invalid return format',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: 'return "hello";'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Valid code',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: 'return [{json: {result: "success"}}];'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Python with external library',
|
||||
config: {
|
||||
language: 'python',
|
||||
pythonCode: 'import pandas as pd\nreturn [{"json": {"result": "fail"}}]'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Code with $json in wrong mode',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: 'const value = $json.field;\nreturn [{json: {value}}];'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Code with security issue',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: 'const result = eval(item.json.code);\nreturn [{json: {result}}];'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const test of validationTests) {
|
||||
console.log(`\nTest: ${test.name}`);
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
'nodes-base.code',
|
||||
test.config,
|
||||
[
|
||||
{ name: 'language', type: 'options', options: ['javaScript', 'python'] },
|
||||
{ name: 'jsCode', type: 'string' },
|
||||
{ name: 'pythonCode', type: 'string' },
|
||||
{ name: 'mode', type: 'options', options: ['runOnceForAllItems', 'runOnceForEachItem'] }
|
||||
],
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
console.log(` Valid: ${result.valid}`);
|
||||
if (result.errors.length > 0) {
|
||||
console.log(` Errors: ${result.errors.map(e => e.message).join(', ')}`);
|
||||
}
|
||||
if (result.warnings.length > 0) {
|
||||
console.log(` Warnings: ${result.warnings.map(w => w.message).join(', ')}`);
|
||||
}
|
||||
if (result.suggestions.length > 0) {
|
||||
console.log(` Suggestions: ${result.suggestions.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: Code node examples
|
||||
console.log('\n\n3️⃣ Testing Code Node Examples');
|
||||
console.log('=====================================');
|
||||
|
||||
const codeExamples = ExampleGenerator.getExamples('nodes-base.code');
|
||||
console.log('Available examples:', Object.keys(codeExamples));
|
||||
console.log('\nMinimal example:');
|
||||
console.log(JSON.stringify(codeExamples.minimal, null, 2));
|
||||
console.log('\nCommon example preview:');
|
||||
console.log(codeExamples.common?.jsCode?.substring(0, 200) + '...');
|
||||
|
||||
// Test 4: Code node task templates
|
||||
console.log('\n\n4️⃣ Testing Code Node Task Templates');
|
||||
console.log('=====================================');
|
||||
|
||||
const codeNodeTasks = [
|
||||
'transform_data',
|
||||
'custom_ai_tool',
|
||||
'aggregate_data',
|
||||
'batch_process_with_api',
|
||||
'error_safe_transform',
|
||||
'async_data_processing',
|
||||
'python_data_analysis'
|
||||
];
|
||||
|
||||
for (const taskName of codeNodeTasks) {
|
||||
const template = TaskTemplates.getTemplate(taskName);
|
||||
if (template) {
|
||||
console.log(`\n✅ ${taskName}:`);
|
||||
console.log(` Description: ${template.description}`);
|
||||
console.log(` Language: ${template.configuration.language || 'javaScript'}`);
|
||||
console.log(` Code preview: ${template.configuration.jsCode?.substring(0, 100) || template.configuration.pythonCode?.substring(0, 100)}...`);
|
||||
} else {
|
||||
console.log(`\n❌ ${taskName}: Template not found`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: Validate a complex Code node configuration
|
||||
console.log('\n\n5️⃣ Testing Complex Code Node Validation');
|
||||
console.log('==========================================');
|
||||
|
||||
const complexCode = {
|
||||
language: 'javaScript',
|
||||
mode: 'runOnceForEachItem',
|
||||
jsCode: `// Complex validation test
|
||||
try {
|
||||
const email = $json.email;
|
||||
const response = await $helpers.httpRequest({
|
||||
method: 'POST',
|
||||
url: 'https://api.example.com/validate',
|
||||
body: { email }
|
||||
});
|
||||
|
||||
return [{
|
||||
json: {
|
||||
...response,
|
||||
validated: true
|
||||
}
|
||||
}];
|
||||
} catch (error) {
|
||||
return [{
|
||||
json: {
|
||||
error: error.message,
|
||||
validated: false
|
||||
}
|
||||
}];
|
||||
}`,
|
||||
onError: 'continueRegularOutput',
|
||||
retryOnFail: true,
|
||||
maxTries: 3
|
||||
};
|
||||
|
||||
const complexResult = EnhancedConfigValidator.validateWithMode(
|
||||
'nodes-base.code',
|
||||
complexCode,
|
||||
[
|
||||
{ name: 'language', type: 'options', options: ['javaScript', 'python'] },
|
||||
{ name: 'jsCode', type: 'string' },
|
||||
{ name: 'mode', type: 'options', options: ['runOnceForAllItems', 'runOnceForEachItem'] },
|
||||
{ name: 'onError', type: 'options' },
|
||||
{ name: 'retryOnFail', type: 'boolean' },
|
||||
{ name: 'maxTries', type: 'number' }
|
||||
],
|
||||
'operation',
|
||||
'strict'
|
||||
);
|
||||
|
||||
console.log('Complex code validation:');
|
||||
console.log(` Valid: ${complexResult.valid}`);
|
||||
console.log(` Errors: ${complexResult.errors.length}`);
|
||||
console.log(` Warnings: ${complexResult.warnings.length}`);
|
||||
console.log(` Suggestions: ${complexResult.suggestions.length}`);
|
||||
|
||||
console.log('\n✅ All Code node enhancement tests completed!');
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env ts-node
|
||||
|
||||
/**
|
||||
* Test script to verify Code node documentation fixes
|
||||
*/
|
||||
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { NodeDocumentationService } from '../src/services/node-documentation-service';
|
||||
import { getToolDocumentation } from '../src/mcp/tools-documentation';
|
||||
import { ExampleGenerator } from '../src/services/example-generator';
|
||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
|
||||
|
||||
const dbPath = process.env.NODE_DB_PATH || './data/nodes.db';
|
||||
|
||||
async function main() {
|
||||
console.log('🧪 Testing Code Node Documentation Fixes\n');
|
||||
|
||||
const db = await createDatabaseAdapter(dbPath);
|
||||
const service = new NodeDocumentationService(dbPath);
|
||||
|
||||
// Test 1: Check JMESPath documentation
|
||||
console.log('1️⃣ Testing JMESPath Documentation Fix');
|
||||
console.log('=====================================');
|
||||
const codeNodeGuide = getToolDocumentation('code_node_guide', 'full');
|
||||
|
||||
// Check for correct JMESPath syntax
|
||||
if (codeNodeGuide.includes('$jmespath(') && !codeNodeGuide.includes('jmespath.search(')) {
|
||||
console.log('✅ JMESPath documentation correctly shows $jmespath() syntax');
|
||||
} else {
|
||||
console.log('❌ JMESPath documentation still shows incorrect syntax');
|
||||
}
|
||||
|
||||
// Check for Python JMESPath
|
||||
if (codeNodeGuide.includes('_jmespath(')) {
|
||||
console.log('✅ Python JMESPath with underscore prefix documented');
|
||||
} else {
|
||||
console.log('❌ Python JMESPath not properly documented');
|
||||
}
|
||||
|
||||
// Test 2: Check $node documentation
|
||||
console.log('\n2️⃣ Testing $node Documentation Fix');
|
||||
console.log('===================================');
|
||||
|
||||
if (codeNodeGuide.includes("$('Previous Node')") && !codeNodeGuide.includes('$node.name')) {
|
||||
console.log('✅ Node access correctly shows $("Node Name") syntax');
|
||||
} else {
|
||||
console.log('❌ Node access documentation still incorrect');
|
||||
}
|
||||
|
||||
// Test 3: Check Python item.json documentation
|
||||
console.log('\n3️⃣ Testing Python item.json Documentation Fix');
|
||||
console.log('==============================================');
|
||||
|
||||
if (codeNodeGuide.includes('item.json.to_py()') && codeNodeGuide.includes('JsProxy')) {
|
||||
console.log('✅ Python item.json correctly documented with to_py() method');
|
||||
} else {
|
||||
console.log('❌ Python item.json documentation incomplete');
|
||||
}
|
||||
|
||||
// Test 4: Check Python examples
|
||||
console.log('\n4️⃣ Testing Python Examples');
|
||||
console.log('===========================');
|
||||
|
||||
const pythonExample = ExampleGenerator.getExamples('nodes-base.code.pythonExample');
|
||||
if (pythonExample?.minimal?.pythonCode?.includes('_input.all()') &&
|
||||
pythonExample?.minimal?.pythonCode?.includes('to_py()')) {
|
||||
console.log('✅ Python examples use correct _input.all() and to_py()');
|
||||
} else {
|
||||
console.log('❌ Python examples not updated correctly');
|
||||
}
|
||||
|
||||
// Test 5: Validate Code node without visibility warnings
|
||||
console.log('\n5️⃣ Testing Code Node Validation (No Visibility Warnings)');
|
||||
console.log('=========================================================');
|
||||
|
||||
const codeNodeInfo = await service.getNodeInfo('n8n-nodes-base.code');
|
||||
if (!codeNodeInfo) {
|
||||
console.log('❌ Could not find Code node info');
|
||||
return;
|
||||
}
|
||||
|
||||
const testConfig = {
|
||||
language: 'javaScript',
|
||||
jsCode: 'return items.map(item => ({json: {...item.json, processed: true}}))',
|
||||
mode: 'runOnceForAllItems',
|
||||
onError: 'continueRegularOutput'
|
||||
};
|
||||
|
||||
const nodeProperties = (codeNodeInfo as any).properties || [];
|
||||
const validationResult = EnhancedConfigValidator.validateWithMode(
|
||||
'nodes-base.code',
|
||||
testConfig,
|
||||
nodeProperties,
|
||||
'full',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
// Check if there are any visibility warnings
|
||||
const visibilityWarnings = validationResult.warnings.filter(w =>
|
||||
w.message.includes("won't be used due to current settings")
|
||||
);
|
||||
|
||||
if (visibilityWarnings.length === 0) {
|
||||
console.log('✅ No false positive visibility warnings for Code node');
|
||||
} else {
|
||||
console.log(`❌ Still getting ${visibilityWarnings.length} visibility warnings:`);
|
||||
visibilityWarnings.forEach(w => console.log(` - ${w.property}: ${w.message}`));
|
||||
}
|
||||
|
||||
// Test 6: Check Python underscore variables in documentation
|
||||
console.log('\n6️⃣ Testing Python Underscore Variables');
|
||||
console.log('========================================');
|
||||
|
||||
const pythonVarsDocumented = codeNodeGuide.includes('Variables use underscore prefix') &&
|
||||
codeNodeGuide.includes('_input') &&
|
||||
codeNodeGuide.includes('_json') &&
|
||||
codeNodeGuide.includes('_jmespath');
|
||||
|
||||
if (pythonVarsDocumented) {
|
||||
console.log('✅ Python underscore variables properly documented');
|
||||
} else {
|
||||
console.log('❌ Python underscore variables not fully documented');
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n📊 Test Summary');
|
||||
console.log('===============');
|
||||
console.log('All critical documentation fixes have been verified!');
|
||||
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to run Docker config tests
|
||||
# Usage: ./scripts/test-docker-config.sh [unit|integration|all]
|
||||
|
||||
set -e
|
||||
|
||||
MODE=${1:-all}
|
||||
|
||||
echo "Running Docker config tests in mode: $MODE"
|
||||
|
||||
case $MODE in
|
||||
unit)
|
||||
echo "Running unit tests..."
|
||||
npm test -- tests/unit/docker/
|
||||
;;
|
||||
integration)
|
||||
echo "Running integration tests (requires Docker)..."
|
||||
RUN_DOCKER_TESTS=true npm run test:integration -- tests/integration/docker/
|
||||
;;
|
||||
all)
|
||||
echo "Running all Docker config tests..."
|
||||
npm test -- tests/unit/docker/
|
||||
if command -v docker &> /dev/null; then
|
||||
echo "Docker found, running integration tests..."
|
||||
RUN_DOCKER_TESTS=true npm run test:integration -- tests/integration/docker/
|
||||
else
|
||||
echo "Docker not found, skipping integration tests"
|
||||
fi
|
||||
;;
|
||||
coverage)
|
||||
echo "Running Docker config tests with coverage..."
|
||||
npm run test:coverage -- tests/unit/docker/
|
||||
;;
|
||||
security)
|
||||
echo "Running security-focused tests..."
|
||||
npm test -- tests/unit/docker/config-security.test.ts tests/unit/docker/parse-config.test.ts
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [unit|integration|all|coverage|security]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Docker config tests completed!"
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Test Docker Host Fingerprinting
|
||||
* Verifies that host machine characteristics are stable across container recreations
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { platform, arch } from 'os';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
console.log('=== Docker Host Fingerprinting Test ===\n');
|
||||
|
||||
function generateHostFingerprint(): string {
|
||||
try {
|
||||
const signals: string[] = [];
|
||||
|
||||
console.log('Collecting host signals...\n');
|
||||
|
||||
// CPU info (stable across container recreations)
|
||||
if (existsSync('/proc/cpuinfo')) {
|
||||
const cpuinfo = readFileSync('/proc/cpuinfo', 'utf-8');
|
||||
const modelMatch = cpuinfo.match(/model name\s*:\s*(.+)/);
|
||||
const coresMatch = cpuinfo.match(/processor\s*:/g);
|
||||
|
||||
if (modelMatch) {
|
||||
const cpuModel = modelMatch[1].trim();
|
||||
signals.push(cpuModel);
|
||||
console.log('✓ CPU Model:', cpuModel);
|
||||
}
|
||||
|
||||
if (coresMatch) {
|
||||
const cores = `cores:${coresMatch.length}`;
|
||||
signals.push(cores);
|
||||
console.log('✓ CPU Cores:', coresMatch.length);
|
||||
}
|
||||
} else {
|
||||
console.log('✗ /proc/cpuinfo not available (Windows/Mac Docker)');
|
||||
}
|
||||
|
||||
// Memory (stable)
|
||||
if (existsSync('/proc/meminfo')) {
|
||||
const meminfo = readFileSync('/proc/meminfo', 'utf-8');
|
||||
const totalMatch = meminfo.match(/MemTotal:\s+(\d+)/);
|
||||
|
||||
if (totalMatch) {
|
||||
const memory = `mem:${totalMatch[1]}`;
|
||||
signals.push(memory);
|
||||
console.log('✓ Total Memory:', totalMatch[1], 'kB');
|
||||
}
|
||||
} else {
|
||||
console.log('✗ /proc/meminfo not available (Windows/Mac Docker)');
|
||||
}
|
||||
|
||||
// Docker network subnet
|
||||
const networkInfo = getDockerNetworkInfo();
|
||||
if (networkInfo) {
|
||||
signals.push(networkInfo);
|
||||
console.log('✓ Network Info:', networkInfo);
|
||||
} else {
|
||||
console.log('✗ Network info not available');
|
||||
}
|
||||
|
||||
// Platform basics (stable)
|
||||
signals.push(platform(), arch());
|
||||
console.log('✓ Platform:', platform());
|
||||
console.log('✓ Architecture:', arch());
|
||||
|
||||
// Generate stable ID from all signals
|
||||
console.log('\nCombined signals:', signals.join(' | '));
|
||||
const fingerprint = signals.join('-');
|
||||
const userId = createHash('sha256').update(fingerprint).digest('hex').substring(0, 16);
|
||||
|
||||
return userId;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating fingerprint:', error);
|
||||
// Fallback
|
||||
return createHash('sha256')
|
||||
.update(`${platform()}-${arch()}-docker`)
|
||||
.digest('hex')
|
||||
.substring(0, 16);
|
||||
}
|
||||
}
|
||||
|
||||
function getDockerNetworkInfo(): string | null {
|
||||
try {
|
||||
// Read routing table to get bridge network
|
||||
if (existsSync('/proc/net/route')) {
|
||||
const routes = readFileSync('/proc/net/route', 'utf-8');
|
||||
const lines = routes.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('eth0')) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts[2]) {
|
||||
const gateway = parseInt(parts[2], 16).toString(16);
|
||||
return `net:${gateway}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Test environment detection
|
||||
console.log('\n=== Environment Detection ===\n');
|
||||
|
||||
const isDocker = process.env.IS_DOCKER === 'true';
|
||||
const isCloudEnvironment = !!(
|
||||
process.env.RAILWAY_ENVIRONMENT ||
|
||||
process.env.RENDER ||
|
||||
process.env.FLY_APP_NAME ||
|
||||
process.env.HEROKU_APP_NAME ||
|
||||
process.env.AWS_EXECUTION_ENV ||
|
||||
process.env.KUBERNETES_SERVICE_HOST
|
||||
);
|
||||
|
||||
console.log('IS_DOCKER env:', process.env.IS_DOCKER);
|
||||
console.log('Docker detected:', isDocker);
|
||||
console.log('Cloud environment:', isCloudEnvironment);
|
||||
|
||||
// Generate fingerprints
|
||||
console.log('\n=== Fingerprint Generation ===\n');
|
||||
|
||||
const fingerprint1 = generateHostFingerprint();
|
||||
const fingerprint2 = generateHostFingerprint();
|
||||
const fingerprint3 = generateHostFingerprint();
|
||||
|
||||
console.log('\nFingerprint 1:', fingerprint1);
|
||||
console.log('Fingerprint 2:', fingerprint2);
|
||||
console.log('Fingerprint 3:', fingerprint3);
|
||||
|
||||
const consistent = fingerprint1 === fingerprint2 && fingerprint2 === fingerprint3;
|
||||
console.log('\nConsistent:', consistent ? '✓ YES' : '✗ NO');
|
||||
|
||||
// Test explicit ID override
|
||||
console.log('\n=== Environment Variable Override Test ===\n');
|
||||
|
||||
if (process.env.N8N_MCP_USER_ID) {
|
||||
// Intentionally log nothing derived from the env value — not even a
|
||||
// masked prefix, because CodeQL's taint tracking follows string
|
||||
// slicing and any substring still counts as "sensitive data in
|
||||
// clear text". The only signal the developer needs here is "an
|
||||
// override is set", which is what this line conveys.
|
||||
// Addresses CodeQL js/clear-text-logging.
|
||||
console.log('Explicit user ID is set (value redacted)');
|
||||
console.log('This would override the fingerprint');
|
||||
} else {
|
||||
console.log('No explicit user ID set');
|
||||
console.log('To test: N8N_MCP_USER_ID=my-custom-id npx tsx ' + process.argv[1]);
|
||||
}
|
||||
|
||||
// Stability estimate
|
||||
console.log('\n=== Stability Analysis ===\n');
|
||||
|
||||
const hasStableSignals = existsSync('/proc/cpuinfo') || existsSync('/proc/meminfo');
|
||||
if (hasStableSignals) {
|
||||
console.log('✓ Host-based signals available');
|
||||
console.log('✓ Fingerprint should be stable across container recreations');
|
||||
console.log('✓ Different fingerprints on different physical hosts');
|
||||
} else {
|
||||
console.log('⚠️ Limited host signals (Windows/Mac Docker Desktop)');
|
||||
console.log('⚠️ Fingerprint may not be fully stable');
|
||||
console.log('💡 Recommendation: Use N8N_MCP_USER_ID env var for stability');
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
# Test script to verify Docker optimization (no n8n deps)
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧪 Testing Docker optimization..."
|
||||
echo ""
|
||||
|
||||
# Check if nodes.db exists
|
||||
if [ ! -f "data/nodes.db" ]; then
|
||||
echo "❌ ERROR: data/nodes.db not found!"
|
||||
echo " Run 'npm run rebuild' first to create the database"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the image
|
||||
echo "📦 Building Docker image..."
|
||||
DOCKER_BUILDKIT=1 docker build -t n8n-mcp:test . > /dev/null 2>&1
|
||||
|
||||
# Check image size
|
||||
echo "📊 Checking image size..."
|
||||
SIZE=$(docker images n8n-mcp:test --format "{{.Size}}")
|
||||
echo " Image size: $SIZE"
|
||||
|
||||
# Test that n8n is NOT in the image
|
||||
echo ""
|
||||
echo "🔍 Verifying no n8n dependencies..."
|
||||
if docker run --rm n8n-mcp:test sh -c "ls node_modules | grep -E '^n8n$|^n8n-|^@n8n'" 2>/dev/null; then
|
||||
echo "❌ ERROR: Found n8n dependencies in runtime image!"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ No n8n dependencies found (as expected)"
|
||||
fi
|
||||
|
||||
# Test that runtime dependencies ARE present
|
||||
echo ""
|
||||
echo "🔍 Verifying runtime dependencies..."
|
||||
EXPECTED_DEPS=("@modelcontextprotocol" "better-sqlite3" "express" "dotenv")
|
||||
for dep in "${EXPECTED_DEPS[@]}"; do
|
||||
if docker run --rm n8n-mcp:test sh -c "ls node_modules | grep -q '$dep'" 2>/dev/null; then
|
||||
echo "✅ Found: $dep"
|
||||
else
|
||||
echo "❌ Missing: $dep"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Test that the server starts
|
||||
echo ""
|
||||
echo "🚀 Testing server startup..."
|
||||
docker run --rm -d \
|
||||
--name n8n-mcp-test \
|
||||
-e MCP_MODE=http \
|
||||
-e AUTH_TOKEN=test-token \
|
||||
-e LOG_LEVEL=error \
|
||||
n8n-mcp:test > /dev/null 2>&1
|
||||
|
||||
# Wait for startup
|
||||
sleep 3
|
||||
|
||||
# Check if running
|
||||
if docker ps | grep -q n8n-mcp-test; then
|
||||
echo "✅ Server started successfully"
|
||||
docker stop n8n-mcp-test > /dev/null 2>&1
|
||||
else
|
||||
echo "❌ Server failed to start"
|
||||
docker logs n8n-mcp-test 2>&1
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up
|
||||
docker rmi n8n-mcp:test > /dev/null 2>&1
|
||||
|
||||
echo ""
|
||||
echo "🎉 All tests passed! Docker optimization is working correctly."
|
||||
echo ""
|
||||
echo "📈 Benefits:"
|
||||
echo " - No n8n dependencies in runtime image"
|
||||
echo " - Image size: ~200MB (vs ~1.5GB with n8n)"
|
||||
echo " - Build time: ~1-2 minutes (vs ~12 minutes)"
|
||||
echo " - No version conflicts at runtime"
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# scripts/test-docker.sh
|
||||
|
||||
echo "🧪 Testing n8n-MCP Docker Deployment"
|
||||
|
||||
# Test 1: Build simple image
|
||||
echo "1. Building simple Docker image..."
|
||||
docker build -t n8n-mcp:test .
|
||||
|
||||
# Test 2: Test stdio mode
|
||||
echo "2. Testing stdio mode..."
|
||||
echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | \
|
||||
docker run --rm -i -e MCP_MODE=stdio n8n-mcp:test
|
||||
|
||||
# Test 3: Test HTTP mode
|
||||
echo "3. Testing HTTP mode..."
|
||||
docker run -d --name test-http \
|
||||
-e MCP_MODE=http \
|
||||
-e AUTH_TOKEN=test-token \
|
||||
-p 3001:3000 \
|
||||
n8n-mcp:test
|
||||
|
||||
sleep 5
|
||||
|
||||
# Check health
|
||||
curl -f http://localhost:3001/health || echo "Health check failed"
|
||||
|
||||
# Test auth
|
||||
curl -H "Authorization: Bearer test-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
|
||||
http://localhost:3001/mcp
|
||||
|
||||
docker stop test-http && docker rm test-http
|
||||
|
||||
# Test 4: Volume persistence
|
||||
echo "4. Testing volume persistence..."
|
||||
docker volume create test-data
|
||||
docker run -d --name test-persist \
|
||||
-v test-data:/app/data \
|
||||
-e MCP_MODE=http \
|
||||
-e AUTH_TOKEN=test \
|
||||
-p 3002:3000 \
|
||||
n8n-mcp:test
|
||||
|
||||
sleep 10
|
||||
docker exec test-persist ls -la /app/data/nodes.db
|
||||
docker stop test-persist && docker rm test-persist
|
||||
docker volume rm test-data
|
||||
|
||||
echo "✅ Docker tests completed!"
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Test script for empty connection validation
|
||||
* Tests the improvements to prevent broken workflows like the one in the logs
|
||||
*/
|
||||
|
||||
import { WorkflowValidator } from '../src/services/workflow-validator';
|
||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
|
||||
import { NodeRepository } from '../src/database/node-repository';
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { validateWorkflowStructure, getWorkflowFixSuggestions, getWorkflowStructureExample } from '../src/services/n8n-validation';
|
||||
import { Logger } from '../src/utils/logger';
|
||||
|
||||
const logger = new Logger({ prefix: '[TestEmptyConnectionValidation]' });
|
||||
|
||||
async function testValidation() {
|
||||
const adapter = await createDatabaseAdapter('./data/nodes.db');
|
||||
const repository = new NodeRepository(adapter);
|
||||
const validator = new WorkflowValidator(repository, EnhancedConfigValidator);
|
||||
|
||||
logger.info('Testing empty connection validation...\n');
|
||||
|
||||
// Test 1: The broken workflow from the logs
|
||||
const brokenWorkflow = {
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "webhook_node",
|
||||
"name": "Webhook",
|
||||
"type": "nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [260, 300] as [number, number]
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"pinData": {},
|
||||
"meta": {
|
||||
"instanceId": "74e11c77e266f2c77f6408eb6c88e3fec63c9a5d8c4a3a2ea4c135c542012d6b"
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('Test 1: Broken single-node workflow with empty connections');
|
||||
const result1 = await validator.validateWorkflow(brokenWorkflow as any);
|
||||
|
||||
logger.info('Validation result:');
|
||||
logger.info(`Valid: ${result1.valid}`);
|
||||
logger.info(`Errors: ${result1.errors.length}`);
|
||||
result1.errors.forEach(err => {
|
||||
if (typeof err === 'string') {
|
||||
logger.error(` - ${err}`);
|
||||
} else if (err && typeof err === 'object' && 'message' in err) {
|
||||
logger.error(` - ${err.message}`);
|
||||
} else {
|
||||
logger.error(` - ${JSON.stringify(err)}`);
|
||||
}
|
||||
});
|
||||
logger.info(`Warnings: ${result1.warnings.length}`);
|
||||
result1.warnings.forEach(warn => logger.warn(` - ${warn.message || JSON.stringify(warn)}`));
|
||||
logger.info(`Suggestions: ${result1.suggestions.length}`);
|
||||
result1.suggestions.forEach(sug => logger.info(` - ${sug}`));
|
||||
|
||||
// Test 2: Multi-node workflow with no connections
|
||||
const multiNodeNoConnections = {
|
||||
"name": "Test Workflow",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "manual-1",
|
||||
"name": "Manual Trigger",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [250, 300] as [number, number],
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"id": "set-1",
|
||||
"name": "Set",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [450, 300] as [number, number],
|
||||
"parameters": {}
|
||||
}
|
||||
],
|
||||
"connections": {}
|
||||
};
|
||||
|
||||
logger.info('\nTest 2: Multi-node workflow with empty connections');
|
||||
const result2 = await validator.validateWorkflow(multiNodeNoConnections as any);
|
||||
|
||||
logger.info('Validation result:');
|
||||
logger.info(`Valid: ${result2.valid}`);
|
||||
logger.info(`Errors: ${result2.errors.length}`);
|
||||
result2.errors.forEach(err => logger.error(` - ${err.message || JSON.stringify(err)}`));
|
||||
logger.info(`Suggestions: ${result2.suggestions.length}`);
|
||||
result2.suggestions.forEach(sug => logger.info(` - ${sug}`));
|
||||
|
||||
// Test 3: Using n8n-validation functions
|
||||
logger.info('\nTest 3: Testing n8n-validation.ts functions');
|
||||
|
||||
const errors = validateWorkflowStructure(brokenWorkflow as any);
|
||||
logger.info('Validation errors:');
|
||||
errors.forEach(err => logger.error(` - ${err}`));
|
||||
|
||||
const suggestions = getWorkflowFixSuggestions(errors);
|
||||
logger.info('Fix suggestions:');
|
||||
suggestions.forEach(sug => logger.info(` - ${sug}`));
|
||||
|
||||
logger.info('\nExample of proper workflow structure:');
|
||||
logger.info(getWorkflowStructureExample());
|
||||
|
||||
// Test 4: Workflow using IDs instead of names in connections
|
||||
const workflowWithIdConnections = {
|
||||
"name": "Test Workflow",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "manual-1",
|
||||
"name": "Manual Trigger",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [250, 300] as [number, number],
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"id": "set-1",
|
||||
"name": "Set Data",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [450, 300] as [number, number],
|
||||
"parameters": {}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"manual-1": { // Using ID instead of name!
|
||||
"main": [[{
|
||||
"node": "set-1", // Using ID instead of name!
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('\nTest 4: Workflow using IDs instead of names in connections');
|
||||
const result4 = await validator.validateWorkflow(workflowWithIdConnections as any);
|
||||
|
||||
logger.info('Validation result:');
|
||||
logger.info(`Valid: ${result4.valid}`);
|
||||
logger.info(`Errors: ${result4.errors.length}`);
|
||||
result4.errors.forEach(err => logger.error(` - ${err.message || JSON.stringify(err)}`));
|
||||
|
||||
adapter.close();
|
||||
}
|
||||
|
||||
testValidation().catch(err => {
|
||||
logger.error('Test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Test script to verify error message tracking is working
|
||||
*/
|
||||
|
||||
import { telemetry } from '../src/telemetry';
|
||||
|
||||
async function testErrorTracking() {
|
||||
console.log('=== Testing Error Message Tracking ===\n');
|
||||
|
||||
// Track session first
|
||||
console.log('1. Starting session...');
|
||||
telemetry.trackSessionStart();
|
||||
|
||||
// Track an error WITH a message
|
||||
console.log('\n2. Tracking error WITH message:');
|
||||
const testErrorMessage = 'This is a test error message with sensitive data: password=secret123 and test@example.com';
|
||||
telemetry.trackError(
|
||||
'TypeError',
|
||||
'tool_execution',
|
||||
'test_tool',
|
||||
testErrorMessage
|
||||
);
|
||||
console.log(` Original message: "${testErrorMessage}"`);
|
||||
|
||||
// Track an error WITHOUT a message
|
||||
console.log('\n3. Tracking error WITHOUT message:');
|
||||
telemetry.trackError(
|
||||
'Error',
|
||||
'tool_execution',
|
||||
'test_tool2'
|
||||
);
|
||||
|
||||
// Check the event queue
|
||||
const metrics = telemetry.getMetrics();
|
||||
console.log('\n4. Telemetry metrics:');
|
||||
console.log(' Status:', metrics.status);
|
||||
console.log(' Events queued:', metrics.tracking.eventsQueued);
|
||||
|
||||
// Get raw event queue to inspect
|
||||
const eventTracker = (telemetry as any).eventTracker;
|
||||
const queue = eventTracker.getEventQueue();
|
||||
|
||||
console.log('\n5. Event queue contents:');
|
||||
queue.forEach((event, i) => {
|
||||
console.log(`\n Event ${i + 1}:`);
|
||||
console.log(` - Type: ${event.event}`);
|
||||
console.log(` - Properties:`, JSON.stringify(event.properties, null, 6));
|
||||
});
|
||||
|
||||
// Flush to database
|
||||
console.log('\n6. Flushing to database...');
|
||||
await telemetry.flush();
|
||||
|
||||
console.log('\n7. Done! Check Supabase for error events with "error" field.');
|
||||
console.log(' Query: SELECT * FROM telemetry_events WHERE event = \'error_occurred\' ORDER BY created_at DESC LIMIT 5;');
|
||||
}
|
||||
|
||||
testErrorTracking().catch(console.error);
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Test script for error output validation improvements
|
||||
* Tests both incorrect and correct error output configurations
|
||||
*/
|
||||
|
||||
import { WorkflowValidator } from '../dist/services/workflow-validator.js';
|
||||
import { NodeRepository } from '../dist/database/node-repository.js';
|
||||
import { EnhancedConfigValidator } from '../dist/services/enhanced-config-validator.js';
|
||||
import { DatabaseAdapter } from '../dist/database/database-adapter.js';
|
||||
import { Logger } from '../dist/utils/logger.js';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const logger = new Logger({ prefix: '[TestErrorValidation]' });
|
||||
|
||||
async function runTests() {
|
||||
// Initialize database
|
||||
const dbPath = path.join(__dirname, '..', 'data', 'n8n-nodes.db');
|
||||
const adapter = new DatabaseAdapter();
|
||||
adapter.initialize({
|
||||
type: 'better-sqlite3',
|
||||
filename: dbPath
|
||||
});
|
||||
const db = adapter.getDatabase();
|
||||
|
||||
const nodeRepository = new NodeRepository(db);
|
||||
const validator = new WorkflowValidator(nodeRepository, EnhancedConfigValidator);
|
||||
|
||||
console.log('\n🧪 Testing Error Output Validation Improvements\n');
|
||||
console.log('=' .repeat(60));
|
||||
|
||||
// Test 1: Incorrect configuration - multiple nodes in same array
|
||||
console.log('\n📝 Test 1: INCORRECT - Multiple nodes in main[0]');
|
||||
console.log('-'.repeat(40));
|
||||
|
||||
const incorrectWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '132ef0dc-87af-41de-a95d-cabe3a0a5342',
|
||||
name: 'Validate Input',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [-400, 64] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '5dedf217-63f9-409f-b34e-7780b22e199a',
|
||||
name: 'Filter URLs',
|
||||
type: 'n8n-nodes-base.filter',
|
||||
typeVersion: 2.2,
|
||||
position: [-176, 64] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '9d5407cc-ca5a-4966-b4b7-0e5dfbf54ad3',
|
||||
name: 'Error Response1',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.5,
|
||||
position: [-160, 240] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Validate Input': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Filter URLs', type: 'main', index: 0 },
|
||||
{ node: 'Error Response1', type: 'main', index: 0 } // WRONG!
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result1 = await validator.validateWorkflow(incorrectWorkflow);
|
||||
|
||||
if (result1.errors.length > 0) {
|
||||
console.log('❌ ERROR DETECTED (as expected):');
|
||||
const errorMessage = result1.errors.find(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
);
|
||||
if (errorMessage) {
|
||||
console.log('\n' + errorMessage.message);
|
||||
}
|
||||
} else {
|
||||
console.log('✅ No errors found (but should have detected the issue!)');
|
||||
}
|
||||
|
||||
// Test 2: Correct configuration - separate arrays
|
||||
console.log('\n📝 Test 2: CORRECT - Separate main[0] and main[1]');
|
||||
console.log('-'.repeat(40));
|
||||
|
||||
const correctWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '132ef0dc-87af-41de-a95d-cabe3a0a5342',
|
||||
name: 'Validate Input',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [-400, 64] as [number, number],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput' as const
|
||||
},
|
||||
{
|
||||
id: '5dedf217-63f9-409f-b34e-7780b22e199a',
|
||||
name: 'Filter URLs',
|
||||
type: 'n8n-nodes-base.filter',
|
||||
typeVersion: 2.2,
|
||||
position: [-176, 64] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '9d5407cc-ca5a-4966-b4b7-0e5dfbf54ad3',
|
||||
name: 'Error Response1',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.5,
|
||||
position: [-160, 240] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Validate Input': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Filter URLs', type: 'main', index: 0 }
|
||||
],
|
||||
[
|
||||
{ node: 'Error Response1', type: 'main', index: 0 } // CORRECT!
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result2 = await validator.validateWorkflow(correctWorkflow);
|
||||
|
||||
const hasIncorrectError = result2.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
);
|
||||
|
||||
if (!hasIncorrectError) {
|
||||
console.log('✅ No error output configuration issues (correct!)');
|
||||
} else {
|
||||
console.log('❌ Unexpected error found');
|
||||
}
|
||||
|
||||
// Test 3: onError without error connections
|
||||
console.log('\n📝 Test 3: onError without error connections');
|
||||
console.log('-'.repeat(40));
|
||||
|
||||
const mismatchWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4,
|
||||
position: [100, 100] as [number, number],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput' as const
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Process Data',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 2,
|
||||
position: [300, 100] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'HTTP Request': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Process Data', type: 'main', index: 0 }
|
||||
]
|
||||
// No main[1] for error output
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result3 = await validator.validateWorkflow(mismatchWorkflow);
|
||||
|
||||
const mismatchError = result3.errors.find(e =>
|
||||
e.message.includes("has onError: 'continueErrorOutput' but no error output connections")
|
||||
);
|
||||
|
||||
if (mismatchError) {
|
||||
console.log('❌ ERROR DETECTED (as expected):');
|
||||
console.log(`Node: ${mismatchError.nodeName}`);
|
||||
console.log(`Message: ${mismatchError.message}`);
|
||||
} else {
|
||||
console.log('✅ No mismatch detected (but should have!)');
|
||||
}
|
||||
|
||||
// Test 4: Error connections without onError
|
||||
console.log('\n📝 Test 4: Error connections without onError property');
|
||||
console.log('-'.repeat(40));
|
||||
|
||||
const missingOnErrorWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4,
|
||||
position: [100, 100] as [number, number],
|
||||
parameters: {}
|
||||
// Missing onError property
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Process Data',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 100] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Error Handler',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [300, 300] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'HTTP Request': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Process Data', type: 'main', index: 0 }
|
||||
],
|
||||
[
|
||||
{ node: 'Error Handler', type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result4 = await validator.validateWorkflow(missingOnErrorWorkflow);
|
||||
|
||||
const missingOnErrorWarning = result4.warnings.find(w =>
|
||||
w.message.includes('error output connections in main[1] but missing onError')
|
||||
);
|
||||
|
||||
if (missingOnErrorWarning) {
|
||||
console.log('⚠️ WARNING DETECTED (as expected):');
|
||||
console.log(`Node: ${missingOnErrorWarning.nodeName}`);
|
||||
console.log(`Message: ${missingOnErrorWarning.message}`);
|
||||
} else {
|
||||
console.log('✅ No warning (but should have warned!)');
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('\n📊 Summary:');
|
||||
console.log('- Error output validation is working correctly');
|
||||
console.log('- Detects incorrect configurations (multiple nodes in main[0])');
|
||||
console.log('- Validates onError property matches connections');
|
||||
console.log('- Provides clear error messages with fix examples');
|
||||
|
||||
// Close database
|
||||
adapter.close();
|
||||
}
|
||||
|
||||
runTests().catch(error => {
|
||||
console.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script for error output validation improvements
|
||||
*/
|
||||
|
||||
const { WorkflowValidator } = require('../dist/services/workflow-validator.js');
|
||||
const { NodeRepository } = require('../dist/database/node-repository.js');
|
||||
const { EnhancedConfigValidator } = require('../dist/services/enhanced-config-validator.js');
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
async function runTests() {
|
||||
// Initialize database
|
||||
const dbPath = path.join(__dirname, '..', 'data', 'nodes.db');
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
const nodeRepository = new NodeRepository(db);
|
||||
const validator = new WorkflowValidator(nodeRepository, EnhancedConfigValidator);
|
||||
|
||||
console.log('\n🧪 Testing Error Output Validation Improvements\n');
|
||||
console.log('=' .repeat(60));
|
||||
|
||||
// Test 1: Incorrect configuration - multiple nodes in same array
|
||||
console.log('\n📝 Test 1: INCORRECT - Multiple nodes in main[0]');
|
||||
console.log('-'.repeat(40));
|
||||
|
||||
const incorrectWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '132ef0dc-87af-41de-a95d-cabe3a0a5342',
|
||||
name: 'Validate Input',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [-400, 64],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '5dedf217-63f9-409f-b34e-7780b22e199a',
|
||||
name: 'Filter URLs',
|
||||
type: 'n8n-nodes-base.filter',
|
||||
typeVersion: 2.2,
|
||||
position: [-176, 64],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '9d5407cc-ca5a-4966-b4b7-0e5dfbf54ad3',
|
||||
name: 'Error Response1',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.5,
|
||||
position: [-160, 240],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Validate Input': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Filter URLs', type: 'main', index: 0 },
|
||||
{ node: 'Error Response1', type: 'main', index: 0 } // WRONG!
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result1 = await validator.validateWorkflow(incorrectWorkflow);
|
||||
|
||||
if (result1.errors.length > 0) {
|
||||
console.log('❌ ERROR DETECTED (as expected):');
|
||||
const errorMessage = result1.errors.find(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
);
|
||||
if (errorMessage) {
|
||||
console.log('\nError Summary:');
|
||||
console.log(`Node: ${errorMessage.nodeName || 'Validate Input'}`);
|
||||
console.log('\nFull Error Message:');
|
||||
console.log(errorMessage.message);
|
||||
} else {
|
||||
console.log('Other errors found:', result1.errors.map(e => e.message));
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ No errors found - validation may not be working correctly');
|
||||
}
|
||||
|
||||
// Test 2: Correct configuration - separate arrays
|
||||
console.log('\n📝 Test 2: CORRECT - Separate main[0] and main[1]');
|
||||
console.log('-'.repeat(40));
|
||||
|
||||
const correctWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '132ef0dc-87af-41de-a95d-cabe3a0a5342',
|
||||
name: 'Validate Input',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [-400, 64],
|
||||
parameters: {},
|
||||
onError: 'continueErrorOutput'
|
||||
},
|
||||
{
|
||||
id: '5dedf217-63f9-409f-b34e-7780b22e199a',
|
||||
name: 'Filter URLs',
|
||||
type: 'n8n-nodes-base.filter',
|
||||
typeVersion: 2.2,
|
||||
position: [-176, 64],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '9d5407cc-ca5a-4966-b4b7-0e5dfbf54ad3',
|
||||
name: 'Error Response1',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.5,
|
||||
position: [-160, 240],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Validate Input': {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Filter URLs', type: 'main', index: 0 }
|
||||
],
|
||||
[
|
||||
{ node: 'Error Response1', type: 'main', index: 0 } // CORRECT!
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result2 = await validator.validateWorkflow(correctWorkflow);
|
||||
|
||||
const hasIncorrectError = result2.errors.some(e =>
|
||||
e.message.includes('Incorrect error output configuration')
|
||||
);
|
||||
|
||||
if (!hasIncorrectError) {
|
||||
console.log('✅ No error output configuration issues (correct!)');
|
||||
} else {
|
||||
console.log('❌ Unexpected error found');
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('\n✨ Error output validation is working correctly!');
|
||||
console.log('The validator now properly detects:');
|
||||
console.log(' 1. Multiple nodes incorrectly placed in main[0]');
|
||||
console.log(' 2. Provides clear JSON examples for fixing issues');
|
||||
console.log(' 3. Validates onError property matches connections');
|
||||
|
||||
// Close database
|
||||
db.close();
|
||||
}
|
||||
|
||||
runTests().catch(error => {
|
||||
console.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+282
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env ts-node
|
||||
/**
|
||||
* Test script for validating the get_node_essentials tool
|
||||
*
|
||||
* This script:
|
||||
* 1. Compares get_node_essentials vs get_node_info response sizes
|
||||
* 2. Validates that essential properties are correctly extracted
|
||||
* 3. Checks that examples are properly generated
|
||||
* 4. Tests the property search functionality
|
||||
*/
|
||||
|
||||
import { N8NDocumentationMCPServer } from '../src/mcp/server';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
// Color codes for terminal output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
green: '\x1b[32m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m'
|
||||
};
|
||||
|
||||
function log(message: string, color: string = colors.reset) {
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function logSection(title: string) {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
log(title, colors.bright + colors.cyan);
|
||||
console.log('='.repeat(60));
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return kb.toFixed(1) + ' KB';
|
||||
const mb = kb / 1024;
|
||||
return mb.toFixed(2) + ' MB';
|
||||
}
|
||||
|
||||
async function testNodeEssentials(server: N8NDocumentationMCPServer, nodeType: string) {
|
||||
logSection(`Testing ${nodeType}`);
|
||||
|
||||
try {
|
||||
// Get full node info
|
||||
const startFull = Date.now();
|
||||
const fullInfo = await server.executeTool('get_node_info', { nodeType });
|
||||
const fullTime = Date.now() - startFull;
|
||||
const fullSize = JSON.stringify(fullInfo).length;
|
||||
|
||||
// Get essential info
|
||||
const startEssential = Date.now();
|
||||
const essentialInfo = await server.executeTool('get_node_essentials', { nodeType });
|
||||
const essentialTime = Date.now() - startEssential;
|
||||
const essentialSize = JSON.stringify(essentialInfo).length;
|
||||
|
||||
// Calculate metrics
|
||||
const sizeReduction = ((fullSize - essentialSize) / fullSize * 100).toFixed(1);
|
||||
const speedImprovement = ((fullTime - essentialTime) / fullTime * 100).toFixed(1);
|
||||
|
||||
// Display results
|
||||
log(`\n📊 Size Comparison:`, colors.bright);
|
||||
log(` Full response: ${formatBytes(fullSize)}`, colors.yellow);
|
||||
log(` Essential response: ${formatBytes(essentialSize)}`, colors.green);
|
||||
log(` Size reduction: ${sizeReduction}% ✨`, colors.bright + colors.green);
|
||||
|
||||
log(`\n⚡ Performance:`, colors.bright);
|
||||
log(` Full response time: ${fullTime}ms`);
|
||||
log(` Essential response time: ${essentialTime}ms`);
|
||||
log(` Speed improvement: ${speedImprovement}%`, colors.green);
|
||||
|
||||
log(`\n📋 Property Count:`, colors.bright);
|
||||
const fullPropCount = fullInfo.properties?.length || 0;
|
||||
const essentialPropCount = (essentialInfo.requiredProperties?.length || 0) +
|
||||
(essentialInfo.commonProperties?.length || 0);
|
||||
log(` Full properties: ${fullPropCount}`);
|
||||
log(` Essential properties: ${essentialPropCount}`);
|
||||
log(` Properties removed: ${fullPropCount - essentialPropCount} (${((fullPropCount - essentialPropCount) / fullPropCount * 100).toFixed(1)}%)`, colors.green);
|
||||
|
||||
log(`\n🔧 Essential Properties:`, colors.bright);
|
||||
log(` Required: ${essentialInfo.requiredProperties?.map((p: any) => p.name).join(', ') || 'None'}`);
|
||||
log(` Common: ${essentialInfo.commonProperties?.map((p: any) => p.name).join(', ') || 'None'}`);
|
||||
|
||||
log(`\n📚 Examples:`, colors.bright);
|
||||
const examples = Object.keys(essentialInfo.examples || {});
|
||||
log(` Available examples: ${examples.join(', ') || 'None'}`);
|
||||
|
||||
if (essentialInfo.examples?.minimal) {
|
||||
log(` Minimal example properties: ${Object.keys(essentialInfo.examples.minimal).join(', ')}`);
|
||||
}
|
||||
|
||||
log(`\n📊 Metadata:`, colors.bright);
|
||||
log(` Total properties available: ${essentialInfo.metadata?.totalProperties || 0}`);
|
||||
log(` Is AI Tool: ${essentialInfo.metadata?.isAITool ? 'Yes' : 'No'}`);
|
||||
log(` Is Trigger: ${essentialInfo.metadata?.isTrigger ? 'Yes' : 'No'}`);
|
||||
log(` Has Credentials: ${essentialInfo.metadata?.hasCredentials ? 'Yes' : 'No'}`);
|
||||
|
||||
// Test property search
|
||||
const searchTerms = ['auth', 'header', 'body', 'json'];
|
||||
log(`\n🔍 Property Search Test:`, colors.bright);
|
||||
|
||||
for (const term of searchTerms) {
|
||||
try {
|
||||
const searchResult = await server.executeTool('search_node_properties', {
|
||||
nodeType,
|
||||
query: term,
|
||||
maxResults: 5
|
||||
});
|
||||
log(` "${term}": Found ${searchResult.totalMatches} properties`);
|
||||
} catch (error) {
|
||||
log(` "${term}": Search failed`, colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeType,
|
||||
fullSize,
|
||||
essentialSize,
|
||||
sizeReduction: parseFloat(sizeReduction),
|
||||
fullPropCount,
|
||||
essentialPropCount,
|
||||
success: true
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ Error testing ${nodeType}: ${error}`, colors.red);
|
||||
return {
|
||||
nodeType,
|
||||
fullSize: 0,
|
||||
essentialSize: 0,
|
||||
sizeReduction: 0,
|
||||
fullPropCount: 0,
|
||||
essentialPropCount: 0,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
logSection('n8n MCP Essentials Tool Test Suite');
|
||||
|
||||
try {
|
||||
// Initialize server
|
||||
log('\n🚀 Initializing MCP server...', colors.cyan);
|
||||
const server = new N8NDocumentationMCPServer();
|
||||
|
||||
// Wait for initialization
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Test nodes
|
||||
const testNodes = [
|
||||
'nodes-base.httpRequest',
|
||||
'nodes-base.webhook',
|
||||
'nodes-base.code',
|
||||
'nodes-base.set',
|
||||
'nodes-base.if',
|
||||
'nodes-base.postgres',
|
||||
'nodes-base.openAi',
|
||||
'nodes-base.googleSheets',
|
||||
'nodes-base.slack',
|
||||
'nodes-base.merge'
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const nodeType of testNodes) {
|
||||
const result = await testNodeEssentials(server, nodeType);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Summary
|
||||
logSection('Test Summary');
|
||||
|
||||
const successful = results.filter(r => r.success);
|
||||
const totalFullSize = successful.reduce((sum, r) => sum + r.fullSize, 0);
|
||||
const totalEssentialSize = successful.reduce((sum, r) => sum + r.essentialSize, 0);
|
||||
const avgReduction = successful.reduce((sum, r) => sum + r.sizeReduction, 0) / successful.length;
|
||||
|
||||
log(`\n✅ Successful tests: ${successful.length}/${results.length}`, colors.green);
|
||||
|
||||
if (successful.length > 0) {
|
||||
log(`\n📊 Overall Statistics:`, colors.bright);
|
||||
log(` Total full size: ${formatBytes(totalFullSize)}`);
|
||||
log(` Total essential size: ${formatBytes(totalEssentialSize)}`);
|
||||
log(` Average reduction: ${avgReduction.toFixed(1)}%`, colors.bright + colors.green);
|
||||
|
||||
log(`\n🏆 Best Performers:`, colors.bright);
|
||||
const sorted = successful.sort((a, b) => b.sizeReduction - a.sizeReduction);
|
||||
sorted.slice(0, 3).forEach((r, i) => {
|
||||
log(` ${i + 1}. ${r.nodeType}: ${r.sizeReduction}% reduction (${formatBytes(r.fullSize)} → ${formatBytes(r.essentialSize)})`);
|
||||
});
|
||||
}
|
||||
|
||||
const failed = results.filter(r => !r.success);
|
||||
if (failed.length > 0) {
|
||||
log(`\n❌ Failed tests:`, colors.red);
|
||||
failed.forEach(r => {
|
||||
log(` - ${r.nodeType}: ${r.error}`, colors.red);
|
||||
});
|
||||
}
|
||||
|
||||
// Save detailed results
|
||||
const reportPath = join(process.cwd(), 'test-results-essentials.json');
|
||||
writeFileSync(reportPath, JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
summary: {
|
||||
totalTests: results.length,
|
||||
successful: successful.length,
|
||||
failed: failed.length,
|
||||
averageReduction: avgReduction,
|
||||
totalFullSize,
|
||||
totalEssentialSize
|
||||
},
|
||||
results
|
||||
}, null, 2));
|
||||
|
||||
log(`\n📄 Detailed results saved to: ${reportPath}`, colors.cyan);
|
||||
|
||||
// Recommendations
|
||||
logSection('Recommendations');
|
||||
|
||||
if (avgReduction > 90) {
|
||||
log('✨ Excellent! The essentials tool is achieving >90% size reduction.', colors.green);
|
||||
} else if (avgReduction > 80) {
|
||||
log('👍 Good! The essentials tool is achieving 80-90% size reduction.', colors.yellow);
|
||||
log(' Consider reviewing nodes with lower reduction rates.');
|
||||
} else {
|
||||
log('⚠️ The average size reduction is below 80%.', colors.yellow);
|
||||
log(' Review the essential property lists for optimization.');
|
||||
}
|
||||
|
||||
// Test specific functionality
|
||||
logSection('Testing Advanced Features');
|
||||
|
||||
// Test error handling
|
||||
log('\n🧪 Testing error handling...', colors.cyan);
|
||||
try {
|
||||
await server.executeTool('get_node_essentials', { nodeType: 'non-existent-node' });
|
||||
log(' ❌ Error handling failed - should have thrown error', colors.red);
|
||||
} catch (error) {
|
||||
log(' ✅ Error handling works correctly', colors.green);
|
||||
}
|
||||
|
||||
// Test alternative node type formats
|
||||
log('\n🧪 Testing alternative node type formats...', colors.cyan);
|
||||
const alternativeFormats = [
|
||||
{ input: 'httpRequest', expected: 'nodes-base.httpRequest' },
|
||||
{ input: 'nodes-base.httpRequest', expected: 'nodes-base.httpRequest' },
|
||||
{ input: 'HTTPREQUEST', expected: 'nodes-base.httpRequest' }
|
||||
];
|
||||
|
||||
for (const format of alternativeFormats) {
|
||||
try {
|
||||
const result = await server.executeTool('get_node_essentials', { nodeType: format.input });
|
||||
if (result.nodeType === format.expected) {
|
||||
log(` ✅ "${format.input}" → "${format.expected}"`, colors.green);
|
||||
} else {
|
||||
log(` ❌ "${format.input}" → "${result.nodeType}" (expected "${format.expected}")`, colors.red);
|
||||
}
|
||||
} catch (error) {
|
||||
log(` ❌ "${format.input}" → Error: ${error}`, colors.red);
|
||||
}
|
||||
}
|
||||
|
||||
log('\n✨ Test suite completed!', colors.bright + colors.green);
|
||||
|
||||
} catch (error) {
|
||||
log(`\n❌ Fatal error: ${error}`, colors.red);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
main().catch(error => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
/**
|
||||
* Test script for Expression vs Code Node validation
|
||||
* Tests that we properly detect and warn about expression syntax in Code nodes
|
||||
*/
|
||||
|
||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator.js';
|
||||
|
||||
console.log('🧪 Testing Expression vs Code Node Validation\n');
|
||||
|
||||
// Test cases with expression syntax that shouldn't work in Code nodes
|
||||
const testCases = [
|
||||
{
|
||||
name: 'Expression syntax in Code node',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `// Using expression syntax
|
||||
const value = {{$json.field}};
|
||||
return [{json: {value}}];`
|
||||
},
|
||||
expectedError: 'Expression syntax {{...}} is not valid in Code nodes'
|
||||
},
|
||||
{
|
||||
name: 'Wrong $node syntax',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `// Using expression $node syntax
|
||||
const data = $node['Previous Node'].json;
|
||||
return [{json: data}];`
|
||||
},
|
||||
expectedWarning: 'Use $(\'Node Name\') instead of $node[\'Node Name\'] in Code nodes'
|
||||
},
|
||||
{
|
||||
name: 'Expression-only functions',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `// Using expression functions
|
||||
const now = $now();
|
||||
const unique = items.unique();
|
||||
return [{json: {now, unique}}];`
|
||||
},
|
||||
expectedWarning: '$now() is an expression-only function'
|
||||
},
|
||||
{
|
||||
name: 'Wrong JMESPath parameter order',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `// Wrong parameter order
|
||||
const result = $jmespath("users[*].name", data);
|
||||
return [{json: {result}}];`
|
||||
},
|
||||
expectedWarning: 'Code node $jmespath has reversed parameter order'
|
||||
},
|
||||
{
|
||||
name: 'Correct Code node syntax',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `// Correct syntax
|
||||
const prevData = $('Previous Node').first();
|
||||
const now = DateTime.now();
|
||||
const result = $jmespath(data, "users[*].name");
|
||||
return [{json: {prevData, now, result}}];`
|
||||
},
|
||||
shouldBeValid: true
|
||||
}
|
||||
];
|
||||
|
||||
// Basic node properties for Code node
|
||||
const codeNodeProperties = [
|
||||
{ name: 'language', type: 'options', options: ['javaScript', 'python'] },
|
||||
{ name: 'jsCode', type: 'string' },
|
||||
{ name: 'pythonCode', type: 'string' },
|
||||
{ name: 'mode', type: 'options', options: ['runOnceForAllItems', 'runOnceForEachItem'] }
|
||||
];
|
||||
|
||||
console.log('Running validation tests...\n');
|
||||
|
||||
testCases.forEach((test, index) => {
|
||||
console.log(`Test ${index + 1}: ${test.name}`);
|
||||
console.log('─'.repeat(50));
|
||||
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
'nodes-base.code',
|
||||
test.config,
|
||||
codeNodeProperties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
console.log(`Valid: ${result.valid}`);
|
||||
console.log(`Errors: ${result.errors.length}`);
|
||||
console.log(`Warnings: ${result.warnings.length}`);
|
||||
|
||||
if (test.expectedError) {
|
||||
const hasExpectedError = result.errors.some(e =>
|
||||
e.message.includes(test.expectedError)
|
||||
);
|
||||
console.log(`✅ Expected error found: ${hasExpectedError}`);
|
||||
if (!hasExpectedError) {
|
||||
console.log('❌ Missing expected error:', test.expectedError);
|
||||
console.log('Actual errors:', result.errors.map(e => e.message));
|
||||
}
|
||||
}
|
||||
|
||||
if (test.expectedWarning) {
|
||||
const hasExpectedWarning = result.warnings.some(w =>
|
||||
w.message.includes(test.expectedWarning)
|
||||
);
|
||||
console.log(`✅ Expected warning found: ${hasExpectedWarning}`);
|
||||
if (!hasExpectedWarning) {
|
||||
console.log('❌ Missing expected warning:', test.expectedWarning);
|
||||
console.log('Actual warnings:', result.warnings.map(w => w.message));
|
||||
}
|
||||
}
|
||||
|
||||
if (test.shouldBeValid) {
|
||||
console.log(`✅ Should be valid: ${result.valid && result.errors.length === 0}`);
|
||||
if (!result.valid || result.errors.length > 0) {
|
||||
console.log('❌ Unexpected errors:', result.errors);
|
||||
}
|
||||
}
|
||||
|
||||
// Show actual messages
|
||||
if (result.errors.length > 0) {
|
||||
console.log('\nErrors:');
|
||||
result.errors.forEach(e => console.log(` - ${e.message}`));
|
||||
}
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log('\nWarnings:');
|
||||
result.warnings.forEach(w => console.log(` - ${w.message}`));
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
});
|
||||
|
||||
console.log('✅ Expression vs Code Node validation tests completed!');
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script for expression format validation
|
||||
* Tests the validation of expression prefixes and resource locator formats
|
||||
*/
|
||||
|
||||
const { WorkflowValidator } = require('../dist/services/workflow-validator.js');
|
||||
const { NodeRepository } = require('../dist/database/node-repository.js');
|
||||
const { EnhancedConfigValidator } = require('../dist/services/enhanced-config-validator.js');
|
||||
const { createDatabaseAdapter } = require('../dist/database/database-adapter.js');
|
||||
const path = require('path');
|
||||
|
||||
async function runTests() {
|
||||
// Initialize database
|
||||
const dbPath = path.join(__dirname, '..', 'data', 'nodes.db');
|
||||
const adapter = await createDatabaseAdapter(dbPath);
|
||||
const db = adapter;
|
||||
|
||||
const nodeRepository = new NodeRepository(db);
|
||||
const validator = new WorkflowValidator(nodeRepository, EnhancedConfigValidator);
|
||||
|
||||
console.log('\n🧪 Testing Expression Format Validation\n');
|
||||
console.log('=' .repeat(60));
|
||||
|
||||
// Test 1: Email node with missing = prefix
|
||||
console.log('\n📝 Test 1: Email Send node - Missing = prefix');
|
||||
console.log('-'.repeat(40));
|
||||
|
||||
const emailWorkflowIncorrect = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0',
|
||||
name: 'Error Handler',
|
||||
type: 'n8n-nodes-base.emailSend',
|
||||
typeVersion: 2.1,
|
||||
position: [-128, 400],
|
||||
parameters: {
|
||||
fromEmail: '{{ $env.ADMIN_EMAIL }}', // INCORRECT - missing =
|
||||
toEmail: 'admin@company.com',
|
||||
subject: 'GitHub Issue Workflow Error - HIGH PRIORITY',
|
||||
options: {}
|
||||
},
|
||||
credentials: {
|
||||
smtp: {
|
||||
id: '7AQ08VMFHubmfvzR',
|
||||
name: 'romuald@aiadvisors.pl'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result1 = await validator.validateWorkflow(emailWorkflowIncorrect);
|
||||
|
||||
if (result1.errors.some(e => e.message.includes('Expression format'))) {
|
||||
console.log('✅ ERROR DETECTED (correct behavior):');
|
||||
const formatError = result1.errors.find(e => e.message.includes('Expression format'));
|
||||
console.log('\n' + formatError.message);
|
||||
} else {
|
||||
console.log('❌ No expression format error detected (should have detected missing prefix)');
|
||||
}
|
||||
|
||||
// Test 2: Email node with correct = prefix
|
||||
console.log('\n📝 Test 2: Email Send node - Correct = prefix');
|
||||
console.log('-'.repeat(40));
|
||||
|
||||
const emailWorkflowCorrect = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0',
|
||||
name: 'Error Handler',
|
||||
type: 'n8n-nodes-base.emailSend',
|
||||
typeVersion: 2.1,
|
||||
position: [-128, 400],
|
||||
parameters: {
|
||||
fromEmail: '={{ $env.ADMIN_EMAIL }}', // CORRECT - has =
|
||||
toEmail: 'admin@company.com',
|
||||
subject: 'GitHub Issue Workflow Error - HIGH PRIORITY',
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result2 = await validator.validateWorkflow(emailWorkflowCorrect);
|
||||
|
||||
if (result2.errors.some(e => e.message.includes('Expression format'))) {
|
||||
console.log('❌ Unexpected expression format error (should accept = prefix)');
|
||||
} else {
|
||||
console.log('✅ No expression format errors (correct!)');
|
||||
}
|
||||
|
||||
// Test 3: GitHub node without resource locator format
|
||||
console.log('\n📝 Test 3: GitHub node - Missing resource locator format');
|
||||
console.log('-'.repeat(40));
|
||||
|
||||
const githubWorkflowIncorrect = {
|
||||
nodes: [
|
||||
{
|
||||
id: '3c742ca1-af8f-4d80-a47e-e68fb1ced491',
|
||||
name: 'Send Welcome Comment',
|
||||
type: 'n8n-nodes-base.github',
|
||||
typeVersion: 1.1,
|
||||
position: [-240, 96],
|
||||
parameters: {
|
||||
operation: 'createComment',
|
||||
owner: '{{ $vars.GITHUB_OWNER }}', // INCORRECT - needs RL format
|
||||
repository: '{{ $vars.GITHUB_REPO }}', // INCORRECT - needs RL format
|
||||
issueNumber: null,
|
||||
body: '👋 Hi @{{ $(\'Extract Issue Data\').first().json.author }}!' // INCORRECT - missing =
|
||||
},
|
||||
credentials: {
|
||||
githubApi: {
|
||||
id: 'edgpwh6ldYN07MXx',
|
||||
name: 'GitHub account'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result3 = await validator.validateWorkflow(githubWorkflowIncorrect);
|
||||
|
||||
const formatErrors = result3.errors.filter(e => e.message.includes('Expression format'));
|
||||
console.log(`\nFound ${formatErrors.length} expression format errors:`);
|
||||
|
||||
if (formatErrors.length >= 3) {
|
||||
console.log('✅ All format issues detected:');
|
||||
formatErrors.forEach((error, index) => {
|
||||
const field = error.message.match(/Field '([^']+)'/)?.[1] || 'unknown';
|
||||
console.log(` ${index + 1}. Field '${field}' - ${error.message.includes('resource locator') ? 'Needs RL format' : 'Missing = prefix'}`);
|
||||
});
|
||||
} else {
|
||||
console.log('❌ Not all format issues detected');
|
||||
}
|
||||
|
||||
// Test 4: GitHub node with correct resource locator format
|
||||
console.log('\n📝 Test 4: GitHub node - Correct resource locator format');
|
||||
console.log('-'.repeat(40));
|
||||
|
||||
const githubWorkflowCorrect = {
|
||||
nodes: [
|
||||
{
|
||||
id: '3c742ca1-af8f-4d80-a47e-e68fb1ced491',
|
||||
name: 'Send Welcome Comment',
|
||||
type: 'n8n-nodes-base.github',
|
||||
typeVersion: 1.1,
|
||||
position: [-240, 96],
|
||||
parameters: {
|
||||
operation: 'createComment',
|
||||
owner: {
|
||||
__rl: true,
|
||||
value: '={{ $vars.GITHUB_OWNER }}', // CORRECT - RL format with =
|
||||
mode: 'expression'
|
||||
},
|
||||
repository: {
|
||||
__rl: true,
|
||||
value: '={{ $vars.GITHUB_REPO }}', // CORRECT - RL format with =
|
||||
mode: 'expression'
|
||||
},
|
||||
issueNumber: 123,
|
||||
body: '=👋 Hi @{{ $(\'Extract Issue Data\').first().json.author }}!' // CORRECT - has =
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result4 = await validator.validateWorkflow(githubWorkflowCorrect);
|
||||
|
||||
const formatErrors4 = result4.errors.filter(e => e.message.includes('Expression format'));
|
||||
if (formatErrors4.length === 0) {
|
||||
console.log('✅ No expression format errors (correct!)');
|
||||
} else {
|
||||
console.log(`❌ Unexpected expression format errors: ${formatErrors4.length}`);
|
||||
formatErrors4.forEach(e => console.log(' - ' + e.message.split('\n')[0]));
|
||||
}
|
||||
|
||||
// Test 5: Mixed content expressions
|
||||
console.log('\n📝 Test 5: Mixed content with expressions');
|
||||
console.log('-'.repeat(40));
|
||||
|
||||
const mixedContentWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
url: 'https://api.example.com/users/{{ $json.userId }}', // INCORRECT
|
||||
headers: {
|
||||
'Authorization': '=Bearer {{ $env.API_TOKEN }}' // CORRECT
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result5 = await validator.validateWorkflow(mixedContentWorkflow);
|
||||
|
||||
const urlError = result5.errors.find(e => e.message.includes('url') && e.message.includes('Expression format'));
|
||||
if (urlError) {
|
||||
console.log('✅ Mixed content error detected for URL field');
|
||||
console.log(' Should be: "=https://api.example.com/users/{{ $json.userId }}"');
|
||||
} else {
|
||||
console.log('❌ Mixed content error not detected');
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('\n✨ Expression Format Validation Summary:');
|
||||
console.log(' - Detects missing = prefix in expressions');
|
||||
console.log(' - Identifies fields needing resource locator format');
|
||||
console.log(' - Provides clear correction examples');
|
||||
console.log(' - Handles mixed literal and expression content');
|
||||
|
||||
// Close database
|
||||
db.close();
|
||||
}
|
||||
|
||||
runTests().catch(error => {
|
||||
console.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { N8NDocumentationMCPServer } from '../src/mcp/server';
|
||||
|
||||
interface SearchTest {
|
||||
query: string;
|
||||
mode?: 'OR' | 'AND' | 'FUZZY';
|
||||
description: string;
|
||||
expectedTop?: string[];
|
||||
}
|
||||
|
||||
async function testFTS5Search() {
|
||||
console.log('Testing FTS5 Search Implementation\n');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
const server = new N8NDocumentationMCPServer();
|
||||
|
||||
// Wait for initialization
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const tests: SearchTest[] = [
|
||||
{
|
||||
query: 'webhook',
|
||||
description: 'Basic search - should return Webhook node first',
|
||||
expectedTop: ['nodes-base.webhook']
|
||||
},
|
||||
{
|
||||
query: 'http call',
|
||||
description: 'Multi-word OR search - should return HTTP Request node first',
|
||||
expectedTop: ['nodes-base.httpRequest']
|
||||
},
|
||||
{
|
||||
query: 'send message',
|
||||
mode: 'AND',
|
||||
description: 'AND mode - only nodes with both "send" AND "message"',
|
||||
},
|
||||
{
|
||||
query: 'slak',
|
||||
mode: 'FUZZY',
|
||||
description: 'FUZZY mode - should find Slack despite typo',
|
||||
expectedTop: ['nodes-base.slack']
|
||||
},
|
||||
{
|
||||
query: '"email trigger"',
|
||||
description: 'Exact phrase search with quotes',
|
||||
},
|
||||
{
|
||||
query: 'http',
|
||||
mode: 'FUZZY',
|
||||
description: 'FUZZY mode with common term',
|
||||
expectedTop: ['nodes-base.httpRequest']
|
||||
},
|
||||
{
|
||||
query: 'google sheets',
|
||||
mode: 'AND',
|
||||
description: 'AND mode - find Google Sheets node',
|
||||
expectedTop: ['nodes-base.googleSheets']
|
||||
},
|
||||
{
|
||||
query: 'webhook trigger',
|
||||
mode: 'OR',
|
||||
description: 'OR mode - should return nodes with either word',
|
||||
}
|
||||
];
|
||||
|
||||
let passedTests = 0;
|
||||
let failedTests = 0;
|
||||
|
||||
for (const test of tests) {
|
||||
console.log(`\n${test.description}`);
|
||||
console.log(`Query: "${test.query}" (Mode: ${test.mode || 'OR'})`);
|
||||
console.log('-'.repeat(40));
|
||||
|
||||
try {
|
||||
const results = await server.executeTool('search_nodes', {
|
||||
query: test.query,
|
||||
mode: test.mode,
|
||||
limit: 5
|
||||
});
|
||||
|
||||
if (!results.results || results.results.length === 0) {
|
||||
console.log('❌ No results found');
|
||||
if (test.expectedTop) {
|
||||
failedTests++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Found ${results.results.length} results:`);
|
||||
results.results.forEach((node: any, index: number) => {
|
||||
const marker = test.expectedTop && index === 0 && test.expectedTop.includes(node.nodeType) ? ' ✅' : '';
|
||||
console.log(` ${index + 1}. ${node.nodeType} - ${node.displayName}${marker}`);
|
||||
});
|
||||
|
||||
// Verify search mode is returned
|
||||
if (results.mode) {
|
||||
console.log(`\nSearch mode used: ${results.mode}`);
|
||||
}
|
||||
|
||||
// Check expected results
|
||||
if (test.expectedTop) {
|
||||
const firstResult = results.results[0];
|
||||
if (test.expectedTop.includes(firstResult.nodeType)) {
|
||||
console.log('✅ Test passed: Expected node found at top');
|
||||
passedTests++;
|
||||
} else {
|
||||
console.log('❌ Test failed: Expected node not at top');
|
||||
console.log(` Expected: ${test.expectedTop.join(' or ')}`);
|
||||
console.log(` Got: ${firstResult.nodeType}`);
|
||||
failedTests++;
|
||||
}
|
||||
} else {
|
||||
// Test without specific expectations
|
||||
console.log('✅ Search completed successfully');
|
||||
passedTests++;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(`❌ Error: ${error}`);
|
||||
failedTests++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('FTS5 Feature Tests');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
// Test FTS5-specific features
|
||||
console.log('\n1. Testing relevance ranking...');
|
||||
const webhookResult = await server.executeTool('search_nodes', {
|
||||
query: 'webhook',
|
||||
limit: 10
|
||||
});
|
||||
console.log(` Primary "Webhook" node position: #${webhookResult.results.findIndex((r: any) => r.nodeType === 'nodes-base.webhook') + 1}`);
|
||||
|
||||
console.log('\n2. Testing fuzzy matching with various typos...');
|
||||
const typoTests = ['webook', 'htpp', 'slck', 'googl sheet'];
|
||||
for (const typo of typoTests) {
|
||||
const result = await server.executeTool('search_nodes', {
|
||||
query: typo,
|
||||
mode: 'FUZZY',
|
||||
limit: 1
|
||||
});
|
||||
if (result.results.length > 0) {
|
||||
console.log(` "${typo}" → ${result.results[0].displayName} ✅`);
|
||||
} else {
|
||||
console.log(` "${typo}" → No results ❌`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log(`Test Summary: ${passedTests} passed, ${failedTests} failed`);
|
||||
console.log('='.repeat(50));
|
||||
|
||||
process.exit(failedTests > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
// Run tests
|
||||
testFTS5Search().catch(error => {
|
||||
console.error('Test execution failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { N8NDocumentationMCPServer } from '../src/mcp/server';
|
||||
|
||||
async function testFuzzyFix() {
|
||||
console.log('Testing FUZZY mode fix...\n');
|
||||
|
||||
const server = new N8NDocumentationMCPServer();
|
||||
|
||||
// Wait for initialization
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Test 1: FUZZY mode with typo
|
||||
console.log('Test 1: FUZZY mode with "slak" (typo for "slack")');
|
||||
const fuzzyResult = await server.executeTool('search_nodes', {
|
||||
query: 'slak',
|
||||
mode: 'FUZZY',
|
||||
limit: 5
|
||||
});
|
||||
|
||||
console.log(`Results: ${fuzzyResult.results.length} found`);
|
||||
if (fuzzyResult.results.length > 0) {
|
||||
console.log('✅ FUZZY mode now finds results!');
|
||||
fuzzyResult.results.forEach((node: any, i: number) => {
|
||||
console.log(` ${i + 1}. ${node.nodeType} - ${node.displayName}`);
|
||||
});
|
||||
} else {
|
||||
console.log('❌ FUZZY mode still not working');
|
||||
}
|
||||
|
||||
// Test 2: AND mode with explanation
|
||||
console.log('\n\nTest 2: AND mode with "send message"');
|
||||
const andResult = await server.executeTool('search_nodes', {
|
||||
query: 'send message',
|
||||
mode: 'AND',
|
||||
limit: 5
|
||||
});
|
||||
|
||||
console.log(`Results: ${andResult.results.length} found`);
|
||||
if (andResult.searchInfo) {
|
||||
console.log('✅ AND mode now includes search info:');
|
||||
console.log(` ${andResult.searchInfo.message}`);
|
||||
console.log(` Tip: ${andResult.searchInfo.tip}`);
|
||||
}
|
||||
|
||||
console.log('\nFirst 5 results:');
|
||||
andResult.results.slice(0, 5).forEach((node: any, i: number) => {
|
||||
console.log(` ${i + 1}. ${node.nodeType} - ${node.displayName}`);
|
||||
});
|
||||
|
||||
// Test 3: More typos
|
||||
console.log('\n\nTest 3: More FUZZY tests');
|
||||
const typos = ['htpp', 'webook', 'slck', 'emial'];
|
||||
|
||||
for (const typo of typos) {
|
||||
const result = await server.executeTool('search_nodes', {
|
||||
query: typo,
|
||||
mode: 'FUZZY',
|
||||
limit: 1
|
||||
});
|
||||
|
||||
if (result.results.length > 0) {
|
||||
console.log(`✅ "${typo}" → ${result.results[0].displayName}`);
|
||||
} else {
|
||||
console.log(`❌ "${typo}" → No results`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Run tests
|
||||
testFuzzyFix().catch(error => {
|
||||
console.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { N8NDocumentationMCPServer } from '../src/mcp/server';
|
||||
|
||||
async function testSimple() {
|
||||
const server = new N8NDocumentationMCPServer();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Just test one query
|
||||
const result = await server.executeTool('search_nodes', {
|
||||
query: 'slak',
|
||||
mode: 'FUZZY',
|
||||
limit: 5
|
||||
});
|
||||
|
||||
console.log('Query: "slak" (FUZZY mode)');
|
||||
console.log(`Results: ${result.results.length}`);
|
||||
|
||||
if (result.results.length === 0) {
|
||||
// Let's check with a lower threshold
|
||||
const serverAny = server as any;
|
||||
const slackNode = {
|
||||
node_type: 'nodes-base.slack',
|
||||
display_name: 'Slack',
|
||||
description: 'Consume Slack API'
|
||||
};
|
||||
const score = serverAny.calculateFuzzyScore(slackNode, 'slak');
|
||||
console.log(`\nSlack node score for "slak": ${score}`);
|
||||
console.log('Current threshold: 400');
|
||||
console.log('Should it match?', score >= 400 ? 'YES' : 'NO');
|
||||
} else {
|
||||
result.results.forEach((r: any, i: number) => {
|
||||
console.log(`${i + 1}. ${r.displayName}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
testSimple().catch(console.error);
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator.js';
|
||||
|
||||
console.log('🧪 Testing $helpers Validation\n');
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
name: 'Incorrect $helpers.getWorkflowStaticData',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `const data = $helpers.getWorkflowStaticData('global');
|
||||
data.counter = 1;
|
||||
return [{json: {counter: data.counter}}];`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Correct $getWorkflowStaticData',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `const data = $getWorkflowStaticData('global');
|
||||
data.counter = 1;
|
||||
return [{json: {counter: data.counter}}];`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '$helpers without check',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `const response = await $helpers.httpRequest({
|
||||
method: 'GET',
|
||||
url: 'https://api.example.com'
|
||||
});
|
||||
return [{json: response}];`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '$helpers with proper check',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `if (typeof $helpers !== 'undefined' && $helpers.httpRequest) {
|
||||
const response = await $helpers.httpRequest({
|
||||
method: 'GET',
|
||||
url: 'https://api.example.com'
|
||||
});
|
||||
return [{json: response}];
|
||||
}
|
||||
return [{json: {error: 'HTTP not available'}}];`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Crypto without require',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `const token = crypto.randomBytes(32).toString('hex');
|
||||
return [{json: {token}}];`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Crypto with require',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `const crypto = require('crypto');
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
return [{json: {token}}];`
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const test of testCases) {
|
||||
console.log(`Test: ${test.name}`);
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
'nodes-base.code',
|
||||
test.config,
|
||||
[
|
||||
{ name: 'language', type: 'options', options: ['javaScript', 'python'] },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
],
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
console.log(` Valid: ${result.valid}`);
|
||||
if (result.errors.length > 0) {
|
||||
console.log(` Errors: ${result.errors.map(e => e.message).join(', ')}`);
|
||||
}
|
||||
if (result.warnings.length > 0) {
|
||||
console.log(` Warnings: ${result.warnings.map(w => w.message).join(', ')}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log('✅ $helpers validation tests completed!');
|
||||
@@ -0,0 +1,46 @@
|
||||
#\!/usr/bin/env node
|
||||
|
||||
import { N8NDocumentationMCPServer } from '../src/mcp/server';
|
||||
|
||||
async function testHttpSearch() {
|
||||
const server = new N8NDocumentationMCPServer();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
console.log('Testing search for "http"...\n');
|
||||
|
||||
const result = await server.executeTool('search_nodes', {
|
||||
query: 'http',
|
||||
limit: 50 // Get more results to see where HTTP Request is
|
||||
});
|
||||
|
||||
console.log(`Total results: ${result.results.length}\n`);
|
||||
|
||||
// Find HTTP Request node in results
|
||||
const httpRequestIndex = result.results.findIndex((r: any) =>
|
||||
r.nodeType === 'nodes-base.httpRequest'
|
||||
);
|
||||
|
||||
if (httpRequestIndex === -1) {
|
||||
console.log('❌ HTTP Request node NOT FOUND in results\!');
|
||||
} else {
|
||||
console.log(`✅ HTTP Request found at position ${httpRequestIndex + 1}`);
|
||||
}
|
||||
|
||||
console.log('\nTop 10 results:');
|
||||
result.results.slice(0, 10).forEach((r: any, i: number) => {
|
||||
console.log(`${i + 1}. ${r.nodeType} - ${r.displayName}`);
|
||||
});
|
||||
|
||||
// Also check LIKE search directly
|
||||
console.log('\n\nTesting LIKE search fallback:');
|
||||
const serverAny = server as any;
|
||||
const likeResult = await serverAny.searchNodesLIKE('http', 20);
|
||||
|
||||
console.log(`LIKE search found ${likeResult.results.length} results`);
|
||||
console.log('Top 5 LIKE results:');
|
||||
likeResult.results.slice(0, 5).forEach((r: any, i: number) => {
|
||||
console.log(`${i + 1}. ${r.nodeType} - ${r.displayName}`);
|
||||
});
|
||||
}
|
||||
|
||||
testHttpSearch().catch(console.error);
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
# Test script for n8n-MCP HTTP Server
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
URL="${1:-http://localhost:3000}"
|
||||
TOKEN="${AUTH_TOKEN:-test-token}"
|
||||
VERBOSE="${VERBOSE:-0}"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo "🧪 Testing n8n-MCP HTTP Server"
|
||||
echo "================================"
|
||||
echo "Server URL: $URL"
|
||||
echo ""
|
||||
|
||||
# Check if jq is installed
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo -e "${YELLOW}Warning: jq not installed. Output will not be formatted.${NC}"
|
||||
echo "Install with: brew install jq (macOS) or apt-get install jq (Linux)"
|
||||
echo ""
|
||||
JQ="cat"
|
||||
else
|
||||
JQ="jq ."
|
||||
fi
|
||||
|
||||
# Function to make requests
|
||||
make_request() {
|
||||
local method="$1"
|
||||
local endpoint="$2"
|
||||
local data="$3"
|
||||
local headers="$4"
|
||||
local expected_status="$5"
|
||||
|
||||
if [ "$VERBOSE" = "1" ]; then
|
||||
echo -e "${YELLOW}Request:${NC} $method $URL$endpoint"
|
||||
[ -n "$data" ] && echo -e "${YELLOW}Data:${NC} $data"
|
||||
fi
|
||||
|
||||
# Build curl command
|
||||
local cmd="curl -s -w '\n%{http_code}' -X $method '$URL$endpoint'"
|
||||
[ -n "$headers" ] && cmd="$cmd $headers"
|
||||
[ -n "$data" ] && cmd="$cmd -d '$data'"
|
||||
|
||||
# Execute and capture response
|
||||
local response=$(eval "$cmd")
|
||||
local body=$(echo "$response" | sed '$d')
|
||||
local status=$(echo "$response" | tail -n 1)
|
||||
|
||||
# Check status
|
||||
if [ "$status" = "$expected_status" ]; then
|
||||
echo -e "${GREEN}✓${NC} $method $endpoint - Status: $status"
|
||||
else
|
||||
echo -e "${RED}✗${NC} $method $endpoint - Expected: $expected_status, Got: $status"
|
||||
fi
|
||||
|
||||
# Show response body
|
||||
if [ -n "$body" ]; then
|
||||
echo "$body" | $JQ
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Test 1: Health check
|
||||
echo "1. Testing health endpoint..."
|
||||
make_request "GET" "/health" "" "" "200"
|
||||
|
||||
# Test 2: OPTIONS request (CORS preflight)
|
||||
echo "2. Testing CORS preflight..."
|
||||
make_request "OPTIONS" "/mcp" "" "-H 'Origin: http://localhost' -H 'Access-Control-Request-Method: POST'" "204"
|
||||
|
||||
# Test 3: Authentication failure
|
||||
echo "3. Testing authentication (should fail)..."
|
||||
make_request "POST" "/mcp" \
|
||||
'{"jsonrpc":"2.0","method":"tools/list","id":1}' \
|
||||
"-H 'Content-Type: application/json' -H 'Authorization: Bearer wrong-token'" \
|
||||
"401"
|
||||
|
||||
# Test 4: Missing authentication
|
||||
echo "4. Testing missing authentication..."
|
||||
make_request "POST" "/mcp" \
|
||||
'{"jsonrpc":"2.0","method":"tools/list","id":1}' \
|
||||
"-H 'Content-Type: application/json'" \
|
||||
"401"
|
||||
|
||||
# Test 5: Valid MCP request to list tools
|
||||
echo "5. Testing valid MCP request (list tools)..."
|
||||
make_request "POST" "/mcp" \
|
||||
'{"jsonrpc":"2.0","method":"tools/list","id":1}' \
|
||||
"-H 'Content-Type: application/json' -H 'Authorization: Bearer $TOKEN' -H 'Accept: application/json, text/event-stream'" \
|
||||
"200"
|
||||
|
||||
# Test 6: 404 for unknown endpoint
|
||||
echo "6. Testing 404 response..."
|
||||
make_request "GET" "/unknown" "" "" "404"
|
||||
|
||||
# Test 7: Invalid JSON
|
||||
echo "7. Testing invalid JSON..."
|
||||
make_request "POST" "/mcp" \
|
||||
'{invalid json}' \
|
||||
"-H 'Content-Type: application/json' -H 'Authorization: Bearer $TOKEN'" \
|
||||
"400"
|
||||
|
||||
# Test 8: Request size limit
|
||||
echo "8. Testing request size limit..."
|
||||
# Use a different approach for large data
|
||||
echo "Skipping large payload test (would exceed bash limits)"
|
||||
|
||||
# Test 9: MCP initialization
|
||||
if [ "$VERBOSE" = "1" ]; then
|
||||
echo "9. Testing MCP initialization..."
|
||||
make_request "POST" "/mcp" \
|
||||
'{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{"roots":{}}},"id":1}' \
|
||||
"-H 'Content-Type: application/json' -H 'Authorization: Bearer $TOKEN' -H 'Accept: text/event-stream'" \
|
||||
"200"
|
||||
fi
|
||||
|
||||
echo "================================"
|
||||
echo "🎉 Tests completed!"
|
||||
echo ""
|
||||
echo "To run with verbose output: VERBOSE=1 $0"
|
||||
echo "To test a different server: $0 https://your-server.com"
|
||||
echo "To use a different token: AUTH_TOKEN=your-token $0"
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator.js';
|
||||
|
||||
console.log('🧪 Testing JMESPath Validation\n');
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
name: 'JMESPath with unquoted numeric literal',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `const data = { users: [{ name: 'John', age: 30 }, { name: 'Jane', age: 25 }] };
|
||||
const adults = $jmespath(data, 'users[?age >= 18]');
|
||||
return [{json: {adults}}];`
|
||||
},
|
||||
expectError: true
|
||||
},
|
||||
{
|
||||
name: 'JMESPath with properly quoted numeric literal',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `const data = { users: [{ name: 'John', age: 30 }, { name: 'Jane', age: 25 }] };
|
||||
const adults = $jmespath(data, 'users[?age >= \`18\`]');
|
||||
return [{json: {adults}}];`
|
||||
},
|
||||
expectError: false
|
||||
},
|
||||
{
|
||||
name: 'Multiple JMESPath filters with unquoted numbers',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `const products = items.map(item => item.json);
|
||||
const expensive = $jmespath(products, '[?price > 100]');
|
||||
const lowStock = $jmespath(products, '[?quantity < 10]');
|
||||
const highPriority = $jmespath(products, '[?priority == 1]');
|
||||
return [{json: {expensive, lowStock, highPriority}}];`
|
||||
},
|
||||
expectError: true
|
||||
},
|
||||
{
|
||||
name: 'JMESPath with string comparison (no backticks needed)',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `const data = { users: [{ name: 'John', status: 'active' }, { name: 'Jane', status: 'inactive' }] };
|
||||
const activeUsers = $jmespath(data, 'users[?status == "active"]');
|
||||
return [{json: {activeUsers}}];`
|
||||
},
|
||||
expectError: false
|
||||
},
|
||||
{
|
||||
name: 'Python JMESPath with unquoted numeric literal',
|
||||
config: {
|
||||
language: 'python',
|
||||
pythonCode: `data = { 'users': [{ 'name': 'John', 'age': 30 }, { 'name': 'Jane', 'age': 25 }] }
|
||||
adults = _jmespath(data, 'users[?age >= 18]')
|
||||
return [{'json': {'adults': adults}}]`
|
||||
},
|
||||
expectError: true
|
||||
},
|
||||
{
|
||||
name: 'Complex filter with decimal numbers',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `const items = [{ price: 99.99 }, { price: 150.50 }, { price: 200 }];
|
||||
const expensive = $jmespath(items, '[?price >= 99.95]');
|
||||
return [{json: {expensive}}];`
|
||||
},
|
||||
expectError: true
|
||||
}
|
||||
];
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const test of testCases) {
|
||||
console.log(`Test: ${test.name}`);
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
'nodes-base.code',
|
||||
test.config,
|
||||
[
|
||||
{ name: 'language', type: 'options', options: ['javaScript', 'python'] },
|
||||
{ name: 'jsCode', type: 'string' },
|
||||
{ name: 'pythonCode', type: 'string' }
|
||||
],
|
||||
'operation',
|
||||
'strict'
|
||||
);
|
||||
|
||||
const hasJMESPathError = result.errors.some(e =>
|
||||
e.message.includes('JMESPath numeric literal') ||
|
||||
e.message.includes('must be wrapped in backticks')
|
||||
);
|
||||
|
||||
const passed = hasJMESPathError === test.expectError;
|
||||
|
||||
console.log(` Expected error: ${test.expectError}`);
|
||||
console.log(` Has JMESPath error: ${hasJMESPathError}`);
|
||||
console.log(` Result: ${passed ? '✅ PASS' : '❌ FAIL'}`);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.log(` Errors: ${result.errors.map(e => e.message).join(', ')}`);
|
||||
}
|
||||
if (result.warnings.length > 0) {
|
||||
console.log(` Warnings: ${result.warnings.slice(0, 2).map(w => w.message).join(', ')}`);
|
||||
}
|
||||
|
||||
if (passed) passCount++;
|
||||
else failCount++;
|
||||
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`\n📊 Results: ${passCount} passed, ${failCount} failed`);
|
||||
console.log(failCount === 0 ? '✅ All JMESPath validation tests passed!' : '❌ Some tests failed');
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env ts-node
|
||||
|
||||
/**
|
||||
* Simple test for multi-tenant functionality
|
||||
* Tests that tools are registered correctly based on configuration
|
||||
*/
|
||||
|
||||
import { isN8nApiConfigured } from '../src/config/n8n-api';
|
||||
import { InstanceContext } from '../src/types/instance-context';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function testMultiTenant() {
|
||||
console.log('🧪 Testing Multi-Tenant Tool Registration\n');
|
||||
console.log('=' .repeat(60));
|
||||
|
||||
// Save original environment
|
||||
const originalEnv = {
|
||||
ENABLE_MULTI_TENANT: process.env.ENABLE_MULTI_TENANT,
|
||||
N8N_API_URL: process.env.N8N_API_URL,
|
||||
N8N_API_KEY: process.env.N8N_API_KEY
|
||||
};
|
||||
|
||||
try {
|
||||
// Test 1: Default - no API config
|
||||
console.log('\n✅ Test 1: No API configuration');
|
||||
delete process.env.N8N_API_URL;
|
||||
delete process.env.N8N_API_KEY;
|
||||
delete process.env.ENABLE_MULTI_TENANT;
|
||||
|
||||
const hasConfig1 = isN8nApiConfigured();
|
||||
console.log(` Environment API configured: ${hasConfig1}`);
|
||||
console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`);
|
||||
console.log(` Should show tools: ${hasConfig1 || process.env.ENABLE_MULTI_TENANT === 'true'}`);
|
||||
|
||||
// Test 2: Multi-tenant enabled
|
||||
console.log('\n✅ Test 2: Multi-tenant enabled (no env API)');
|
||||
process.env.ENABLE_MULTI_TENANT = 'true';
|
||||
|
||||
const hasConfig2 = isN8nApiConfigured();
|
||||
console.log(` Environment API configured: ${hasConfig2}`);
|
||||
console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`);
|
||||
console.log(` Should show tools: ${hasConfig2 || process.env.ENABLE_MULTI_TENANT === 'true'}`);
|
||||
|
||||
// Test 3: Environment variables set
|
||||
console.log('\n✅ Test 3: Environment variables set');
|
||||
process.env.ENABLE_MULTI_TENANT = 'false';
|
||||
process.env.N8N_API_URL = 'https://test.n8n.cloud';
|
||||
process.env.N8N_API_KEY = 'test-key';
|
||||
|
||||
const hasConfig3 = isN8nApiConfigured();
|
||||
console.log(` Environment API configured: ${hasConfig3}`);
|
||||
console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`);
|
||||
console.log(` Should show tools: ${hasConfig3 || process.env.ENABLE_MULTI_TENANT === 'true'}`);
|
||||
|
||||
// Test 4: Instance context simulation
|
||||
console.log('\n✅ Test 4: Instance context (simulated)');
|
||||
const instanceContext: InstanceContext = {
|
||||
n8nApiUrl: 'https://instance.n8n.cloud',
|
||||
n8nApiKey: 'instance-key',
|
||||
instanceId: 'test-instance'
|
||||
};
|
||||
|
||||
const hasInstanceConfig = !!(instanceContext.n8nApiUrl && instanceContext.n8nApiKey);
|
||||
console.log(` Instance has API config: ${hasInstanceConfig}`);
|
||||
console.log(` Environment API configured: ${hasConfig3}`);
|
||||
console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`);
|
||||
console.log(` Should show tools: ${hasConfig3 || hasInstanceConfig || process.env.ENABLE_MULTI_TENANT === 'true'}`);
|
||||
|
||||
// Test 5: Multi-tenant with instance strategy
|
||||
console.log('\n✅ Test 5: Multi-tenant with instance strategy');
|
||||
process.env.ENABLE_MULTI_TENANT = 'true';
|
||||
process.env.MULTI_TENANT_SESSION_STRATEGY = 'instance';
|
||||
delete process.env.N8N_API_URL;
|
||||
delete process.env.N8N_API_KEY;
|
||||
|
||||
const hasConfig5 = isN8nApiConfigured();
|
||||
const sessionStrategy = process.env.MULTI_TENANT_SESSION_STRATEGY || 'instance';
|
||||
console.log(` Environment API configured: ${hasConfig5}`);
|
||||
console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`);
|
||||
console.log(` Session strategy: ${sessionStrategy}`);
|
||||
console.log(` Should show tools: ${hasConfig5 || process.env.ENABLE_MULTI_TENANT === 'true'}`);
|
||||
|
||||
if (instanceContext.instanceId) {
|
||||
const sessionId = `instance-${instanceContext.instanceId}-uuid`;
|
||||
console.log(` Session ID format: ${sessionId}`);
|
||||
}
|
||||
|
||||
console.log('\n' + '=' .repeat(60));
|
||||
console.log('✅ All configuration tests passed!');
|
||||
console.log('\n📝 Summary:');
|
||||
console.log(' - Tools are shown when: env API configured OR multi-tenant enabled OR instance context provided');
|
||||
console.log(' - Session isolation works with instance-based session IDs in multi-tenant mode');
|
||||
console.log(' - Backward compatibility maintained for env-based configuration');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Test failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
// Restore original environment
|
||||
if (originalEnv.ENABLE_MULTI_TENANT !== undefined) {
|
||||
process.env.ENABLE_MULTI_TENANT = originalEnv.ENABLE_MULTI_TENANT;
|
||||
} else {
|
||||
delete process.env.ENABLE_MULTI_TENANT;
|
||||
}
|
||||
|
||||
if (originalEnv.N8N_API_URL !== undefined) {
|
||||
process.env.N8N_API_URL = originalEnv.N8N_API_URL;
|
||||
} else {
|
||||
delete process.env.N8N_API_URL;
|
||||
}
|
||||
|
||||
if (originalEnv.N8N_API_KEY !== undefined) {
|
||||
process.env.N8N_API_KEY = originalEnv.N8N_API_KEY;
|
||||
} else {
|
||||
delete process.env.N8N_API_KEY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests
|
||||
testMultiTenant().catch(error => {
|
||||
console.error('Test execution failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env ts-node
|
||||
|
||||
/**
|
||||
* Test script for multi-tenant functionality
|
||||
* Verifies that instance context from headers enables n8n API tools
|
||||
*/
|
||||
|
||||
import { N8NDocumentationMCPServer } from '../src/mcp/server';
|
||||
import { InstanceContext } from '../src/types/instance-context';
|
||||
import { logger } from '../src/utils/logger';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function testMultiTenant() {
|
||||
console.log('🧪 Testing Multi-Tenant Functionality\n');
|
||||
console.log('=' .repeat(60));
|
||||
|
||||
// Save original environment
|
||||
const originalEnv = {
|
||||
ENABLE_MULTI_TENANT: process.env.ENABLE_MULTI_TENANT,
|
||||
N8N_API_URL: process.env.N8N_API_URL,
|
||||
N8N_API_KEY: process.env.N8N_API_KEY
|
||||
};
|
||||
|
||||
// Wait a moment for database initialization
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
try {
|
||||
// Test 1: Without multi-tenant mode (default)
|
||||
console.log('\n📌 Test 1: Without multi-tenant mode (no env vars)');
|
||||
delete process.env.N8N_API_URL;
|
||||
delete process.env.N8N_API_KEY;
|
||||
process.env.ENABLE_MULTI_TENANT = 'false';
|
||||
|
||||
const server1 = new N8NDocumentationMCPServer();
|
||||
const tools1 = await getToolsFromServer(server1);
|
||||
const hasManagementTools1 = tools1.some(t => t.name.startsWith('n8n_'));
|
||||
console.log(` Tools available: ${tools1.length}`);
|
||||
console.log(` Has management tools: ${hasManagementTools1}`);
|
||||
console.log(` ✅ Expected: No management tools (correct: ${!hasManagementTools1})`);
|
||||
|
||||
// Test 2: With instance context but multi-tenant disabled
|
||||
console.log('\n📌 Test 2: With instance context but multi-tenant disabled');
|
||||
const instanceContext: InstanceContext = {
|
||||
n8nApiUrl: 'https://instance1.n8n.cloud',
|
||||
n8nApiKey: 'test-api-key',
|
||||
instanceId: 'instance-1'
|
||||
};
|
||||
|
||||
const server2 = new N8NDocumentationMCPServer(instanceContext);
|
||||
const tools2 = await getToolsFromServer(server2);
|
||||
const hasManagementTools2 = tools2.some(t => t.name.startsWith('n8n_'));
|
||||
console.log(` Tools available: ${tools2.length}`);
|
||||
console.log(` Has management tools: ${hasManagementTools2}`);
|
||||
console.log(` ✅ Expected: Has management tools (correct: ${hasManagementTools2})`);
|
||||
|
||||
// Test 3: With multi-tenant mode enabled
|
||||
console.log('\n📌 Test 3: With multi-tenant mode enabled');
|
||||
process.env.ENABLE_MULTI_TENANT = 'true';
|
||||
|
||||
const server3 = new N8NDocumentationMCPServer();
|
||||
const tools3 = await getToolsFromServer(server3);
|
||||
const hasManagementTools3 = tools3.some(t => t.name.startsWith('n8n_'));
|
||||
console.log(` Tools available: ${tools3.length}`);
|
||||
console.log(` Has management tools: ${hasManagementTools3}`);
|
||||
console.log(` ✅ Expected: Has management tools (correct: ${hasManagementTools3})`);
|
||||
|
||||
// Test 4: Multi-tenant with instance context
|
||||
console.log('\n📌 Test 4: Multi-tenant with instance context');
|
||||
const server4 = new N8NDocumentationMCPServer(instanceContext);
|
||||
const tools4 = await getToolsFromServer(server4);
|
||||
const hasManagementTools4 = tools4.some(t => t.name.startsWith('n8n_'));
|
||||
console.log(` Tools available: ${tools4.length}`);
|
||||
console.log(` Has management tools: ${hasManagementTools4}`);
|
||||
console.log(` ✅ Expected: Has management tools (correct: ${hasManagementTools4})`);
|
||||
|
||||
// Test 5: Environment variables (backward compatibility)
|
||||
console.log('\n📌 Test 5: Environment variables (backward compatibility)');
|
||||
process.env.ENABLE_MULTI_TENANT = 'false';
|
||||
process.env.N8N_API_URL = 'https://env.n8n.cloud';
|
||||
process.env.N8N_API_KEY = 'env-api-key';
|
||||
|
||||
const server5 = new N8NDocumentationMCPServer();
|
||||
const tools5 = await getToolsFromServer(server5);
|
||||
const hasManagementTools5 = tools5.some(t => t.name.startsWith('n8n_'));
|
||||
console.log(` Tools available: ${tools5.length}`);
|
||||
console.log(` Has management tools: ${hasManagementTools5}`);
|
||||
console.log(` ✅ Expected: Has management tools (correct: ${hasManagementTools5})`);
|
||||
|
||||
console.log('\n' + '=' .repeat(60));
|
||||
console.log('✅ All multi-tenant tests passed!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Test failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
// Restore original environment
|
||||
Object.assign(process.env, originalEnv);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get tools from server
|
||||
async function getToolsFromServer(server: N8NDocumentationMCPServer): Promise<any[]> {
|
||||
// Access the private server instance to simulate tool listing
|
||||
const serverInstance = (server as any).server;
|
||||
const handlers = (serverInstance as any)._requestHandlers;
|
||||
|
||||
// Find and call the ListToolsRequestSchema handler
|
||||
if (handlers && handlers.size > 0) {
|
||||
for (const [schema, handler] of handlers) {
|
||||
// Check for the tools/list schema
|
||||
if (schema && schema.method === 'tools/list') {
|
||||
const result = await handler({ params: {} });
|
||||
return result.tools || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: directly check the handlers map
|
||||
const ListToolsRequestSchema = { method: 'tools/list' };
|
||||
const handler = handlers?.get(ListToolsRequestSchema);
|
||||
if (handler) {
|
||||
const result = await handler({ params: {} });
|
||||
return result.tools || [];
|
||||
}
|
||||
|
||||
console.log(' ⚠️ Warning: Could not find tools/list handler');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Run tests
|
||||
testMultiTenant().catch(error => {
|
||||
console.error('Test execution failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+387
@@ -0,0 +1,387 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to test n8n integration with n8n-mcp server
|
||||
set -e
|
||||
|
||||
# Check for command line arguments
|
||||
if [ "$1" == "--clear-api-key" ] || [ "$1" == "-c" ]; then
|
||||
echo "🗑️ Clearing saved n8n API key..."
|
||||
rm -f "$HOME/.n8n-mcp-test/.n8n-api-key"
|
||||
echo "✅ API key cleared. You'll be prompted for a new key on next run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -h, --help Show this help message"
|
||||
echo " -c, --clear-api-key Clear the saved n8n API key"
|
||||
echo ""
|
||||
echo "The script will save your n8n API key on first use and reuse it on"
|
||||
echo "subsequent runs. You can override the saved key at runtime or clear"
|
||||
echo "it with the --clear-api-key option."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "🚀 Starting n8n integration test environment..."
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
N8N_PORT=5678
|
||||
MCP_PORT=3001
|
||||
AUTH_TOKEN="test-token-for-n8n-testing-minimum-32-chars"
|
||||
|
||||
# n8n data directory for persistence
|
||||
N8N_DATA_DIR="$HOME/.n8n-mcp-test"
|
||||
# API key storage file
|
||||
API_KEY_FILE="$N8N_DATA_DIR/.n8n-api-key"
|
||||
|
||||
# Function to detect OS
|
||||
detect_os() {
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
echo "$ID"
|
||||
else
|
||||
echo "linux"
|
||||
fi
|
||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
echo "macos"
|
||||
elif [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]]; then
|
||||
echo "windows"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check if Docker is installed
|
||||
check_docker() {
|
||||
if command -v docker &> /dev/null; then
|
||||
echo -e "${GREEN}✅ Docker is installed${NC}"
|
||||
# Check if Docker daemon is running
|
||||
if ! docker info &> /dev/null; then
|
||||
echo -e "${YELLOW}⚠️ Docker is installed but not running${NC}"
|
||||
echo -e "${YELLOW}Please start Docker and run this script again${NC}"
|
||||
exit 1
|
||||
fi
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to install Docker based on OS
|
||||
install_docker() {
|
||||
local os=$(detect_os)
|
||||
echo -e "${YELLOW}📦 Docker is not installed. Attempting to install...${NC}"
|
||||
|
||||
case $os in
|
||||
"ubuntu"|"debian")
|
||||
echo -e "${BLUE}Installing Docker on Ubuntu/Debian...${NC}"
|
||||
echo "This requires sudo privileges."
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ca-certificates curl gnupg
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
sudo usermod -aG docker $USER
|
||||
echo -e "${GREEN}✅ Docker installed successfully${NC}"
|
||||
echo -e "${YELLOW}⚠️ Please log out and back in for group changes to take effect${NC}"
|
||||
;;
|
||||
"fedora"|"rhel"|"centos")
|
||||
echo -e "${BLUE}Installing Docker on Fedora/RHEL/CentOS...${NC}"
|
||||
echo "This requires sudo privileges."
|
||||
sudo dnf -y install dnf-plugins-core
|
||||
sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo
|
||||
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
sudo systemctl start docker
|
||||
sudo systemctl enable docker
|
||||
sudo usermod -aG docker $USER
|
||||
echo -e "${GREEN}✅ Docker installed successfully${NC}"
|
||||
echo -e "${YELLOW}⚠️ Please log out and back in for group changes to take effect${NC}"
|
||||
;;
|
||||
"macos")
|
||||
echo -e "${BLUE}Installing Docker on macOS...${NC}"
|
||||
if command -v brew &> /dev/null; then
|
||||
echo "Installing Docker Desktop via Homebrew..."
|
||||
brew install --cask docker
|
||||
echo -e "${GREEN}✅ Docker Desktop installed${NC}"
|
||||
echo -e "${YELLOW}⚠️ Please start Docker Desktop from Applications${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Homebrew not found${NC}"
|
||||
echo "Please install Docker Desktop manually from:"
|
||||
echo "https://www.docker.com/products/docker-desktop/"
|
||||
fi
|
||||
;;
|
||||
"windows")
|
||||
echo -e "${RED}❌ Windows detected${NC}"
|
||||
echo "Please install Docker Desktop manually from:"
|
||||
echo "https://www.docker.com/products/docker-desktop/"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}❌ Unknown operating system: $os${NC}"
|
||||
echo "Please install Docker manually from https://docs.docker.com/get-docker/"
|
||||
;;
|
||||
esac
|
||||
|
||||
# If we installed Docker on Linux, we need to restart for group changes
|
||||
if [[ "$os" == "ubuntu" ]] || [[ "$os" == "debian" ]] || [[ "$os" == "fedora" ]] || [[ "$os" == "rhel" ]] || [[ "$os" == "centos" ]]; then
|
||||
echo -e "${YELLOW}Please run 'newgrp docker' or log out and back in, then run this script again${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check for Docker
|
||||
if ! check_docker; then
|
||||
install_docker
|
||||
fi
|
||||
|
||||
# Check for jq (optional but recommended)
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo -e "${YELLOW}⚠️ jq is not installed (optional)${NC}"
|
||||
echo -e "${YELLOW} Install it for pretty JSON output in tests${NC}"
|
||||
fi
|
||||
|
||||
# Function to cleanup on exit
|
||||
cleanup() {
|
||||
echo -e "\n${YELLOW}🧹 Cleaning up...${NC}"
|
||||
|
||||
# Stop n8n container
|
||||
if docker ps -q -f name=n8n-test > /dev/null 2>&1; then
|
||||
echo "Stopping n8n container..."
|
||||
docker stop n8n-test >/dev/null 2>&1 || true
|
||||
docker rm n8n-test >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Kill MCP server if running
|
||||
if [ -n "$MCP_PID" ] && kill -0 $MCP_PID 2>/dev/null; then
|
||||
echo "Stopping MCP server..."
|
||||
kill $MCP_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ Cleanup complete${NC}"
|
||||
}
|
||||
|
||||
# Set trap to cleanup on exit
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Check if we're in the right directory
|
||||
if [ ! -f "package.json" ] || [ ! -d "dist" ]; then
|
||||
echo -e "${RED}❌ Error: Must run from n8n-mcp directory${NC}"
|
||||
echo "Please cd to /Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Always build the project to ensure latest changes
|
||||
echo -e "${YELLOW}📦 Building project...${NC}"
|
||||
npm run build
|
||||
|
||||
# Create n8n data directory if it doesn't exist
|
||||
if [ ! -d "$N8N_DATA_DIR" ]; then
|
||||
echo -e "${YELLOW}📁 Creating n8n data directory: $N8N_DATA_DIR${NC}"
|
||||
mkdir -p "$N8N_DATA_DIR"
|
||||
fi
|
||||
|
||||
# Start n8n in Docker with persistent volume
|
||||
echo -e "\n${GREEN}🐳 Starting n8n container with persistent data...${NC}"
|
||||
docker run -d \
|
||||
--name n8n-test \
|
||||
-p ${N8N_PORT}:5678 \
|
||||
-v "${N8N_DATA_DIR}:/home/node/.n8n" \
|
||||
-e N8N_BASIC_AUTH_ACTIVE=false \
|
||||
-e N8N_HOST=localhost \
|
||||
-e N8N_PORT=5678 \
|
||||
-e N8N_PROTOCOL=http \
|
||||
-e NODE_ENV=development \
|
||||
-e N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true \
|
||||
n8nio/n8n:latest
|
||||
|
||||
# Wait for n8n to be ready
|
||||
echo -e "${YELLOW}⏳ Waiting for n8n to start...${NC}"
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:${N8N_PORT}/ >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}✅ n8n is ready!${NC}"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
echo -e "${RED}❌ n8n failed to start${NC}"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Check for saved API key
|
||||
if [ -f "$API_KEY_FILE" ]; then
|
||||
# Read saved API key
|
||||
N8N_API_KEY=$(cat "$API_KEY_FILE" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$N8N_API_KEY" ]; then
|
||||
echo -e "\n${GREEN}✅ Using saved n8n API key${NC}"
|
||||
echo -e "${YELLOW} To use a different key, delete: ${API_KEY_FILE}${NC}"
|
||||
|
||||
# Give user a chance to override
|
||||
echo -e "\n${YELLOW}Press Enter to continue with saved key, or paste a new API key:${NC}"
|
||||
read -r NEW_API_KEY
|
||||
|
||||
if [ -n "$NEW_API_KEY" ]; then
|
||||
N8N_API_KEY="$NEW_API_KEY"
|
||||
# Save the new key
|
||||
echo "$N8N_API_KEY" > "$API_KEY_FILE"
|
||||
chmod 600 "$API_KEY_FILE"
|
||||
echo -e "${GREEN}✅ New API key saved${NC}"
|
||||
fi
|
||||
else
|
||||
# File exists but is empty, remove it
|
||||
rm -f "$API_KEY_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If no saved key, prompt for one
|
||||
if [ -z "$N8N_API_KEY" ]; then
|
||||
# Guide user to get API key
|
||||
echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${YELLOW}🔑 n8n API Key Setup${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "\nTo enable n8n management tools, you need to create an API key:"
|
||||
echo -e "\n${GREEN}Steps:${NC}"
|
||||
echo -e " 1. Open n8n in your browser: ${BLUE}http://localhost:${N8N_PORT}${NC}"
|
||||
echo -e " 2. Click on your user menu (top right)"
|
||||
echo -e " 3. Go to 'Settings'"
|
||||
echo -e " 4. Navigate to 'API'"
|
||||
echo -e " 5. Click 'Create API Key'"
|
||||
echo -e " 6. Give it a name (e.g., 'n8n-mcp')"
|
||||
echo -e " 7. Copy the generated API key"
|
||||
echo -e "\n${YELLOW}Note: If this is your first time, you'll need to create an account first.${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
|
||||
# Wait for API key input
|
||||
echo -e "\n${YELLOW}Please paste your n8n API key here (or press Enter to skip):${NC}"
|
||||
read -r N8N_API_KEY
|
||||
|
||||
# Save the API key if provided
|
||||
if [ -n "$N8N_API_KEY" ]; then
|
||||
echo "$N8N_API_KEY" > "$API_KEY_FILE"
|
||||
chmod 600 "$API_KEY_FILE"
|
||||
echo -e "${GREEN}✅ API key saved for future use${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if API key was provided
|
||||
if [ -z "$N8N_API_KEY" ]; then
|
||||
echo -e "${YELLOW}⚠️ No API key provided. n8n management tools will not be available.${NC}"
|
||||
echo -e "${YELLOW} You can still use documentation and search tools.${NC}"
|
||||
N8N_API_KEY=""
|
||||
N8N_API_URL=""
|
||||
else
|
||||
echo -e "${GREEN}✅ API key received${NC}"
|
||||
# Set the API URL for localhost access (MCP server runs on host, not in Docker)
|
||||
N8N_API_URL="http://localhost:${N8N_PORT}/api/v1"
|
||||
fi
|
||||
|
||||
# Start MCP server
|
||||
echo -e "\n${GREEN}🚀 Starting MCP server in n8n mode...${NC}"
|
||||
if [ -n "$N8N_API_KEY" ]; then
|
||||
echo -e "${YELLOW} With n8n management tools enabled${NC}"
|
||||
fi
|
||||
|
||||
N8N_MODE=true \
|
||||
MCP_MODE=http \
|
||||
AUTH_TOKEN="${AUTH_TOKEN}" \
|
||||
PORT=${MCP_PORT} \
|
||||
N8N_API_KEY="${N8N_API_KEY}" \
|
||||
N8N_API_URL="${N8N_API_URL}" \
|
||||
node dist/mcp/index.js > /tmp/mcp-server.log 2>&1 &
|
||||
|
||||
MCP_PID=$!
|
||||
|
||||
# Show log file location
|
||||
echo -e "${YELLOW}📄 MCP server logs: /tmp/mcp-server.log${NC}"
|
||||
|
||||
# Wait for MCP server to be ready
|
||||
echo -e "${YELLOW}⏳ Waiting for MCP server to start...${NC}"
|
||||
for i in {1..10}; do
|
||||
if curl -s http://localhost:${MCP_PORT}/health >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}✅ MCP server is ready!${NC}"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 10 ]; then
|
||||
echo -e "${RED}❌ MCP server failed to start${NC}"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Show status and test endpoints
|
||||
echo -e "\n${GREEN}🎉 Both services are running!${NC}"
|
||||
echo -e "\n📍 Service URLs:"
|
||||
echo -e " • n8n: http://localhost:${N8N_PORT}"
|
||||
echo -e " • MCP server: http://localhost:${MCP_PORT}"
|
||||
echo -e "\n🔑 Auth token: ${AUTH_TOKEN}"
|
||||
echo -e "\n💾 n8n data stored in: ${N8N_DATA_DIR}"
|
||||
echo -e " (Your workflows, credentials, and settings are preserved between runs)"
|
||||
|
||||
# Test MCP protocol endpoint
|
||||
echo -e "\n${YELLOW}🧪 Testing MCP protocol endpoint...${NC}"
|
||||
echo "Response from GET /mcp:"
|
||||
curl -s http://localhost:${MCP_PORT}/mcp | jq '.' || curl -s http://localhost:${MCP_PORT}/mcp
|
||||
|
||||
# Test MCP initialization
|
||||
echo -e "\n${YELLOW}🧪 Testing MCP initialization...${NC}"
|
||||
echo "Response from POST /mcp (initialize):"
|
||||
curl -s -X POST http://localhost:${MCP_PORT}/mcp \
|
||||
-H "Authorization: Bearer ${AUTH_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}},"id":1}' \
|
||||
| jq '.' || echo "(Install jq for pretty JSON output)"
|
||||
|
||||
# Test available tools
|
||||
echo -e "\n${YELLOW}🧪 Checking available MCP tools...${NC}"
|
||||
if [ -n "$N8N_API_KEY" ]; then
|
||||
echo -e "${GREEN}✅ n8n Management Tools Available:${NC}"
|
||||
echo " • n8n_list_workflows - List all workflows"
|
||||
echo " • n8n_get_workflow - Get workflow details"
|
||||
echo " • n8n_create_workflow - Create new workflows"
|
||||
echo " • n8n_update_workflow - Update existing workflows"
|
||||
echo " • n8n_delete_workflow - Delete workflows"
|
||||
echo " • n8n_trigger_webhook_workflow - Trigger webhook workflows"
|
||||
echo " • n8n_list_executions - List workflow executions"
|
||||
echo " • And more..."
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ n8n Management Tools NOT Available${NC}"
|
||||
echo " To enable, restart with an n8n API key"
|
||||
fi
|
||||
|
||||
echo -e "\n${GREEN}✅ Documentation Tools Always Available:${NC}"
|
||||
echo " • list_nodes - List available n8n nodes"
|
||||
echo " • search_nodes - Search for specific nodes"
|
||||
echo " • get_node_info - Get detailed node information"
|
||||
echo " • validate_node_operation - Validate node configurations"
|
||||
echo " • And many more..."
|
||||
|
||||
echo -e "\n${GREEN}✅ Setup complete!${NC}"
|
||||
echo -e "\n📝 Next steps:"
|
||||
echo -e " 1. Open n8n at http://localhost:${N8N_PORT}"
|
||||
echo -e " 2. Create a workflow with the AI Agent node"
|
||||
echo -e " 3. Add MCP Client Tool node"
|
||||
echo -e " 4. Configure it with:"
|
||||
echo -e " • Transport: HTTP"
|
||||
echo -e " • URL: http://host.docker.internal:${MCP_PORT}/mcp"
|
||||
echo -e " • Auth Token: ${BLUE}${AUTH_TOKEN}${NC}"
|
||||
echo -e "\n${YELLOW}Press Ctrl+C to stop both services${NC}"
|
||||
echo -e "\n${YELLOW}📋 To monitor MCP logs: tail -f /tmp/mcp-server.log${NC}"
|
||||
echo -e "${YELLOW}📋 To monitor n8n logs: docker logs -f n8n-test${NC}"
|
||||
|
||||
# Wait for interrupt
|
||||
wait $MCP_PID
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test get_node_info to diagnose timeout issues
|
||||
*/
|
||||
|
||||
const { N8NDocumentationMCPServer } = require('../dist/mcp/server');
|
||||
|
||||
async function testNodeInfo() {
|
||||
console.log('🔍 Testing get_node_info...\n');
|
||||
|
||||
try {
|
||||
const server = new N8NDocumentationMCPServer();
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const nodes = [
|
||||
'nodes-base.httpRequest',
|
||||
'nodes-base.webhook',
|
||||
'nodes-langchain.agent'
|
||||
];
|
||||
|
||||
for (const nodeType of nodes) {
|
||||
console.log(`Testing ${nodeType}...`);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const result = await server.executeTool('get_node_info', { nodeType });
|
||||
const elapsed = Date.now() - start;
|
||||
const size = JSON.stringify(result).length;
|
||||
|
||||
console.log(`✅ Success in ${elapsed}ms`);
|
||||
console.log(` Size: ${(size / 1024).toFixed(1)}KB`);
|
||||
console.log(` Properties: ${result.properties?.length || 0}`);
|
||||
console.log(` Operations: ${result.operations?.length || 0}`);
|
||||
|
||||
// Check for issues
|
||||
if (size > 50000) {
|
||||
console.log(` ⚠️ WARNING: Response over 50KB!`);
|
||||
}
|
||||
|
||||
// Check property quality
|
||||
const propsWithoutDesc = result.properties?.filter(p => !p.description && !p.displayName).length || 0;
|
||||
if (propsWithoutDesc > 0) {
|
||||
console.log(` ⚠️ ${propsWithoutDesc} properties without descriptions`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const elapsed = Date.now() - start;
|
||||
console.log(`❌ Failed after ${elapsed}ms: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fatal error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
testNodeInfo().catch(console.error);
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Test script for node type validation
|
||||
* Tests the improvements to catch invalid node types like "nodes-base.webhook"
|
||||
*/
|
||||
|
||||
import { WorkflowValidator } from '../src/services/workflow-validator';
|
||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
|
||||
import { NodeRepository } from '../src/database/node-repository';
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { validateWorkflowStructure } from '../src/services/n8n-validation';
|
||||
import { Logger } from '../src/utils/logger';
|
||||
|
||||
const logger = new Logger({ prefix: '[TestNodeTypeValidation]' });
|
||||
|
||||
async function testValidation() {
|
||||
const adapter = await createDatabaseAdapter('./data/nodes.db');
|
||||
const repository = new NodeRepository(adapter);
|
||||
const validator = new WorkflowValidator(repository, EnhancedConfigValidator);
|
||||
|
||||
logger.info('Testing node type validation...\n');
|
||||
|
||||
// Test 1: The exact broken workflow from Claude Desktop
|
||||
const brokenWorkflowFromLogs = {
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "webhook_node",
|
||||
"name": "Webhook",
|
||||
"type": "nodes-base.webhook", // WRONG! Missing n8n- prefix
|
||||
"typeVersion": 2,
|
||||
"position": [260, 300] as [number, number]
|
||||
}
|
||||
],
|
||||
"connections": {},
|
||||
"pinData": {},
|
||||
"meta": {
|
||||
"instanceId": "74e11c77e266f2c77f6408eb6c88e3fec63c9a5d8c4a3a2ea4c135c542012d6b"
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('Test 1: Invalid node type "nodes-base.webhook" (missing n8n- prefix)');
|
||||
const result1 = await validator.validateWorkflow(brokenWorkflowFromLogs as any);
|
||||
|
||||
logger.info('Validation result:');
|
||||
logger.info(`Valid: ${result1.valid}`);
|
||||
logger.info(`Errors: ${result1.errors.length}`);
|
||||
result1.errors.forEach(err => {
|
||||
if (typeof err === 'string') {
|
||||
logger.error(` - ${err}`);
|
||||
} else if (err && typeof err === 'object' && 'message' in err) {
|
||||
logger.error(` - ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if the specific error about nodes-base.webhook was caught
|
||||
const hasNodeBaseError = result1.errors.some(err =>
|
||||
err && typeof err === 'object' && 'message' in err &&
|
||||
err.message.includes('nodes-base.webhook') &&
|
||||
err.message.includes('n8n-nodes-base.webhook')
|
||||
);
|
||||
logger.info(`Caught nodes-base.webhook error: ${hasNodeBaseError ? 'YES ✅' : 'NO ❌'}`);
|
||||
|
||||
// Test 2: Node type without any prefix
|
||||
const noPrefixWorkflow = {
|
||||
"name": "Test Workflow",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "webhook-1",
|
||||
"name": "My Webhook",
|
||||
"type": "webhook", // WRONG! No package prefix
|
||||
"typeVersion": 2,
|
||||
"position": [250, 300] as [number, number],
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"id": "set-1",
|
||||
"name": "Set Data",
|
||||
"type": "set", // WRONG! No package prefix
|
||||
"typeVersion": 3.4,
|
||||
"position": [450, 300] as [number, number],
|
||||
"parameters": {}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"My Webhook": {
|
||||
"main": [[{
|
||||
"node": "Set Data",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('\nTest 2: Node types without package prefix ("webhook", "set")');
|
||||
const result2 = await validator.validateWorkflow(noPrefixWorkflow as any);
|
||||
|
||||
logger.info('Validation result:');
|
||||
logger.info(`Valid: ${result2.valid}`);
|
||||
logger.info(`Errors: ${result2.errors.length}`);
|
||||
result2.errors.forEach(err => {
|
||||
if (typeof err === 'string') {
|
||||
logger.error(` - ${err}`);
|
||||
} else if (err && typeof err === 'object' && 'message' in err) {
|
||||
logger.error(` - ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 3: Completely invalid node type
|
||||
const invalidNodeWorkflow = {
|
||||
"name": "Test Workflow",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "fake-1",
|
||||
"name": "Fake Node",
|
||||
"type": "n8n-nodes-base.fakeNodeThatDoesNotExist",
|
||||
"typeVersion": 1,
|
||||
"position": [250, 300] as [number, number],
|
||||
"parameters": {}
|
||||
}
|
||||
],
|
||||
"connections": {}
|
||||
};
|
||||
|
||||
logger.info('\nTest 3: Completely invalid node type');
|
||||
const result3 = await validator.validateWorkflow(invalidNodeWorkflow as any);
|
||||
|
||||
logger.info('Validation result:');
|
||||
logger.info(`Valid: ${result3.valid}`);
|
||||
logger.info(`Errors: ${result3.errors.length}`);
|
||||
result3.errors.forEach(err => {
|
||||
if (typeof err === 'string') {
|
||||
logger.error(` - ${err}`);
|
||||
} else if (err && typeof err === 'object' && 'message' in err) {
|
||||
logger.error(` - ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 4: Using n8n-validation.ts function
|
||||
logger.info('\nTest 4: Testing n8n-validation.ts with invalid node types');
|
||||
|
||||
const errors = validateWorkflowStructure(brokenWorkflowFromLogs as any);
|
||||
logger.info('Validation errors:');
|
||||
errors.forEach(err => logger.error(` - ${err}`));
|
||||
|
||||
// Test 5: Valid workflow (should pass)
|
||||
const validWorkflow = {
|
||||
"name": "Valid Webhook Workflow",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "webhook-1",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook", // CORRECT!
|
||||
"typeVersion": 2,
|
||||
"position": [250, 300] as [number, number],
|
||||
"parameters": {
|
||||
"path": "my-webhook",
|
||||
"responseMode": "onReceived",
|
||||
"responseData": "allEntries"
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {}
|
||||
};
|
||||
|
||||
logger.info('\nTest 5: Valid workflow with correct node type');
|
||||
const result5 = await validator.validateWorkflow(validWorkflow as any);
|
||||
|
||||
logger.info('Validation result:');
|
||||
logger.info(`Valid: ${result5.valid}`);
|
||||
logger.info(`Errors: ${result5.errors.length}`);
|
||||
logger.info(`Warnings: ${result5.warnings.length}`);
|
||||
result5.warnings.forEach(warn => {
|
||||
if (warn && typeof warn === 'object' && 'message' in warn) {
|
||||
logger.warn(` - ${warn.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
adapter.close();
|
||||
}
|
||||
|
||||
testValidation().catch(err => {
|
||||
logger.error('Test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Specific test for nodes-base. prefix validation
|
||||
*/
|
||||
|
||||
import { WorkflowValidator } from '../src/services/workflow-validator';
|
||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
|
||||
import { NodeRepository } from '../src/database/node-repository';
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { Logger } from '../src/utils/logger';
|
||||
|
||||
const logger = new Logger({ prefix: '[TestNodesBasePrefix]' });
|
||||
|
||||
async function testValidation() {
|
||||
const adapter = await createDatabaseAdapter('./data/nodes.db');
|
||||
const repository = new NodeRepository(adapter);
|
||||
const validator = new WorkflowValidator(repository, EnhancedConfigValidator);
|
||||
|
||||
logger.info('Testing nodes-base. prefix validation...\n');
|
||||
|
||||
// Test various nodes-base. prefixed types
|
||||
const testCases = [
|
||||
{ type: 'nodes-base.webhook', expected: 'n8n-nodes-base.webhook' },
|
||||
{ type: 'nodes-base.httpRequest', expected: 'n8n-nodes-base.httpRequest' },
|
||||
{ type: 'nodes-base.set', expected: 'n8n-nodes-base.set' },
|
||||
{ type: 'nodes-base.code', expected: 'n8n-nodes-base.code' },
|
||||
{ type: 'nodes-base.slack', expected: 'n8n-nodes-base.slack' },
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const workflow = {
|
||||
name: `Test ${testCase.type}`,
|
||||
nodes: [{
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: testCase.type,
|
||||
typeVersion: 1,
|
||||
position: [100, 100] as [number, number],
|
||||
parameters: {}
|
||||
}],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
logger.info(`Testing: "${testCase.type}"`);
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const nodeTypeError = result.errors.find(err =>
|
||||
err && typeof err === 'object' && 'message' in err &&
|
||||
err.message.includes(testCase.type) &&
|
||||
err.message.includes(testCase.expected)
|
||||
);
|
||||
|
||||
if (nodeTypeError) {
|
||||
logger.info(`✅ Caught and suggested: "${testCase.expected}"`);
|
||||
} else {
|
||||
logger.error(`❌ Failed to catch invalid type: "${testCase.type}"`);
|
||||
result.errors.forEach(err => {
|
||||
if (err && typeof err === 'object' && 'message' in err) {
|
||||
logger.error(` Error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Test that n8n-nodes-base. prefix still works
|
||||
const validWorkflow = {
|
||||
name: 'Valid Workflow',
|
||||
nodes: [{
|
||||
id: 'webhook',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [100, 100] as [number, number],
|
||||
parameters: {}
|
||||
}],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
logger.info('\nTesting valid n8n-nodes-base.webhook:');
|
||||
const validResult = await validator.validateWorkflow(validWorkflow as any);
|
||||
|
||||
const hasNodeTypeError = validResult.errors.some(err =>
|
||||
err && typeof err === 'object' && 'message' in err &&
|
||||
err.message.includes('node type')
|
||||
);
|
||||
|
||||
if (!hasNodeTypeError) {
|
||||
logger.info('✅ Correctly accepted n8n-nodes-base.webhook');
|
||||
} else {
|
||||
logger.error('❌ Incorrectly rejected valid n8n-nodes-base.webhook');
|
||||
}
|
||||
|
||||
adapter.close();
|
||||
}
|
||||
|
||||
testValidation().catch(err => {
|
||||
logger.error('Test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Test script for operation and resource validation with Google Drive example
|
||||
*/
|
||||
|
||||
import { DatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { NodeRepository } from '../src/database/node-repository';
|
||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
|
||||
import { WorkflowValidator } from '../src/services/workflow-validator';
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { logger } from '../src/utils/logger';
|
||||
import chalk from 'chalk';
|
||||
|
||||
async function testOperationValidation() {
|
||||
console.log(chalk.blue('Testing Operation and Resource Validation'));
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// Initialize database
|
||||
const dbPath = process.env.NODE_DB_PATH || 'data/nodes.db';
|
||||
const db = await createDatabaseAdapter(dbPath);
|
||||
const repository = new NodeRepository(db);
|
||||
|
||||
// Initialize similarity services
|
||||
EnhancedConfigValidator.initializeSimilarityServices(repository);
|
||||
|
||||
// Test 1: Invalid operation "listFiles"
|
||||
console.log(chalk.yellow('\n📝 Test 1: Google Drive with invalid operation "listFiles"'));
|
||||
const invalidConfig = {
|
||||
resource: 'fileFolder',
|
||||
operation: 'listFiles'
|
||||
};
|
||||
|
||||
const node = repository.getNode('nodes-base.googleDrive');
|
||||
if (!node) {
|
||||
console.error(chalk.red('Google Drive node not found in database'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result1 = EnhancedConfigValidator.validateWithMode(
|
||||
'nodes-base.googleDrive',
|
||||
invalidConfig,
|
||||
node.properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
console.log(`Valid: ${result1.valid ? chalk.green('✓') : chalk.red('✗')}`);
|
||||
if (result1.errors.length > 0) {
|
||||
console.log(chalk.red('Errors:'));
|
||||
result1.errors.forEach(error => {
|
||||
console.log(` - ${error.property}: ${error.message}`);
|
||||
if (error.fix) {
|
||||
console.log(chalk.cyan(` Fix: ${error.fix}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Test 2: Invalid resource "files" (should be singular)
|
||||
console.log(chalk.yellow('\n📝 Test 2: Google Drive with invalid resource "files"'));
|
||||
const pluralResourceConfig = {
|
||||
resource: 'files',
|
||||
operation: 'download'
|
||||
};
|
||||
|
||||
const result2 = EnhancedConfigValidator.validateWithMode(
|
||||
'nodes-base.googleDrive',
|
||||
pluralResourceConfig,
|
||||
node.properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
console.log(`Valid: ${result2.valid ? chalk.green('✓') : chalk.red('✗')}`);
|
||||
if (result2.errors.length > 0) {
|
||||
console.log(chalk.red('Errors:'));
|
||||
result2.errors.forEach(error => {
|
||||
console.log(` - ${error.property}: ${error.message}`);
|
||||
if (error.fix) {
|
||||
console.log(chalk.cyan(` Fix: ${error.fix}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Test 3: Valid configuration
|
||||
console.log(chalk.yellow('\n📝 Test 3: Google Drive with valid configuration'));
|
||||
const validConfig = {
|
||||
resource: 'file',
|
||||
operation: 'download'
|
||||
};
|
||||
|
||||
const result3 = EnhancedConfigValidator.validateWithMode(
|
||||
'nodes-base.googleDrive',
|
||||
validConfig,
|
||||
node.properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
console.log(`Valid: ${result3.valid ? chalk.green('✓') : chalk.red('✗')}`);
|
||||
if (result3.errors.length > 0) {
|
||||
console.log(chalk.red('Errors:'));
|
||||
result3.errors.forEach(error => {
|
||||
console.log(` - ${error.property}: ${error.message}`);
|
||||
});
|
||||
} else {
|
||||
console.log(chalk.green('No errors - configuration is valid!'));
|
||||
}
|
||||
|
||||
// Test 4: Test in workflow context
|
||||
console.log(chalk.yellow('\n📝 Test 4: Full workflow with invalid Google Drive node'));
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Google Drive',
|
||||
type: 'n8n-nodes-base.googleDrive',
|
||||
position: [100, 100] as [number, number],
|
||||
parameters: {
|
||||
resource: 'fileFolder',
|
||||
operation: 'listFiles' // Invalid operation
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const validator = new WorkflowValidator(repository, EnhancedConfigValidator);
|
||||
const workflowResult = await validator.validateWorkflow(workflow, {
|
||||
validateNodes: true,
|
||||
profile: 'ai-friendly'
|
||||
});
|
||||
|
||||
console.log(`Workflow Valid: ${workflowResult.valid ? chalk.green('✓') : chalk.red('✗')}`);
|
||||
if (workflowResult.errors.length > 0) {
|
||||
console.log(chalk.red('Errors:'));
|
||||
workflowResult.errors.forEach(error => {
|
||||
console.log(` - ${error.nodeName || 'Workflow'}: ${error.message}`);
|
||||
if (error.details?.fix) {
|
||||
console.log(chalk.cyan(` Fix: ${error.details.fix}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Test 5: Typo in operation
|
||||
console.log(chalk.yellow('\n📝 Test 5: Typo in operation "downlod"'));
|
||||
const typoConfig = {
|
||||
resource: 'file',
|
||||
operation: 'downlod' // Typo
|
||||
};
|
||||
|
||||
const result5 = EnhancedConfigValidator.validateWithMode(
|
||||
'nodes-base.googleDrive',
|
||||
typoConfig,
|
||||
node.properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
console.log(`Valid: ${result5.valid ? chalk.green('✓') : chalk.red('✗')}`);
|
||||
if (result5.errors.length > 0) {
|
||||
console.log(chalk.red('Errors:'));
|
||||
result5.errors.forEach(error => {
|
||||
console.log(` - ${error.property}: ${error.message}`);
|
||||
if (error.fix) {
|
||||
console.log(chalk.cyan(` Fix: ${error.fix}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log(chalk.green('\n✅ All tests completed!'));
|
||||
db.close();
|
||||
}
|
||||
|
||||
// Run tests
|
||||
testOperationValidation().catch(error => {
|
||||
console.error(chalk.red('Error running tests:'), error);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/bin/bash
|
||||
# Test script for optimized Docker build
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧪 Testing Optimized Docker Build"
|
||||
echo "================================="
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Build the optimized image
|
||||
echo -e "\n📦 Building optimized Docker image..."
|
||||
docker build -f Dockerfile.optimized -t n8n-mcp:optimized .
|
||||
|
||||
# Check image size
|
||||
echo -e "\n📊 Checking image size..."
|
||||
SIZE=$(docker images n8n-mcp:optimized --format "{{.Size}}")
|
||||
echo "Image size: $SIZE"
|
||||
|
||||
# Extract size in MB for comparison
|
||||
SIZE_MB=$(docker images n8n-mcp:optimized --format "{{.Size}}" | sed 's/MB//' | sed 's/GB/*1024/' | bc 2>/dev/null || echo "0")
|
||||
if [ "$SIZE_MB" != "0" ] && [ "$SIZE_MB" -lt "300" ]; then
|
||||
echo -e "${GREEN}✅ Image size is optimized (<300MB)${NC}"
|
||||
else
|
||||
echo -e "${RED}⚠️ Image might be larger than expected${NC}"
|
||||
fi
|
||||
|
||||
# Test stdio mode
|
||||
echo -e "\n🔍 Testing stdio mode..."
|
||||
TEST_RESULT=$(echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | \
|
||||
docker run --rm -i -e MCP_MODE=stdio n8n-mcp:optimized 2>/dev/null | \
|
||||
grep -o '"name":"[^"]*"' | head -1)
|
||||
|
||||
if [ -n "$TEST_RESULT" ]; then
|
||||
echo -e "${GREEN}✅ Stdio mode working${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Stdio mode failed${NC}"
|
||||
fi
|
||||
|
||||
# Test HTTP mode
|
||||
echo -e "\n🌐 Testing HTTP mode..."
|
||||
docker run -d --name test-optimized \
|
||||
-e MCP_MODE=http \
|
||||
-e AUTH_TOKEN=test-token \
|
||||
-p 3002:3000 \
|
||||
n8n-mcp:optimized
|
||||
|
||||
# Wait for startup
|
||||
sleep 5
|
||||
|
||||
# Test health endpoint
|
||||
HEALTH=$(curl -s http://localhost:3002/health | grep -o '"status":"healthy"' || echo "")
|
||||
if [ -n "$HEALTH" ]; then
|
||||
echo -e "${GREEN}✅ Health endpoint working${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Health endpoint failed${NC}"
|
||||
fi
|
||||
|
||||
# Test MCP endpoint
|
||||
MCP_TEST=$(curl -s -H "Authorization: Bearer test-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
|
||||
http://localhost:3002/mcp | grep -o '"tools":\[' || echo "")
|
||||
|
||||
if [ -n "$MCP_TEST" ]; then
|
||||
echo -e "${GREEN}✅ MCP endpoint working${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ MCP endpoint failed${NC}"
|
||||
fi
|
||||
|
||||
# Test database statistics tool
|
||||
STATS_TEST=$(curl -s -H "Authorization: Bearer test-token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_database_statistics","arguments":{}},"id":2}' \
|
||||
http://localhost:3002/mcp | grep -o '"totalNodes":[0-9]*' || echo "")
|
||||
|
||||
if [ -n "$STATS_TEST" ]; then
|
||||
echo -e "${GREEN}✅ Database statistics tool working${NC}"
|
||||
echo "Database stats: $STATS_TEST"
|
||||
else
|
||||
echo -e "${RED}❌ Database statistics tool failed${NC}"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
docker stop test-optimized >/dev/null 2>&1
|
||||
docker rm test-optimized >/dev/null 2>&1
|
||||
|
||||
# Compare with original image
|
||||
echo -e "\n📊 Size Comparison:"
|
||||
echo "Original image: $(docker images n8n-mcp:latest --format "{{.Size}}" 2>/dev/null || echo "Not built")"
|
||||
echo "Optimized image: $SIZE"
|
||||
|
||||
echo -e "\n✨ Testing complete!"
|
||||
Executable
+583
@@ -0,0 +1,583 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script for release automation
|
||||
* Validates the release workflow components locally
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Color codes for output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m'
|
||||
};
|
||||
|
||||
function log(message, color = 'reset') {
|
||||
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function header(title) {
|
||||
log(`\n${'='.repeat(60)}`, 'cyan');
|
||||
log(`🧪 ${title}`, 'cyan');
|
||||
log(`${'='.repeat(60)}`, 'cyan');
|
||||
}
|
||||
|
||||
function section(title) {
|
||||
log(`\n📋 ${title}`, 'blue');
|
||||
log(`${'-'.repeat(40)}`, 'blue');
|
||||
}
|
||||
|
||||
function success(message) {
|
||||
log(`✅ ${message}`, 'green');
|
||||
}
|
||||
|
||||
function warning(message) {
|
||||
log(`⚠️ ${message}`, 'yellow');
|
||||
}
|
||||
|
||||
function error(message) {
|
||||
log(`❌ ${message}`, 'red');
|
||||
}
|
||||
|
||||
function info(message) {
|
||||
log(`ℹ️ ${message}`, 'blue');
|
||||
}
|
||||
|
||||
class ReleaseAutomationTester {
|
||||
constructor() {
|
||||
this.rootDir = path.resolve(__dirname, '..');
|
||||
this.errors = [];
|
||||
this.warnings = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if required files exist
|
||||
*/
|
||||
testFileExistence() {
|
||||
section('Testing File Existence');
|
||||
|
||||
const requiredFiles = [
|
||||
'package.json',
|
||||
'package.runtime.json',
|
||||
'docs/CHANGELOG.md',
|
||||
'.github/workflows/release.yml',
|
||||
'scripts/sync-runtime-version.js',
|
||||
'scripts/publish-npm.sh'
|
||||
];
|
||||
|
||||
for (const file of requiredFiles) {
|
||||
const filePath = path.join(this.rootDir, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
success(`Found: ${file}`);
|
||||
} else {
|
||||
error(`Missing: ${file}`);
|
||||
this.errors.push(`Missing required file: ${file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test version detection logic
|
||||
*/
|
||||
testVersionDetection() {
|
||||
section('Testing Version Detection');
|
||||
|
||||
try {
|
||||
const packageJson = require(path.join(this.rootDir, 'package.json'));
|
||||
const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json'));
|
||||
|
||||
success(`Package.json version: ${packageJson.version}`);
|
||||
success(`Runtime package version: ${runtimeJson.version}`);
|
||||
|
||||
if (packageJson.version === runtimeJson.version) {
|
||||
success('Version sync: Both versions match');
|
||||
} else {
|
||||
warning('Version sync: Versions do not match - run sync:runtime-version');
|
||||
this.warnings.push('Package versions are not synchronized');
|
||||
}
|
||||
|
||||
// Test semantic version format
|
||||
const semverRegex = /^\d+\.\d+\.\d+(?:-[\w\.-]+)?(?:\+[\w\.-]+)?$/;
|
||||
if (semverRegex.test(packageJson.version)) {
|
||||
success(`Version format: Valid semantic version (${packageJson.version})`);
|
||||
} else {
|
||||
error(`Version format: Invalid semantic version (${packageJson.version})`);
|
||||
this.errors.push('Invalid semantic version format');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error(`Version detection failed: ${err.message}`);
|
||||
this.errors.push(`Version detection error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test changelog parsing
|
||||
*/
|
||||
testChangelogParsing() {
|
||||
section('Testing Changelog Parsing');
|
||||
|
||||
try {
|
||||
const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md');
|
||||
|
||||
if (!fs.existsSync(changelogPath)) {
|
||||
error('Changelog file not found');
|
||||
this.errors.push('Missing changelog file');
|
||||
return;
|
||||
}
|
||||
|
||||
const changelogContent = fs.readFileSync(changelogPath, 'utf8');
|
||||
const packageJson = require(path.join(this.rootDir, 'package.json'));
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
// Check if current version exists in changelog
|
||||
const versionRegex = new RegExp(`^## \\[${currentVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm');
|
||||
|
||||
if (versionRegex.test(changelogContent)) {
|
||||
success(`Changelog entry found for version ${currentVersion}`);
|
||||
|
||||
// Test extraction logic (simplified version of the GitHub Actions script)
|
||||
const lines = changelogContent.split('\n');
|
||||
let startIndex = -1;
|
||||
let endIndex = -1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (versionRegex.test(lines[i])) {
|
||||
startIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startIndex !== -1) {
|
||||
// Find the end of this version's section
|
||||
for (let i = startIndex + 1; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('## [') && !lines[i].includes('Unreleased')) {
|
||||
endIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex === -1) {
|
||||
endIndex = lines.length;
|
||||
}
|
||||
|
||||
const sectionLines = lines.slice(startIndex + 1, endIndex);
|
||||
const contentLines = sectionLines.filter(line => line.trim() !== '');
|
||||
|
||||
if (contentLines.length > 0) {
|
||||
success(`Changelog content extracted: ${contentLines.length} lines`);
|
||||
info(`Preview: ${contentLines[0].substring(0, 100)}...`);
|
||||
} else {
|
||||
warning('Changelog section appears to be empty');
|
||||
this.warnings.push(`Empty changelog section for version ${currentVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
warning(`No changelog entry found for current version ${currentVersion}`);
|
||||
this.warnings.push(`Missing changelog entry for version ${currentVersion}`);
|
||||
}
|
||||
|
||||
// Check changelog format
|
||||
if (changelogContent.includes('## [Unreleased]')) {
|
||||
success('Changelog format: Contains Unreleased section');
|
||||
} else {
|
||||
warning('Changelog format: Missing Unreleased section');
|
||||
}
|
||||
|
||||
if (changelogContent.includes('Keep a Changelog')) {
|
||||
success('Changelog format: Follows Keep a Changelog format');
|
||||
} else {
|
||||
warning('Changelog format: Does not reference Keep a Changelog');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error(`Changelog parsing failed: ${err.message}`);
|
||||
this.errors.push(`Changelog parsing error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test build process
|
||||
*/
|
||||
testBuildProcess() {
|
||||
section('Testing Build Process');
|
||||
|
||||
try {
|
||||
// Check if dist directory exists
|
||||
const distPath = path.join(this.rootDir, 'dist');
|
||||
if (fs.existsSync(distPath)) {
|
||||
success('Build output: dist directory exists');
|
||||
|
||||
// Check for key build files
|
||||
const keyFiles = [
|
||||
'dist/index.js',
|
||||
'dist/mcp/index.js',
|
||||
'dist/mcp/server.js'
|
||||
];
|
||||
|
||||
for (const file of keyFiles) {
|
||||
const filePath = path.join(this.rootDir, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
success(`Build file: ${file} exists`);
|
||||
} else {
|
||||
warning(`Build file: ${file} missing - run 'npm run build'`);
|
||||
this.warnings.push(`Missing build file: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
warning('Build output: dist directory missing - run "npm run build"');
|
||||
this.warnings.push('Missing build output');
|
||||
}
|
||||
|
||||
// Check database
|
||||
const dbPath = path.join(this.rootDir, 'data/nodes.db');
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const stats = fs.statSync(dbPath);
|
||||
success(`Database: nodes.db exists (${Math.round(stats.size / 1024 / 1024)}MB)`);
|
||||
} else {
|
||||
warning('Database: nodes.db missing - run "npm run rebuild"');
|
||||
this.warnings.push('Missing database file');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error(`Build process test failed: ${err.message}`);
|
||||
this.errors.push(`Build process error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test npm publish preparation
|
||||
*/
|
||||
testNpmPublishPrep() {
|
||||
section('Testing NPM Publish Preparation');
|
||||
|
||||
try {
|
||||
const packageJson = require(path.join(this.rootDir, 'package.json'));
|
||||
const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json'));
|
||||
|
||||
// Check package.json fields
|
||||
const requiredFields = ['name', 'version', 'description', 'main', 'bin'];
|
||||
for (const field of requiredFields) {
|
||||
if (packageJson[field]) {
|
||||
success(`Package field: ${field} is present`);
|
||||
} else {
|
||||
error(`Package field: ${field} is missing`);
|
||||
this.errors.push(`Missing package.json field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check runtime dependencies
|
||||
if (runtimeJson.dependencies) {
|
||||
const depCount = Object.keys(runtimeJson.dependencies).length;
|
||||
success(`Runtime dependencies: ${depCount} packages`);
|
||||
|
||||
// List key dependencies
|
||||
const keyDeps = ['@modelcontextprotocol/sdk', 'express', 'sql.js'];
|
||||
for (const dep of keyDeps) {
|
||||
if (runtimeJson.dependencies[dep]) {
|
||||
success(`Key dependency: ${dep} (${runtimeJson.dependencies[dep]})`);
|
||||
} else {
|
||||
warning(`Key dependency: ${dep} is missing`);
|
||||
this.warnings.push(`Missing key dependency: ${dep}`);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
error('Runtime package has no dependencies');
|
||||
this.errors.push('Missing runtime dependencies');
|
||||
}
|
||||
|
||||
// Check files array
|
||||
if (packageJson.files && Array.isArray(packageJson.files)) {
|
||||
success(`Package files: ${packageJson.files.length} patterns specified`);
|
||||
info(`Files: ${packageJson.files.join(', ')}`);
|
||||
} else {
|
||||
warning('Package files: No files array specified');
|
||||
this.warnings.push('No files array in package.json');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error(`NPM publish prep test failed: ${err.message}`);
|
||||
this.errors.push(`NPM publish prep error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Docker configuration
|
||||
*/
|
||||
testDockerConfig() {
|
||||
section('Testing Docker Configuration');
|
||||
|
||||
try {
|
||||
const dockerfiles = ['Dockerfile', 'Dockerfile.railway'];
|
||||
|
||||
for (const dockerfile of dockerfiles) {
|
||||
const dockerfilePath = path.join(this.rootDir, dockerfile);
|
||||
if (fs.existsSync(dockerfilePath)) {
|
||||
success(`Dockerfile: ${dockerfile} exists`);
|
||||
|
||||
const content = fs.readFileSync(dockerfilePath, 'utf8');
|
||||
|
||||
// Check for key instructions
|
||||
if (content.includes('FROM node:')) {
|
||||
success(`${dockerfile}: Uses Node.js base image`);
|
||||
} else {
|
||||
warning(`${dockerfile}: Does not use standard Node.js base image`);
|
||||
}
|
||||
|
||||
if (content.includes('COPY dist')) {
|
||||
success(`${dockerfile}: Copies build output`);
|
||||
} else {
|
||||
warning(`${dockerfile}: May not copy build output correctly`);
|
||||
}
|
||||
|
||||
} else {
|
||||
warning(`Dockerfile: ${dockerfile} not found`);
|
||||
this.warnings.push(`Missing Dockerfile: ${dockerfile}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check docker-compose files
|
||||
const composeFiles = ['docker-compose.yml', 'docker-compose.n8n.yml'];
|
||||
for (const composeFile of composeFiles) {
|
||||
const composePath = path.join(this.rootDir, composeFile);
|
||||
if (fs.existsSync(composePath)) {
|
||||
success(`Docker Compose: ${composeFile} exists`);
|
||||
} else {
|
||||
info(`Docker Compose: ${composeFile} not found (optional)`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error(`Docker config test failed: ${err.message}`);
|
||||
this.errors.push(`Docker config error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test workflow file syntax
|
||||
*/
|
||||
testWorkflowSyntax() {
|
||||
section('Testing Workflow Syntax');
|
||||
|
||||
try {
|
||||
const workflowPath = path.join(this.rootDir, '.github/workflows/release.yml');
|
||||
|
||||
if (!fs.existsSync(workflowPath)) {
|
||||
error('Release workflow file not found');
|
||||
this.errors.push('Missing release workflow file');
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowContent = fs.readFileSync(workflowPath, 'utf8');
|
||||
|
||||
// Basic YAML structure checks
|
||||
if (workflowContent.includes('name: Automated Release')) {
|
||||
success('Workflow: Has correct name');
|
||||
} else {
|
||||
warning('Workflow: Name may be incorrect');
|
||||
}
|
||||
|
||||
if (workflowContent.includes('on:') && workflowContent.includes('push:')) {
|
||||
success('Workflow: Has push trigger');
|
||||
} else {
|
||||
error('Workflow: Missing push trigger');
|
||||
this.errors.push('Workflow missing push trigger');
|
||||
}
|
||||
|
||||
if (workflowContent.includes('branches: [main]')) {
|
||||
success('Workflow: Configured for main branch');
|
||||
} else {
|
||||
warning('Workflow: May not be configured for main branch');
|
||||
}
|
||||
|
||||
// Check for required jobs
|
||||
const requiredJobs = [
|
||||
'detect-version-change',
|
||||
'extract-changelog',
|
||||
'create-release',
|
||||
'publish-npm',
|
||||
'build-docker'
|
||||
];
|
||||
|
||||
for (const job of requiredJobs) {
|
||||
if (workflowContent.includes(`${job}:`)) {
|
||||
success(`Workflow job: ${job} defined`);
|
||||
} else {
|
||||
error(`Workflow job: ${job} missing`);
|
||||
this.errors.push(`Missing workflow job: ${job}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for npm Trusted Publisher (OIDC) configuration — scoped to the publish-npm job
|
||||
// Slice from " publish-npm:" to the next job at the same indent (2 spaces, non-space char)
|
||||
const publishNpmMatch = workflowContent.match(/^ {2}publish-npm:\n([\s\S]*?)(?=^ {2}\S|\Z)/m);
|
||||
const publishNpmBlock = publishNpmMatch ? publishNpmMatch[1] : '';
|
||||
|
||||
if (!publishNpmBlock) {
|
||||
error('Workflow: could not locate publish-npm job block for OIDC checks');
|
||||
this.errors.push('publish-npm job not found in workflow');
|
||||
} else {
|
||||
if (/\bid-token:\s*write\b/.test(publishNpmBlock)) {
|
||||
success('Workflow: publish-npm has id-token: write permission (OIDC)');
|
||||
} else {
|
||||
error('Workflow: publish-npm is missing id-token: write permission');
|
||||
this.errors.push('Trusted Publishing requires id-token: write on publish-npm job');
|
||||
}
|
||||
|
||||
if (/\benvironment:\s*npm-publish\b/.test(publishNpmBlock)) {
|
||||
success('Workflow: publish-npm uses npm-publish environment');
|
||||
} else {
|
||||
error('Workflow: publish-npm is missing environment: npm-publish');
|
||||
this.errors.push('Trusted Publishing requires environment: npm-publish on publish-npm job');
|
||||
}
|
||||
}
|
||||
|
||||
if (workflowContent.includes('${{ secrets.NPM_TOKEN }}')) {
|
||||
warning('Workflow: stale NPM_TOKEN reference found — Trusted Publishing makes this unnecessary');
|
||||
this.warnings.push('Remove ${{ secrets.NPM_TOKEN }} from workflow now that OIDC is used');
|
||||
}
|
||||
|
||||
if (workflowContent.includes('${{ secrets.GITHUB_TOKEN }}')) {
|
||||
success('Workflow: GITHUB_TOKEN secret configured');
|
||||
} else {
|
||||
warning('Workflow: GITHUB_TOKEN secret may be missing');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error(`Workflow syntax test failed: ${err.message}`);
|
||||
this.errors.push(`Workflow syntax error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test environment and dependencies
|
||||
*/
|
||||
testEnvironment() {
|
||||
section('Testing Environment');
|
||||
|
||||
try {
|
||||
// Check Node.js version
|
||||
const nodeVersion = process.version;
|
||||
success(`Node.js version: ${nodeVersion}`);
|
||||
|
||||
// Check if npm is available
|
||||
try {
|
||||
const npmVersion = execSync('npm --version', { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||
success(`NPM version: ${npmVersion}`);
|
||||
} catch (err) {
|
||||
error('NPM not available');
|
||||
this.errors.push('NPM not available');
|
||||
}
|
||||
|
||||
// Check if git is available
|
||||
try {
|
||||
const gitVersion = execSync('git --version', { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||
success(`Git available: ${gitVersion}`);
|
||||
} catch (err) {
|
||||
error('Git not available');
|
||||
this.errors.push('Git not available');
|
||||
}
|
||||
|
||||
// Check if we're in a git repository
|
||||
try {
|
||||
execSync('git rev-parse --git-dir', { stdio: 'pipe' });
|
||||
success('Git repository: Detected');
|
||||
|
||||
// Check current branch
|
||||
try {
|
||||
const branch = execSync('git branch --show-current', { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||
info(`Current branch: ${branch}`);
|
||||
} catch (err) {
|
||||
info('Could not determine current branch');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
warning('Not in a git repository');
|
||||
this.warnings.push('Not in a git repository');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error(`Environment test failed: ${err.message}`);
|
||||
this.errors.push(`Environment error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all tests
|
||||
*/
|
||||
async runAllTests() {
|
||||
header('Release Automation Test Suite');
|
||||
|
||||
info('Testing release automation components...');
|
||||
|
||||
this.testFileExistence();
|
||||
this.testVersionDetection();
|
||||
this.testChangelogParsing();
|
||||
this.testBuildProcess();
|
||||
this.testNpmPublishPrep();
|
||||
this.testDockerConfig();
|
||||
this.testWorkflowSyntax();
|
||||
this.testEnvironment();
|
||||
|
||||
// Summary
|
||||
header('Test Summary');
|
||||
|
||||
if (this.errors.length === 0 && this.warnings.length === 0) {
|
||||
log('🎉 All tests passed! Release automation is ready.', 'green');
|
||||
} else {
|
||||
if (this.errors.length > 0) {
|
||||
log(`\n❌ ${this.errors.length} Error(s):`, 'red');
|
||||
this.errors.forEach(err => log(` • ${err}`, 'red'));
|
||||
}
|
||||
|
||||
if (this.warnings.length > 0) {
|
||||
log(`\n⚠️ ${this.warnings.length} Warning(s):`, 'yellow');
|
||||
this.warnings.forEach(warn => log(` • ${warn}`, 'yellow'));
|
||||
}
|
||||
|
||||
if (this.errors.length > 0) {
|
||||
log('\n🔧 Please fix the errors before running the release workflow.', 'red');
|
||||
process.exit(1);
|
||||
} else {
|
||||
log('\n✅ No critical errors found. Warnings should be reviewed but won\'t prevent releases.', 'yellow');
|
||||
}
|
||||
}
|
||||
|
||||
// Next steps
|
||||
log('\n📋 Next Steps:', 'cyan');
|
||||
log('1. Ensure GitHub repository settings are configured:', 'cyan');
|
||||
log(' • Secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN', 'cyan');
|
||||
log(' • Environment "npm-publish" exists (Settings → Environments)', 'cyan');
|
||||
log(' • npm Trusted Publisher configured on npmjs.com (no NPM_TOKEN secret needed)', 'cyan');
|
||||
log(' • GITHUB_TOKEN is provided automatically', 'cyan');
|
||||
log('\n2. To trigger a release:', 'cyan');
|
||||
log(' • Update version in package.json', 'cyan');
|
||||
log(' • Update changelog in docs/CHANGELOG.md', 'cyan');
|
||||
log(' • Commit and push to main branch', 'cyan');
|
||||
log('\n3. Monitor the release workflow in GitHub Actions', 'cyan');
|
||||
|
||||
return this.errors.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Run the tests
|
||||
if (require.main === module) {
|
||||
const tester = new ReleaseAutomationTester();
|
||||
tester.runAllTests().catch(err => {
|
||||
console.error('Test suite failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = ReleaseAutomationTester;
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { N8NDocumentationMCPServer } from '../src/mcp/server';
|
||||
|
||||
interface SearchTestCase {
|
||||
query: string;
|
||||
expectedTop: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
async function testSearchImprovements() {
|
||||
console.log('Testing search improvements...\n');
|
||||
|
||||
const server = new N8NDocumentationMCPServer();
|
||||
|
||||
// Wait for initialization
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const testCases: SearchTestCase[] = [
|
||||
{
|
||||
query: 'webhook',
|
||||
expectedTop: ['nodes-base.webhook'],
|
||||
description: 'Primary webhook node should appear first'
|
||||
},
|
||||
{
|
||||
query: 'http',
|
||||
expectedTop: ['nodes-base.httpRequest'],
|
||||
description: 'HTTP Request node should appear first'
|
||||
},
|
||||
{
|
||||
query: 'http call',
|
||||
expectedTop: ['nodes-base.httpRequest'],
|
||||
description: 'HTTP Request node should appear first for "http call"'
|
||||
},
|
||||
{
|
||||
query: 'slack',
|
||||
expectedTop: ['nodes-base.slack'],
|
||||
description: 'Slack node should appear first'
|
||||
},
|
||||
{
|
||||
query: 'email',
|
||||
expectedTop: ['nodes-base.emailSend', 'nodes-base.gmail', 'nodes-base.emailReadImap'],
|
||||
description: 'Email-related nodes should appear first'
|
||||
},
|
||||
{
|
||||
query: 'http request',
|
||||
expectedTop: ['nodes-base.httpRequest'],
|
||||
description: 'HTTP Request node should appear first for exact name'
|
||||
}
|
||||
];
|
||||
|
||||
let passedTests = 0;
|
||||
let failedTests = 0;
|
||||
|
||||
for (const testCase of testCases) {
|
||||
try {
|
||||
console.log(`\nTest: ${testCase.description}`);
|
||||
console.log(`Query: "${testCase.query}"`);
|
||||
|
||||
const results = await server.executeTool('search_nodes', {
|
||||
query: testCase.query,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
if (!results.results || results.results.length === 0) {
|
||||
console.log('❌ No results found');
|
||||
failedTests++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Found ${results.results.length} results`);
|
||||
console.log('Top 5 results:');
|
||||
|
||||
const top5 = results.results.slice(0, 5);
|
||||
top5.forEach((node: any, index: number) => {
|
||||
const isExpected = testCase.expectedTop.includes(node.nodeType);
|
||||
const marker = index === 0 && isExpected ? '✅' : index === 0 && !isExpected ? '❌' : '';
|
||||
console.log(` ${index + 1}. ${node.nodeType} - ${node.displayName} ${marker}`);
|
||||
});
|
||||
|
||||
// Check if any expected node appears in top position
|
||||
const firstResult = results.results[0];
|
||||
if (testCase.expectedTop.includes(firstResult.nodeType)) {
|
||||
console.log('✅ Test passed: Expected node found at top position');
|
||||
passedTests++;
|
||||
} else {
|
||||
console.log('❌ Test failed: Expected nodes not at top position');
|
||||
console.log(` Expected one of: ${testCase.expectedTop.join(', ')}`);
|
||||
console.log(` Got: ${firstResult.nodeType}`);
|
||||
failedTests++;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(`❌ Test failed with error: ${error}`);
|
||||
failedTests++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log(`Test Summary: ${passedTests} passed, ${failedTests} failed`);
|
||||
console.log('='.repeat(50));
|
||||
|
||||
// Test the old problematic queries to ensure improvement
|
||||
console.log('\n\nTesting Original Problem Scenarios:');
|
||||
console.log('=====================================\n');
|
||||
|
||||
// Test webhook query that was problematic
|
||||
console.log('1. Testing "webhook" query (was returning service-specific webhooks first):');
|
||||
const webhookResult = await server.executeTool('search_nodes', { query: 'webhook', limit: 10 });
|
||||
const webhookFirst = webhookResult.results[0];
|
||||
if (webhookFirst.nodeType === 'nodes-base.webhook') {
|
||||
console.log(' ✅ SUCCESS: Primary Webhook node now appears first!');
|
||||
} else {
|
||||
console.log(` ❌ FAILED: Got ${webhookFirst.nodeType} instead of nodes-base.webhook`);
|
||||
console.log(` First 3 results: ${webhookResult.results.slice(0, 3).map((r: any) => r.nodeType).join(', ')}`);
|
||||
}
|
||||
|
||||
// Test http call query
|
||||
console.log('\n2. Testing "http call" query (was not finding HTTP Request easily):');
|
||||
const httpCallResult = await server.executeTool('search_nodes', { query: 'http call', limit: 10 });
|
||||
const httpCallFirst = httpCallResult.results[0];
|
||||
if (httpCallFirst.nodeType === 'nodes-base.httpRequest') {
|
||||
console.log(' ✅ SUCCESS: HTTP Request node now appears first!');
|
||||
} else {
|
||||
console.log(` ❌ FAILED: Got ${httpCallFirst.nodeType} instead of nodes-base.httpRequest`);
|
||||
console.log(` First 3 results: ${httpCallResult.results.slice(0, 3).map((r: any) => r.nodeType).join(', ')}`);
|
||||
}
|
||||
|
||||
process.exit(failedTests > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
// Run tests
|
||||
testSearchImprovements().catch(error => {
|
||||
console.error('Test execution failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
import axios from 'axios';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
async function testMaliciousHeaders() {
|
||||
console.log('🔒 Testing Security Fixes...\n');
|
||||
|
||||
// Start server with TRUST_PROXY enabled
|
||||
const serverProcess = spawn('node', ['dist/mcp/index.js'], {
|
||||
env: {
|
||||
...process.env,
|
||||
MCP_MODE: 'http',
|
||||
AUTH_TOKEN: 'test-security-token-32-characters-long',
|
||||
PORT: '3999',
|
||||
TRUST_PROXY: '1'
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for server to start
|
||||
await new Promise(resolve => {
|
||||
serverProcess.stdout.on('data', (data) => {
|
||||
if (data.toString().includes('Press Ctrl+C to stop')) {
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
name: 'Valid proxy headers',
|
||||
headers: {
|
||||
'X-Forwarded-Host': 'example.com',
|
||||
'X-Forwarded-Proto': 'https'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Malicious host header (with path)',
|
||||
headers: {
|
||||
'X-Forwarded-Host': 'evil.com/path/to/evil',
|
||||
'X-Forwarded-Proto': 'https'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Malicious host header (with @)',
|
||||
headers: {
|
||||
'X-Forwarded-Host': 'user@evil.com',
|
||||
'X-Forwarded-Proto': 'https'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Invalid hostname (multiple dots)',
|
||||
headers: {
|
||||
'X-Forwarded-Host': '.....',
|
||||
'X-Forwarded-Proto': 'https'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'IPv6 address',
|
||||
headers: {
|
||||
'X-Forwarded-Host': '[::1]:3000',
|
||||
'X-Forwarded-Proto': 'https'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:3999/', {
|
||||
headers: testCase.headers,
|
||||
timeout: 2000
|
||||
});
|
||||
|
||||
const endpoints = response.data.endpoints;
|
||||
const healthUrl = endpoints?.health?.url || 'N/A';
|
||||
|
||||
console.log(`✅ ${testCase.name}`);
|
||||
console.log(` Response: ${healthUrl}`);
|
||||
|
||||
// Check if malicious headers were blocked.
|
||||
//
|
||||
// NOTE: this is a substring presence check, not URL sanitization.
|
||||
// The goal is to detect whether ANY of the attacker-supplied markers
|
||||
// leaked into the server's echoed health URL — a hostname-only check
|
||||
// would miss path/userinfo injection, which is exactly what we're
|
||||
// testing for. CodeQL js/incomplete-url-substring-sanitization
|
||||
// flagged this as if it were an auth gate; it is not.
|
||||
if (testCase.name.includes('Malicious') || testCase.name.includes('Invalid')) {
|
||||
const maliciousMarkers = ['evil.com', '@', '.....'];
|
||||
const leaked = maliciousMarkers.some(marker => healthUrl.indexOf(marker) !== -1);
|
||||
if (leaked) {
|
||||
console.log(' ❌ SECURITY ISSUE: Malicious header was not blocked!');
|
||||
} else {
|
||||
console.log(' ✅ Malicious header was blocked');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ ${testCase.name} - Request failed`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
serverProcess.kill();
|
||||
}
|
||||
|
||||
testMaliciousHeaders().catch(console.error);
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# Test script for single-session HTTP server
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧪 Testing Single-Session HTTP Server..."
|
||||
echo
|
||||
|
||||
# Generate test auth token if not set
|
||||
if [ -z "$AUTH_TOKEN" ]; then
|
||||
export AUTH_TOKEN="test-token-$(date +%s)"
|
||||
echo "Generated test AUTH_TOKEN: $AUTH_TOKEN"
|
||||
fi
|
||||
|
||||
# Start server in background
|
||||
echo "Starting server..."
|
||||
MCP_MODE=http npm start > server.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to start
|
||||
echo "Waiting for server to start..."
|
||||
sleep 3
|
||||
|
||||
# Check health endpoint
|
||||
echo
|
||||
echo "Testing health endpoint..."
|
||||
curl -s http://localhost:3000/health | jq .
|
||||
|
||||
# Test authentication failure
|
||||
echo
|
||||
echo "Testing authentication failure..."
|
||||
curl -s -X POST http://localhost:3000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer wrong-token" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | jq .
|
||||
|
||||
# Test successful request
|
||||
echo
|
||||
echo "Testing successful request..."
|
||||
curl -s -X POST http://localhost:3000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | jq .
|
||||
|
||||
# Test session reuse
|
||||
echo
|
||||
echo "Testing session reuse (second request)..."
|
||||
curl -s -X POST http://localhost:3000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||
-d '{"jsonrpc":"2.0","method":"get_database_statistics","id":2}' | jq .
|
||||
|
||||
# Liveness check (health body is intentionally minimal per GHSA-75hx-xj24-mqrw)
|
||||
echo
|
||||
echo "Liveness check..."
|
||||
curl -s http://localhost:3000/health | jq .
|
||||
|
||||
# Clean up
|
||||
echo
|
||||
echo "Stopping server..."
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
wait $SERVER_PID 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo "✅ Test complete! Check server.log for details."
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test script to verify trigger detection works with sql.js adapter
|
||||
*/
|
||||
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { NodeRepository } from '../src/database/node-repository';
|
||||
import { logger } from '../src/utils/logger';
|
||||
import path from 'path';
|
||||
|
||||
async function testSqlJsTriggers() {
|
||||
logger.info('🧪 Testing trigger detection with sql.js adapter...\n');
|
||||
|
||||
try {
|
||||
// Force sql.js by temporarily renaming better-sqlite3
|
||||
const originalRequire = require.cache[require.resolve('better-sqlite3')];
|
||||
if (originalRequire) {
|
||||
delete require.cache[require.resolve('better-sqlite3')];
|
||||
}
|
||||
|
||||
// Mock better-sqlite3 to force sql.js usage
|
||||
const Module = require('module');
|
||||
const originalResolveFilename = Module._resolveFilename;
|
||||
Module._resolveFilename = function(request: string, parent: any, isMain: boolean) {
|
||||
if (request === 'better-sqlite3') {
|
||||
throw new Error('Forcing sql.js adapter for testing');
|
||||
}
|
||||
return originalResolveFilename.apply(this, arguments);
|
||||
};
|
||||
|
||||
// Now create adapter - should use sql.js
|
||||
const dbPath = path.join(process.cwd(), 'data', 'nodes.db');
|
||||
logger.info(`📁 Database path: ${dbPath}`);
|
||||
|
||||
const adapter = await createDatabaseAdapter(dbPath);
|
||||
logger.info('✅ Adapter created (should be sql.js)\n');
|
||||
|
||||
// Test direct query
|
||||
logger.info('📊 Testing direct database query:');
|
||||
const triggerNodes = ['nodes-base.webhook', 'nodes-base.cron', 'nodes-base.interval', 'nodes-base.emailReadImap'];
|
||||
|
||||
for (const nodeType of triggerNodes) {
|
||||
const row = adapter.prepare('SELECT * FROM nodes WHERE node_type = ?').get(nodeType);
|
||||
if (row) {
|
||||
logger.info(`${nodeType}:`);
|
||||
logger.info(` is_trigger raw value: ${row.is_trigger} (type: ${typeof row.is_trigger})`);
|
||||
logger.info(` !!is_trigger: ${!!row.is_trigger}`);
|
||||
logger.info(` Number(is_trigger) === 1: ${Number(row.is_trigger) === 1}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test through repository
|
||||
logger.info('\n📦 Testing through NodeRepository:');
|
||||
const repository = new NodeRepository(adapter);
|
||||
|
||||
for (const nodeType of triggerNodes) {
|
||||
const node = repository.getNode(nodeType);
|
||||
if (node) {
|
||||
logger.info(`${nodeType}: isTrigger = ${node.isTrigger}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test list query
|
||||
logger.info('\n📋 Testing list query:');
|
||||
const allTriggers = adapter.prepare(
|
||||
'SELECT node_type, is_trigger FROM nodes WHERE node_type IN (?, ?, ?, ?)'
|
||||
).all(...triggerNodes);
|
||||
|
||||
for (const node of allTriggers) {
|
||||
logger.info(`${node.node_type}: is_trigger = ${node.is_trigger} (type: ${typeof node.is_trigger})`);
|
||||
}
|
||||
|
||||
adapter.close();
|
||||
logger.info('\n✅ Test complete!');
|
||||
|
||||
// Restore original require
|
||||
Module._resolveFilename = originalResolveFilename;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run test
|
||||
testSqlJsTriggers().catch(console.error);
|
||||
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env ts-node
|
||||
/**
|
||||
* Phase 3: Real-World Type Structure Validation
|
||||
*
|
||||
* Tests type structure validation against real workflow templates from n8n.io
|
||||
* to ensure production readiness. Validates filter, resourceMapper,
|
||||
* assignmentCollection, and resourceLocator types.
|
||||
*
|
||||
* Usage:
|
||||
* npm run build && node dist/scripts/test-structure-validation.js
|
||||
*
|
||||
* or with ts-node:
|
||||
* npx ts-node scripts/test-structure-validation.ts
|
||||
*/
|
||||
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
|
||||
import type { NodePropertyTypes } from 'n8n-workflow';
|
||||
import { gunzipSync } from 'zlib';
|
||||
|
||||
interface ValidationResult {
|
||||
templateId: number;
|
||||
templateName: string;
|
||||
templateViews: number;
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
nodeType: string;
|
||||
propertyName: string;
|
||||
propertyType: NodePropertyTypes;
|
||||
valid: boolean;
|
||||
errors: Array<{ type: string; property?: string; message: string }>;
|
||||
warnings: Array<{ type: string; property?: string; message: string }>;
|
||||
validationTimeMs: number;
|
||||
}
|
||||
|
||||
interface ValidationStats {
|
||||
totalTemplates: number;
|
||||
totalNodes: number;
|
||||
totalValidations: number;
|
||||
passedValidations: number;
|
||||
failedValidations: number;
|
||||
byType: Record<string, { passed: number; failed: number }>;
|
||||
byError: Record<string, number>;
|
||||
avgValidationTimeMs: number;
|
||||
maxValidationTimeMs: number;
|
||||
}
|
||||
|
||||
// Special types we want to validate
|
||||
const SPECIAL_TYPES: NodePropertyTypes[] = [
|
||||
'filter',
|
||||
'resourceMapper',
|
||||
'assignmentCollection',
|
||||
'resourceLocator',
|
||||
];
|
||||
|
||||
function decompressWorkflow(compressed: string): any {
|
||||
try {
|
||||
const buffer = Buffer.from(compressed, 'base64');
|
||||
const decompressed = gunzipSync(buffer);
|
||||
return JSON.parse(decompressed.toString('utf-8'));
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to decompress workflow: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopTemplates(db: any, limit: number = 100) {
|
||||
console.log(`📥 Loading top ${limit} templates by popularity...\n`);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
workflow_json_compressed,
|
||||
views
|
||||
FROM templates
|
||||
WHERE workflow_json_compressed IS NOT NULL
|
||||
ORDER BY views DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
|
||||
const templates = stmt.all(limit);
|
||||
console.log(`✓ Loaded ${templates.length} templates\n`);
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
function extractNodesWithSpecialTypes(workflowJson: any): Array<{
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
nodeType: string;
|
||||
properties: Array<{ name: string; type: NodePropertyTypes; value: any }>;
|
||||
}> {
|
||||
const results: Array<any> = [];
|
||||
|
||||
if (!workflowJson || !workflowJson.nodes || !Array.isArray(workflowJson.nodes)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const node of workflowJson.nodes) {
|
||||
// Check if node has parameters with special types
|
||||
if (!node.parameters || typeof node.parameters !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const specialProperties: Array<{ name: string; type: NodePropertyTypes; value: any }> = [];
|
||||
|
||||
// Check each parameter against our special types
|
||||
for (const [paramName, paramValue] of Object.entries(node.parameters)) {
|
||||
// Try to infer type from structure
|
||||
const inferredType = inferPropertyType(paramValue);
|
||||
|
||||
if (inferredType && SPECIAL_TYPES.includes(inferredType)) {
|
||||
specialProperties.push({
|
||||
name: paramName,
|
||||
type: inferredType,
|
||||
value: paramValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (specialProperties.length > 0) {
|
||||
results.push({
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
nodeType: node.type,
|
||||
properties: specialProperties,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function inferPropertyType(value: any): NodePropertyTypes | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter type: has combinator and conditions
|
||||
if (value.combinator && value.conditions) {
|
||||
return 'filter';
|
||||
}
|
||||
|
||||
// ResourceMapper type: has mappingMode
|
||||
if (value.mappingMode) {
|
||||
return 'resourceMapper';
|
||||
}
|
||||
|
||||
// AssignmentCollection type: has assignments array
|
||||
if (value.assignments && Array.isArray(value.assignments)) {
|
||||
return 'assignmentCollection';
|
||||
}
|
||||
|
||||
// ResourceLocator type: has mode and value
|
||||
if (value.mode && value.hasOwnProperty('value')) {
|
||||
return 'resourceLocator';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function validateTemplate(
|
||||
templateId: number,
|
||||
templateName: string,
|
||||
templateViews: number,
|
||||
workflowJson: any
|
||||
): Promise<ValidationResult[]> {
|
||||
const results: ValidationResult[] = [];
|
||||
|
||||
// Extract nodes with special types
|
||||
const nodesWithSpecialTypes = extractNodesWithSpecialTypes(workflowJson);
|
||||
|
||||
for (const node of nodesWithSpecialTypes) {
|
||||
for (const prop of node.properties) {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Create property definition for validation
|
||||
const properties = [
|
||||
{
|
||||
name: prop.name,
|
||||
type: prop.type,
|
||||
required: true,
|
||||
displayName: prop.name,
|
||||
default: {},
|
||||
},
|
||||
];
|
||||
|
||||
// Create config with just this property
|
||||
const config = {
|
||||
[prop.name]: prop.value,
|
||||
};
|
||||
|
||||
try {
|
||||
// Run validation
|
||||
const validationResult = EnhancedConfigValidator.validateWithMode(
|
||||
node.nodeType,
|
||||
config,
|
||||
properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
const validationTimeMs = Date.now() - startTime;
|
||||
|
||||
results.push({
|
||||
templateId,
|
||||
templateName,
|
||||
templateViews,
|
||||
nodeId: node.nodeId,
|
||||
nodeName: node.nodeName,
|
||||
nodeType: node.nodeType,
|
||||
propertyName: prop.name,
|
||||
propertyType: prop.type,
|
||||
valid: validationResult.valid,
|
||||
errors: validationResult.errors || [],
|
||||
warnings: validationResult.warnings || [],
|
||||
validationTimeMs,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const validationTimeMs = Date.now() - startTime;
|
||||
|
||||
results.push({
|
||||
templateId,
|
||||
templateName,
|
||||
templateViews,
|
||||
nodeId: node.nodeId,
|
||||
nodeName: node.nodeName,
|
||||
nodeType: node.nodeType,
|
||||
propertyName: prop.name,
|
||||
propertyType: prop.type,
|
||||
valid: false,
|
||||
errors: [
|
||||
{
|
||||
type: 'exception',
|
||||
property: prop.name,
|
||||
message: `Validation threw exception: ${error.message}`,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
validationTimeMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function calculateStats(results: ValidationResult[]): ValidationStats {
|
||||
const stats: ValidationStats = {
|
||||
totalTemplates: new Set(results.map(r => r.templateId)).size,
|
||||
totalNodes: new Set(results.map(r => `${r.templateId}-${r.nodeId}`)).size,
|
||||
totalValidations: results.length,
|
||||
passedValidations: results.filter(r => r.valid).length,
|
||||
failedValidations: results.filter(r => !r.valid).length,
|
||||
byType: {},
|
||||
byError: {},
|
||||
avgValidationTimeMs: 0,
|
||||
maxValidationTimeMs: 0,
|
||||
};
|
||||
|
||||
// Stats by type
|
||||
for (const type of SPECIAL_TYPES) {
|
||||
const typeResults = results.filter(r => r.propertyType === type);
|
||||
stats.byType[type] = {
|
||||
passed: typeResults.filter(r => r.valid).length,
|
||||
failed: typeResults.filter(r => !r.valid).length,
|
||||
};
|
||||
}
|
||||
|
||||
// Error frequency
|
||||
for (const result of results.filter(r => !r.valid)) {
|
||||
for (const error of result.errors) {
|
||||
const key = `${error.type}: ${error.message}`;
|
||||
stats.byError[key] = (stats.byError[key] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Performance stats
|
||||
if (results.length > 0) {
|
||||
stats.avgValidationTimeMs =
|
||||
results.reduce((sum, r) => sum + r.validationTimeMs, 0) / results.length;
|
||||
stats.maxValidationTimeMs = Math.max(...results.map(r => r.validationTimeMs));
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
function printStats(stats: ValidationStats) {
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('VALIDATION STATISTICS');
|
||||
console.log('='.repeat(80) + '\n');
|
||||
|
||||
console.log(`📊 Total Templates Tested: ${stats.totalTemplates}`);
|
||||
console.log(`📊 Total Nodes with Special Types: ${stats.totalNodes}`);
|
||||
console.log(`📊 Total Property Validations: ${stats.totalValidations}\n`);
|
||||
|
||||
const passRate = (stats.passedValidations / stats.totalValidations * 100).toFixed(2);
|
||||
const failRate = (stats.failedValidations / stats.totalValidations * 100).toFixed(2);
|
||||
|
||||
console.log(`✅ Passed: ${stats.passedValidations} (${passRate}%)`);
|
||||
console.log(`❌ Failed: ${stats.failedValidations} (${failRate}%)\n`);
|
||||
|
||||
console.log('By Property Type:');
|
||||
console.log('-'.repeat(80));
|
||||
for (const [type, counts] of Object.entries(stats.byType)) {
|
||||
const total = counts.passed + counts.failed;
|
||||
if (total === 0) {
|
||||
console.log(` ${type}: No occurrences found`);
|
||||
} else {
|
||||
const typePassRate = (counts.passed / total * 100).toFixed(2);
|
||||
console.log(` ${type}: ${counts.passed}/${total} passed (${typePassRate}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n⚡ Performance:');
|
||||
console.log('-'.repeat(80));
|
||||
console.log(` Average validation time: ${stats.avgValidationTimeMs.toFixed(2)}ms`);
|
||||
console.log(` Maximum validation time: ${stats.maxValidationTimeMs.toFixed(2)}ms`);
|
||||
|
||||
const meetsTarget = stats.avgValidationTimeMs < 50;
|
||||
console.log(` Target (<50ms): ${meetsTarget ? '✅ MET' : '❌ NOT MET'}\n`);
|
||||
|
||||
if (Object.keys(stats.byError).length > 0) {
|
||||
console.log('🔍 Most Common Errors:');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const sortedErrors = Object.entries(stats.byError)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10);
|
||||
|
||||
for (const [error, count] of sortedErrors) {
|
||||
console.log(` ${count}x: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printFailures(results: ValidationResult[], maxFailures: number = 20) {
|
||||
const failures = results.filter(r => !r.valid);
|
||||
|
||||
if (failures.length === 0) {
|
||||
console.log('\n✨ No failures! All validations passed.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log(`VALIDATION FAILURES (showing first ${Math.min(maxFailures, failures.length)})` );
|
||||
console.log('='.repeat(80) + '\n');
|
||||
|
||||
for (let i = 0; i < Math.min(maxFailures, failures.length); i++) {
|
||||
const failure = failures[i];
|
||||
|
||||
console.log(`Failure ${i + 1}/${failures.length}:`);
|
||||
console.log(` Template: ${failure.templateName} (ID: ${failure.templateId}, Views: ${failure.templateViews})`);
|
||||
console.log(` Node: ${failure.nodeName} (${failure.nodeType})`);
|
||||
console.log(` Property: ${failure.propertyName} (type: ${failure.propertyType})`);
|
||||
console.log(` Errors:`);
|
||||
|
||||
for (const error of failure.errors) {
|
||||
console.log(` - [${error.type}] ${error.property}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (failure.warnings.length > 0) {
|
||||
console.log(` Warnings:`);
|
||||
for (const warning of failure.warnings) {
|
||||
console.log(` - [${warning.type}] ${warning.property}: ${warning.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (failures.length > maxFailures) {
|
||||
console.log(`... and ${failures.length - maxFailures} more failures\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('='.repeat(80));
|
||||
console.log('PHASE 3: REAL-WORLD TYPE STRUCTURE VALIDATION');
|
||||
console.log('='.repeat(80) + '\n');
|
||||
|
||||
// Initialize database
|
||||
console.log('🔌 Connecting to database...');
|
||||
const db = await createDatabaseAdapter('./data/nodes.db');
|
||||
console.log('✓ Database connected\n');
|
||||
|
||||
// Load templates
|
||||
const templates = await loadTopTemplates(db, 100);
|
||||
|
||||
// Validate each template
|
||||
console.log('🔍 Validating templates...\n');
|
||||
|
||||
const allResults: ValidationResult[] = [];
|
||||
let processedCount = 0;
|
||||
let nodesFound = 0;
|
||||
|
||||
for (const template of templates) {
|
||||
processedCount++;
|
||||
|
||||
let workflowJson;
|
||||
try {
|
||||
workflowJson = decompressWorkflow(template.workflow_json_compressed);
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Template ${template.id}: Decompression failed, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const results = await validateTemplate(
|
||||
template.id,
|
||||
template.name,
|
||||
template.views,
|
||||
workflowJson
|
||||
);
|
||||
|
||||
if (results.length > 0) {
|
||||
nodesFound += new Set(results.map(r => r.nodeId)).size;
|
||||
allResults.push(...results);
|
||||
|
||||
const passedCount = results.filter(r => r.valid).length;
|
||||
const status = passedCount === results.length ? '✓' : '✗';
|
||||
console.log(
|
||||
`${status} Template ${processedCount}/${templates.length}: ` +
|
||||
`"${template.name}" (${results.length} validations, ${passedCount} passed)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✓ Processed ${processedCount} templates`);
|
||||
console.log(`✓ Found ${nodesFound} nodes with special types\n`);
|
||||
|
||||
// Calculate and print statistics
|
||||
const stats = calculateStats(allResults);
|
||||
printStats(stats);
|
||||
|
||||
// Print detailed failures
|
||||
printFailures(allResults);
|
||||
|
||||
// Success criteria check
|
||||
console.log('='.repeat(80));
|
||||
console.log('SUCCESS CRITERIA CHECK');
|
||||
console.log('='.repeat(80) + '\n');
|
||||
|
||||
const passRate = (stats.passedValidations / stats.totalValidations * 100);
|
||||
const falsePositiveRate = (stats.failedValidations / stats.totalValidations * 100);
|
||||
const avgTime = stats.avgValidationTimeMs;
|
||||
|
||||
console.log(`Pass Rate: ${passRate.toFixed(2)}% (target: >95%) ${passRate > 95 ? '✅' : '❌'}`);
|
||||
console.log(`False Positive Rate: ${falsePositiveRate.toFixed(2)}% (target: <5%) ${falsePositiveRate < 5 ? '✅' : '❌'}`);
|
||||
console.log(`Avg Validation Time: ${avgTime.toFixed(2)}ms (target: <50ms) ${avgTime < 50 ? '✅' : '❌'}\n`);
|
||||
|
||||
const allCriteriaMet = passRate > 95 && falsePositiveRate < 5 && avgTime < 50;
|
||||
|
||||
if (allCriteriaMet) {
|
||||
console.log('🎉 ALL SUCCESS CRITERIA MET! Phase 3 validation complete.\n');
|
||||
} else {
|
||||
console.log('⚠️ Some success criteria not met. Iteration required.\n');
|
||||
}
|
||||
|
||||
// Close database
|
||||
db.close();
|
||||
|
||||
process.exit(allCriteriaMet ? 0 : 1);
|
||||
}
|
||||
|
||||
// Run the script
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Debug script for telemetry integration
|
||||
* Tests direct Supabase connection
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
async function debugTelemetry() {
|
||||
console.log('🔍 Debugging Telemetry Integration\n');
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
console.error('❌ Missing SUPABASE_URL or SUPABASE_ANON_KEY');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Environment:');
|
||||
console.log(' URL:', supabaseUrl);
|
||||
console.log(' Key:', supabaseAnonKey.substring(0, 30) + '...');
|
||||
|
||||
// Create Supabase client
|
||||
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
}
|
||||
});
|
||||
|
||||
// Test 1: Direct insert to telemetry_events
|
||||
console.log('\n📝 Test 1: Direct insert to telemetry_events...');
|
||||
const testEvent = {
|
||||
user_id: 'test-user-123',
|
||||
event: 'test_event',
|
||||
properties: {
|
||||
test: true,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
const { data: eventData, error: eventError } = await supabase
|
||||
.from('telemetry_events')
|
||||
.insert([testEvent])
|
||||
.select();
|
||||
|
||||
if (eventError) {
|
||||
console.error('❌ Event insert failed:', eventError);
|
||||
} else {
|
||||
console.log('✅ Event inserted successfully:', eventData);
|
||||
}
|
||||
|
||||
// Test 2: Direct insert to telemetry_workflows
|
||||
console.log('\n📝 Test 2: Direct insert to telemetry_workflows...');
|
||||
const testWorkflow = {
|
||||
user_id: 'test-user-123',
|
||||
workflow_hash: 'test-hash-' + Date.now(),
|
||||
node_count: 3,
|
||||
node_types: ['webhook', 'http', 'slack'],
|
||||
has_trigger: true,
|
||||
has_webhook: true,
|
||||
complexity: 'simple',
|
||||
sanitized_workflow: {
|
||||
nodes: [],
|
||||
connections: {}
|
||||
}
|
||||
};
|
||||
|
||||
const { data: workflowData, error: workflowError } = await supabase
|
||||
.from('telemetry_workflows')
|
||||
.insert([testWorkflow])
|
||||
.select();
|
||||
|
||||
if (workflowError) {
|
||||
console.error('❌ Workflow insert failed:', workflowError);
|
||||
} else {
|
||||
console.log('✅ Workflow inserted successfully:', workflowData);
|
||||
}
|
||||
|
||||
// Test 3: Try to read data (should fail with anon key due to RLS)
|
||||
console.log('\n📖 Test 3: Attempting to read data (should fail due to RLS)...');
|
||||
const { data: readData, error: readError } = await supabase
|
||||
.from('telemetry_events')
|
||||
.select('*')
|
||||
.limit(1);
|
||||
|
||||
if (readError) {
|
||||
console.log('✅ Read correctly blocked by RLS:', readError.message);
|
||||
} else {
|
||||
console.log('⚠️ Unexpected: Read succeeded (RLS may not be working):', readData);
|
||||
}
|
||||
|
||||
// Test 4: Check table existence
|
||||
console.log('\n🔍 Test 4: Verifying tables exist...');
|
||||
const { data: tables, error: tablesError } = await supabase
|
||||
.rpc('get_tables', { schema_name: 'public' })
|
||||
.select('*');
|
||||
|
||||
if (tablesError) {
|
||||
// This is expected - the RPC function might not exist
|
||||
console.log('ℹ️ Cannot list tables (RPC function not available)');
|
||||
} else {
|
||||
console.log('Tables found:', tables);
|
||||
}
|
||||
|
||||
console.log('\n✨ Debug completed! Check your Supabase dashboard for the test data.');
|
||||
console.log('Dashboard: https://supabase.com/dashboard/project/ydyufsohxdfpopqbubwk/editor');
|
||||
}
|
||||
|
||||
debugTelemetry().catch(error => {
|
||||
console.error('❌ Debug failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Direct telemetry test with hardcoded credentials
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const TELEMETRY_BACKEND = {
|
||||
URL: 'https://ydyufsohxdfpopqbubwk.supabase.co',
|
||||
ANON_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkeXVmc29oeGRmcG9wcWJ1YndrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Mzc2MzAxMDgsImV4cCI6MjA1MzIwNjEwOH0.LsUTx9OsNtnqg-jxXaJPc84aBHVDehHiMaFoF2Ir8s0'
|
||||
};
|
||||
|
||||
async function testDirect() {
|
||||
console.log('🧪 Direct Telemetry Test\n');
|
||||
|
||||
const supabase = createClient(TELEMETRY_BACKEND.URL, TELEMETRY_BACKEND.ANON_KEY, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
}
|
||||
});
|
||||
|
||||
const testEvent = {
|
||||
user_id: 'direct-test-' + Date.now(),
|
||||
event: 'direct_test',
|
||||
properties: {
|
||||
source: 'test-telemetry-direct.ts',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Sending event:', testEvent);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('telemetry_events')
|
||||
.insert([testEvent]);
|
||||
|
||||
if (error) {
|
||||
console.error('❌ Failed:', error);
|
||||
} else {
|
||||
console.log('✅ Success! Event sent directly to Supabase');
|
||||
console.log('Response:', data);
|
||||
}
|
||||
}
|
||||
|
||||
testDirect().catch(console.error);
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Test telemetry environment variable override
|
||||
*/
|
||||
|
||||
import { TelemetryConfigManager } from '../src/telemetry/config-manager';
|
||||
import { telemetry } from '../src/telemetry/telemetry-manager';
|
||||
|
||||
async function testEnvOverride() {
|
||||
console.log('🧪 Testing Telemetry Environment Variable Override\n');
|
||||
|
||||
const configManager = TelemetryConfigManager.getInstance();
|
||||
|
||||
// Test 1: Check current status without env var
|
||||
console.log('Test 1: Without environment variable');
|
||||
console.log('Is Enabled:', configManager.isEnabled());
|
||||
console.log('Status:', configManager.getStatus());
|
||||
|
||||
// Test 2: Set environment variable and check again
|
||||
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
console.log('Test 2: With N8N_MCP_TELEMETRY_DISABLED=true');
|
||||
process.env.N8N_MCP_TELEMETRY_DISABLED = 'true';
|
||||
|
||||
// Force reload by creating new instance (for testing)
|
||||
const newConfigManager = TelemetryConfigManager.getInstance();
|
||||
console.log('Is Enabled:', newConfigManager.isEnabled());
|
||||
console.log('Status:', newConfigManager.getStatus());
|
||||
|
||||
// Test 3: Try tracking with env disabled
|
||||
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
console.log('Test 3: Attempting to track with telemetry disabled');
|
||||
telemetry.trackToolUsage('test_tool', true, 100);
|
||||
console.log('Tool usage tracking attempted (should be ignored)');
|
||||
|
||||
// Test 4: Alternative env vars
|
||||
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
console.log('Test 4: Alternative environment variables');
|
||||
|
||||
delete process.env.N8N_MCP_TELEMETRY_DISABLED;
|
||||
process.env.TELEMETRY_DISABLED = 'true';
|
||||
console.log('With TELEMETRY_DISABLED=true:', newConfigManager.isEnabled());
|
||||
|
||||
delete process.env.TELEMETRY_DISABLED;
|
||||
process.env.DISABLE_TELEMETRY = 'true';
|
||||
console.log('With DISABLE_TELEMETRY=true:', newConfigManager.isEnabled());
|
||||
|
||||
// Test 5: Env var takes precedence over config
|
||||
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
console.log('Test 5: Environment variable precedence');
|
||||
|
||||
// Enable via config
|
||||
newConfigManager.enable();
|
||||
console.log('After enabling via config:', newConfigManager.isEnabled());
|
||||
|
||||
// But env var should still override
|
||||
process.env.N8N_MCP_TELEMETRY_DISABLED = 'true';
|
||||
console.log('With env var set (should override config):', newConfigManager.isEnabled());
|
||||
|
||||
console.log('\n✅ All tests completed!');
|
||||
}
|
||||
|
||||
testEnvOverride().catch(console.error);
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Integration test for the telemetry manager
|
||||
*/
|
||||
|
||||
import { telemetry } from '../src/telemetry/telemetry-manager';
|
||||
|
||||
async function testIntegration() {
|
||||
console.log('🧪 Testing Telemetry Manager Integration\n');
|
||||
|
||||
// Check status
|
||||
console.log('Status:', telemetry.getStatus());
|
||||
|
||||
// Track session start
|
||||
console.log('\nTracking session start...');
|
||||
telemetry.trackSessionStart();
|
||||
|
||||
// Track tool usage
|
||||
console.log('Tracking tool usage...');
|
||||
telemetry.trackToolUsage('search_nodes', true, 150);
|
||||
telemetry.trackToolUsage('get_node_info', true, 75);
|
||||
telemetry.trackToolUsage('validate_workflow', false, 200);
|
||||
|
||||
// Track errors
|
||||
console.log('Tracking errors...');
|
||||
telemetry.trackError('ValidationError', 'workflow_validation', 'validate_workflow', 'Required field missing: nodes array is empty');
|
||||
|
||||
// Track a test workflow
|
||||
console.log('Tracking workflow creation...');
|
||||
const testWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
name: 'Webhook',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
path: '/test-webhook',
|
||||
httpMethod: 'POST'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
name: 'HTTP Request',
|
||||
position: [250, 0],
|
||||
parameters: {
|
||||
url: 'https://api.example.com/endpoint',
|
||||
method: 'POST',
|
||||
authentication: 'genericCredentialType',
|
||||
genericAuthType: 'httpHeaderAuth',
|
||||
sendHeaders: true,
|
||||
headerParameters: {
|
||||
parameters: [
|
||||
{
|
||||
name: 'Authorization',
|
||||
value: 'Bearer sk-1234567890abcdef'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
name: 'Slack',
|
||||
position: [500, 0],
|
||||
parameters: {
|
||||
channel: '#notifications',
|
||||
text: 'Workflow completed!'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'1': {
|
||||
main: [[{ node: '2', type: 'main', index: 0 }]]
|
||||
},
|
||||
'2': {
|
||||
main: [[{ node: '3', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
telemetry.trackWorkflowCreation(testWorkflow, true);
|
||||
|
||||
// Force flush
|
||||
console.log('\nFlushing telemetry data...');
|
||||
await telemetry.flush();
|
||||
|
||||
console.log('\n✅ Telemetry integration test completed!');
|
||||
console.log('Check your Supabase dashboard for the telemetry data.');
|
||||
}
|
||||
|
||||
testIntegration().catch(console.error);
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Test telemetry without requesting data back
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function testNoSelect() {
|
||||
const supabaseUrl = process.env.SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY!;
|
||||
|
||||
console.log('🧪 Telemetry Test (No Select)\n');
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
}
|
||||
});
|
||||
|
||||
// Insert WITHOUT .select() - just fire and forget
|
||||
const testData = {
|
||||
user_id: 'test-' + Date.now(),
|
||||
event: 'test_event',
|
||||
properties: { test: true }
|
||||
};
|
||||
|
||||
console.log('Inserting:', testData);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('telemetry_events')
|
||||
.insert([testData]); // No .select() here!
|
||||
|
||||
if (error) {
|
||||
console.error('❌ Failed:', error);
|
||||
} else {
|
||||
console.log('✅ Success! Data inserted (no response data)');
|
||||
}
|
||||
|
||||
// Test workflow insert too
|
||||
const testWorkflow = {
|
||||
user_id: 'test-' + Date.now(),
|
||||
workflow_hash: 'hash-' + Date.now(),
|
||||
node_count: 3,
|
||||
node_types: ['webhook', 'http', 'slack'],
|
||||
has_trigger: true,
|
||||
has_webhook: true,
|
||||
complexity: 'simple',
|
||||
sanitized_workflow: { nodes: [], connections: {} }
|
||||
};
|
||||
|
||||
console.log('\nInserting workflow:', testWorkflow);
|
||||
|
||||
const { error: workflowError } = await supabase
|
||||
.from('telemetry_workflows')
|
||||
.insert([testWorkflow]); // No .select() here!
|
||||
|
||||
if (workflowError) {
|
||||
console.error('❌ Workflow failed:', workflowError);
|
||||
} else {
|
||||
console.log('✅ Workflow inserted successfully!');
|
||||
}
|
||||
}
|
||||
|
||||
testNoSelect().catch(console.error);
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Test that RLS properly protects data
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function testSecurity() {
|
||||
const supabaseUrl = process.env.SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY!;
|
||||
|
||||
console.log('🔒 Testing Telemetry Security (RLS)\n');
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
}
|
||||
});
|
||||
|
||||
// Test 1: Verify anon can INSERT
|
||||
console.log('Test 1: Anonymous INSERT (should succeed)...');
|
||||
const testData = {
|
||||
user_id: 'security-test-' + Date.now(),
|
||||
event: 'security_test',
|
||||
properties: { test: true }
|
||||
};
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from('telemetry_events')
|
||||
.insert([testData]);
|
||||
|
||||
if (insertError) {
|
||||
console.error('❌ Insert failed:', insertError.message);
|
||||
} else {
|
||||
console.log('✅ Insert succeeded (as expected)');
|
||||
}
|
||||
|
||||
// Test 2: Verify anon CANNOT SELECT
|
||||
console.log('\nTest 2: Anonymous SELECT (should fail)...');
|
||||
const { data, error: selectError } = await supabase
|
||||
.from('telemetry_events')
|
||||
.select('*')
|
||||
.limit(1);
|
||||
|
||||
if (selectError) {
|
||||
console.log('✅ Select blocked by RLS (as expected):', selectError.message);
|
||||
} else if (data && data.length > 0) {
|
||||
console.error('❌ SECURITY ISSUE: Anon can read data!', data);
|
||||
} else if (data && data.length === 0) {
|
||||
console.log('⚠️ Select returned empty array (might be RLS working)');
|
||||
}
|
||||
|
||||
// Test 3: Verify anon CANNOT UPDATE
|
||||
console.log('\nTest 3: Anonymous UPDATE (should fail)...');
|
||||
const { error: updateError } = await supabase
|
||||
.from('telemetry_events')
|
||||
.update({ event: 'hacked' })
|
||||
.eq('user_id', 'test');
|
||||
|
||||
if (updateError) {
|
||||
console.log('✅ Update blocked (as expected):', updateError.message);
|
||||
} else {
|
||||
console.error('❌ SECURITY ISSUE: Anon can update data!');
|
||||
}
|
||||
|
||||
// Test 4: Verify anon CANNOT DELETE
|
||||
console.log('\nTest 4: Anonymous DELETE (should fail)...');
|
||||
const { error: deleteError } = await supabase
|
||||
.from('telemetry_events')
|
||||
.delete()
|
||||
.eq('user_id', 'test');
|
||||
|
||||
if (deleteError) {
|
||||
console.log('✅ Delete blocked (as expected):', deleteError.message);
|
||||
} else {
|
||||
console.error('❌ SECURITY ISSUE: Anon can delete data!');
|
||||
}
|
||||
|
||||
console.log('\n✨ Security test completed!');
|
||||
console.log('Summary: Anonymous users can INSERT (for telemetry) but cannot READ/UPDATE/DELETE');
|
||||
}
|
||||
|
||||
testSecurity().catch(console.error);
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Simple test to verify telemetry works
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function testSimple() {
|
||||
const supabaseUrl = process.env.SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY!;
|
||||
|
||||
console.log('🧪 Simple Telemetry Test\n');
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
}
|
||||
});
|
||||
|
||||
// Simple insert
|
||||
const testData = {
|
||||
user_id: 'simple-test-' + Date.now(),
|
||||
event: 'test_event',
|
||||
properties: { test: true }
|
||||
};
|
||||
|
||||
console.log('Inserting:', testData);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('telemetry_events')
|
||||
.insert([testData])
|
||||
.select();
|
||||
|
||||
if (error) {
|
||||
console.error('❌ Failed:', error);
|
||||
} else {
|
||||
console.log('✅ Success! Inserted:', data);
|
||||
}
|
||||
}
|
||||
|
||||
testSimple().catch(console.error);
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env ts-node
|
||||
|
||||
/**
|
||||
* Test script for typeVersion validation in workflow validator
|
||||
*/
|
||||
|
||||
import { NodeRepository } from '../src/database/node-repository';
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { WorkflowValidator } from '../src/services/workflow-validator';
|
||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
|
||||
import { Logger } from '../src/utils/logger';
|
||||
|
||||
const logger = new Logger({ prefix: '[test-typeversion]' });
|
||||
|
||||
// Test workflows with various typeVersion scenarios
|
||||
const testWorkflows = {
|
||||
// Workflow with missing typeVersion on versioned nodes
|
||||
missingTypeVersion: {
|
||||
name: 'Missing typeVersion Test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook_1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
path: '/test',
|
||||
httpMethod: 'POST'
|
||||
}
|
||||
// Missing typeVersion - should error
|
||||
},
|
||||
{
|
||||
id: 'execute_1',
|
||||
name: 'Execute Command',
|
||||
type: 'n8n-nodes-base.executeCommand',
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
command: 'echo "test"'
|
||||
}
|
||||
// Missing typeVersion - should error
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Execute Command', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Workflow with outdated typeVersion
|
||||
outdatedTypeVersion: {
|
||||
name: 'Outdated typeVersion Test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'http_1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1, // Outdated - latest is likely 4+
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
url: 'https://example.com',
|
||||
method: 'GET'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'code_1',
|
||||
name: 'Code',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 1, // Outdated - latest is likely 2
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
jsCode: 'return items;'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'HTTP Request': {
|
||||
main: [[{ node: 'Code', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Workflow with correct typeVersion
|
||||
correctTypeVersion: {
|
||||
name: 'Correct typeVersion Test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook_1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
path: '/test',
|
||||
httpMethod: 'POST'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http_1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
url: 'https://example.com',
|
||||
method: 'GET'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Workflow with invalid typeVersion
|
||||
invalidTypeVersion: {
|
||||
name: 'Invalid typeVersion Test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook_1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 0, // Invalid - must be positive
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
path: '/test'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http_1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 999, // Too high - exceeds maximum
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
url: 'https://example.com'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function testTypeVersionValidation() {
|
||||
const dbAdapter = await createDatabaseAdapter('./data/nodes.db');
|
||||
const repository = new NodeRepository(dbAdapter);
|
||||
const validator = new WorkflowValidator(repository, EnhancedConfigValidator);
|
||||
|
||||
console.log('\n====================================');
|
||||
console.log('Testing typeVersion Validation');
|
||||
console.log('====================================\n');
|
||||
|
||||
// Check some versioned nodes to show their versions
|
||||
console.log('📊 Checking versioned nodes in database:');
|
||||
const versionedNodes = ['nodes-base.webhook', 'nodes-base.httpRequest', 'nodes-base.code', 'nodes-base.executeCommand'];
|
||||
|
||||
for (const nodeType of versionedNodes) {
|
||||
const nodeInfo = repository.getNode(nodeType);
|
||||
if (nodeInfo) {
|
||||
console.log(`- ${nodeType}: isVersioned=${nodeInfo.isVersioned}, maxVersion=${nodeInfo.version || 'N/A'}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
|
||||
// Test each workflow
|
||||
for (const [testName, workflow] of Object.entries(testWorkflows)) {
|
||||
console.log(`\n🧪 Testing: ${testName}`);
|
||||
console.log('─'.repeat(50));
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
console.log(`\n✅ Valid: ${result.valid}`);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.log('\n❌ Errors:');
|
||||
result.errors.forEach(error => {
|
||||
console.log(` - [${error.nodeName || 'Workflow'}] ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log('\n⚠️ Warnings:');
|
||||
result.warnings.forEach(warning => {
|
||||
console.log(` - [${warning.nodeName || 'Workflow'}] ${warning.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (result.suggestions.length > 0) {
|
||||
console.log('\n💡 Suggestions:');
|
||||
result.suggestions.forEach(suggestion => {
|
||||
console.log(` - ${suggestion}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n\n✅ typeVersion validation test completed!');
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testTypeVersionValidation().catch(console.error);
|
||||
Executable
+196
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test script for URL configuration in n8n-MCP HTTP server
|
||||
* Tests various BASE_URL, TRUST_PROXY, and proxy header scenarios
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { spawn } from 'child_process';
|
||||
import { logger } from '../src/utils/logger';
|
||||
|
||||
interface TestCase {
|
||||
name: string;
|
||||
env: Record<string, string>;
|
||||
expectedUrls?: {
|
||||
health: string;
|
||||
mcp: string;
|
||||
};
|
||||
proxyHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
{
|
||||
name: 'Default configuration (no BASE_URL)',
|
||||
env: {
|
||||
MCP_MODE: 'http',
|
||||
AUTH_TOKEN: 'test-token-for-testing-only',
|
||||
PORT: '3001'
|
||||
},
|
||||
expectedUrls: {
|
||||
health: 'http://localhost:3001/health',
|
||||
mcp: 'http://localhost:3001/mcp'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'With BASE_URL configured',
|
||||
env: {
|
||||
MCP_MODE: 'http',
|
||||
AUTH_TOKEN: 'test-token-for-testing-only',
|
||||
PORT: '3002',
|
||||
BASE_URL: 'https://n8n-mcp.example.com'
|
||||
},
|
||||
expectedUrls: {
|
||||
health: 'https://n8n-mcp.example.com/health',
|
||||
mcp: 'https://n8n-mcp.example.com/mcp'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'With PUBLIC_URL configured',
|
||||
env: {
|
||||
MCP_MODE: 'http',
|
||||
AUTH_TOKEN: 'test-token-for-testing-only',
|
||||
PORT: '3003',
|
||||
PUBLIC_URL: 'https://api.company.com/mcp'
|
||||
},
|
||||
expectedUrls: {
|
||||
health: 'https://api.company.com/mcp/health',
|
||||
mcp: 'https://api.company.com/mcp/mcp'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'With TRUST_PROXY and proxy headers',
|
||||
env: {
|
||||
MCP_MODE: 'http',
|
||||
AUTH_TOKEN: 'test-token-for-testing-only',
|
||||
PORT: '3004',
|
||||
TRUST_PROXY: '1'
|
||||
},
|
||||
proxyHeaders: {
|
||||
'X-Forwarded-Proto': 'https',
|
||||
'X-Forwarded-Host': 'proxy.example.com'
|
||||
}
|
||||
},
|
||||
{
|
||||
// DEPRECATED: This test case tests the deprecated fixed HTTP implementation
|
||||
// See: https://github.com/czlonkowski/n8n-mcp/issues/524
|
||||
name: 'Fixed HTTP implementation (DEPRECATED)',
|
||||
env: {
|
||||
MCP_MODE: 'http',
|
||||
USE_FIXED_HTTP: 'true', // DEPRECATED: Will be removed in future version
|
||||
AUTH_TOKEN: 'test-token-for-testing-only',
|
||||
PORT: '3005',
|
||||
BASE_URL: 'https://fixed.example.com'
|
||||
},
|
||||
expectedUrls: {
|
||||
health: 'https://fixed.example.com/health',
|
||||
mcp: 'https://fixed.example.com/mcp'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
async function runTest(testCase: TestCase): Promise<void> {
|
||||
console.log(`\n🧪 Testing: ${testCase.name}`);
|
||||
console.log('Environment:', testCase.env);
|
||||
|
||||
const serverProcess = spawn('node', ['dist/mcp/index.js'], {
|
||||
env: { ...process.env, ...testCase.env }
|
||||
});
|
||||
|
||||
let serverOutput = '';
|
||||
let serverStarted = false;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
serverProcess.kill();
|
||||
reject(new Error('Server startup timeout'));
|
||||
}, 10000);
|
||||
|
||||
serverProcess.stdout.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
serverOutput += output;
|
||||
|
||||
if (output.includes('Press Ctrl+C to stop the server')) {
|
||||
serverStarted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Give server a moment to fully initialize
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Test root endpoint
|
||||
const rootUrl = `http://localhost:${testCase.env.PORT}/`;
|
||||
const rootResponse = await axios.get(rootUrl, {
|
||||
headers: testCase.proxyHeaders || {}
|
||||
});
|
||||
|
||||
console.log('✅ Root endpoint response:');
|
||||
console.log(` - Endpoints: ${JSON.stringify(rootResponse.data.endpoints, null, 2)}`);
|
||||
|
||||
// Test health endpoint
|
||||
const healthUrl = `http://localhost:${testCase.env.PORT}/health`;
|
||||
const healthResponse = await axios.get(healthUrl);
|
||||
console.log(`✅ Health endpoint status: ${healthResponse.data.status}`);
|
||||
|
||||
// Test MCP info endpoint (GET /mcp now requires auth per GHSA-75hx-xj24-mqrw)
|
||||
const mcpUrl = `http://localhost:${testCase.env.PORT}/mcp`;
|
||||
const mcpResponse = await axios.get(mcpUrl, {
|
||||
headers: { Authorization: `Bearer ${testCase.env.AUTH_TOKEN}` }
|
||||
});
|
||||
console.log(`✅ MCP info endpoint: ${mcpResponse.data.description}`);
|
||||
|
||||
// Check console output
|
||||
if (testCase.expectedUrls) {
|
||||
const outputContainsExpectedUrls =
|
||||
serverOutput.includes(testCase.expectedUrls.health) &&
|
||||
serverOutput.includes(testCase.expectedUrls.mcp);
|
||||
|
||||
if (outputContainsExpectedUrls) {
|
||||
console.log('✅ Console output shows expected URLs');
|
||||
} else {
|
||||
console.log('❌ Console output does not show expected URLs');
|
||||
console.log('Expected:', testCase.expectedUrls);
|
||||
}
|
||||
}
|
||||
|
||||
serverProcess.kill();
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed:', error instanceof Error ? error.message : String(error));
|
||||
serverProcess.kill();
|
||||
reject(error);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.stderr.on('data', (data) => {
|
||||
console.error('Server error:', data.toString());
|
||||
});
|
||||
|
||||
serverProcess.on('close', (code) => {
|
||||
if (!serverStarted) {
|
||||
reject(new Error(`Server exited with code ${code} before starting`));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 n8n-MCP URL Configuration Test Suite');
|
||||
console.log('======================================');
|
||||
|
||||
for (const testCase of testCases) {
|
||||
try {
|
||||
await runTest(testCase);
|
||||
console.log('✅ Test passed\n');
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed:', error instanceof Error ? error.message : String(error));
|
||||
console.log('\n');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✨ All tests completed');
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Test User ID Persistence
|
||||
* Verifies that user IDs are consistent across sessions and modes
|
||||
*/
|
||||
|
||||
import { TelemetryConfigManager } from '../src/telemetry/config-manager';
|
||||
import { hostname, platform, arch, homedir } from 'os';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
console.log('=== User ID Persistence Test ===\n');
|
||||
|
||||
// Test 1: Verify deterministic ID generation
|
||||
console.log('Test 1: Deterministic ID Generation');
|
||||
console.log('-----------------------------------');
|
||||
|
||||
const machineId = `${hostname()}-${platform()}-${arch()}-${homedir()}`;
|
||||
const expectedUserId = createHash('sha256')
|
||||
.update(machineId)
|
||||
.digest('hex')
|
||||
.substring(0, 16);
|
||||
|
||||
console.log('Machine characteristics:');
|
||||
console.log(' hostname:', hostname());
|
||||
console.log(' platform:', platform());
|
||||
console.log(' arch:', arch());
|
||||
console.log(' homedir:', homedir());
|
||||
console.log('\nGenerated machine ID:', machineId);
|
||||
console.log('Expected user ID:', expectedUserId);
|
||||
|
||||
// Test 2: Load actual config
|
||||
console.log('\n\nTest 2: Actual Config Manager');
|
||||
console.log('-----------------------------------');
|
||||
|
||||
const configManager = TelemetryConfigManager.getInstance();
|
||||
const actualUserId = configManager.getUserId();
|
||||
const config = configManager.loadConfig();
|
||||
|
||||
console.log('Actual user ID:', actualUserId);
|
||||
console.log('Config first run:', config.firstRun || 'Unknown');
|
||||
console.log('Config version:', config.version || 'Unknown');
|
||||
console.log('Telemetry enabled:', config.enabled);
|
||||
|
||||
// Test 3: Verify consistency
|
||||
console.log('\n\nTest 3: Consistency Check');
|
||||
console.log('-----------------------------------');
|
||||
|
||||
const match = actualUserId === expectedUserId;
|
||||
console.log('User IDs match:', match ? '✓ YES' : '✗ NO');
|
||||
|
||||
if (!match) {
|
||||
console.log('WARNING: User ID mismatch detected!');
|
||||
console.log('This could indicate an implementation issue.');
|
||||
}
|
||||
|
||||
// Test 4: Multiple loads (simulate multiple sessions)
|
||||
console.log('\n\nTest 4: Multiple Session Simulation');
|
||||
console.log('-----------------------------------');
|
||||
|
||||
const userId1 = configManager.getUserId();
|
||||
const userId2 = TelemetryConfigManager.getInstance().getUserId();
|
||||
const userId3 = configManager.getUserId();
|
||||
|
||||
console.log('Session 1 user ID:', userId1);
|
||||
console.log('Session 2 user ID:', userId2);
|
||||
console.log('Session 3 user ID:', userId3);
|
||||
|
||||
const consistent = userId1 === userId2 && userId2 === userId3;
|
||||
console.log('All sessions consistent:', consistent ? '✓ YES' : '✗ NO');
|
||||
|
||||
// Test 5: Docker environment simulation
|
||||
console.log('\n\nTest 5: Docker Environment Check');
|
||||
console.log('-----------------------------------');
|
||||
|
||||
const isDocker = process.env.IS_DOCKER === 'true';
|
||||
console.log('Running in Docker:', isDocker);
|
||||
|
||||
if (isDocker) {
|
||||
console.log('\n⚠️ DOCKER MODE DETECTED');
|
||||
console.log('In Docker, user IDs may change across container recreations because:');
|
||||
console.log(' 1. Container hostname changes each time');
|
||||
console.log(' 2. Config file is not persisted (no volume mount)');
|
||||
console.log(' 3. Each container gets a new ephemeral filesystem');
|
||||
console.log('\nRecommendation: Mount ~/.n8n-mcp as a volume for persistent user IDs');
|
||||
}
|
||||
|
||||
// Test 6: Environment variable override check
|
||||
console.log('\n\nTest 6: Environment Variable Override');
|
||||
console.log('-----------------------------------');
|
||||
|
||||
const telemetryDisabledVars = [
|
||||
'N8N_MCP_TELEMETRY_DISABLED',
|
||||
'TELEMETRY_DISABLED',
|
||||
'DISABLE_TELEMETRY'
|
||||
];
|
||||
|
||||
telemetryDisabledVars.forEach(varName => {
|
||||
const value = process.env[varName];
|
||||
if (value !== undefined) {
|
||||
console.log(`${varName}:`, value);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nTelemetry status:', configManager.isEnabled() ? 'ENABLED' : 'DISABLED');
|
||||
|
||||
// Summary
|
||||
console.log('\n\n=== SUMMARY ===');
|
||||
console.log('User ID:', actualUserId);
|
||||
console.log('Deterministic:', match ? 'YES ✓' : 'NO ✗');
|
||||
console.log('Persistent across sessions:', consistent ? 'YES ✓' : 'NO ✗');
|
||||
console.log('Telemetry enabled:', config.enabled ? 'YES' : 'NO');
|
||||
console.log('Docker mode:', isDocker ? 'YES' : 'NO');
|
||||
|
||||
if (isDocker && !process.env.N8N_MCP_CONFIG_VOLUME) {
|
||||
console.log('\n⚠️ WARNING: Running in Docker without persistent volume!');
|
||||
console.log('User IDs will change on container recreation.');
|
||||
console.log('Mount /home/nodejs/.n8n-mcp to persist telemetry config.');
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator.js';
|
||||
|
||||
console.log('🧪 Testing Webhook Data Access Validation\n');
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
name: 'Direct webhook data access (incorrect)',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `// Processing data from Webhook node
|
||||
const prevWebhook = $('Webhook').first();
|
||||
const command = items[0].json.testCommand;
|
||||
const data = items[0].json.payload;
|
||||
return [{json: {command, data}}];`
|
||||
},
|
||||
expectWarning: true
|
||||
},
|
||||
{
|
||||
name: 'Correct webhook data access through body',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `// Processing data from Webhook node
|
||||
const webhookData = items[0].json.body;
|
||||
const command = webhookData.testCommand;
|
||||
const data = webhookData.payload;
|
||||
return [{json: {command, data}}];`
|
||||
},
|
||||
expectWarning: false
|
||||
},
|
||||
{
|
||||
name: 'Common webhook field names without body',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `// Processing webhook
|
||||
const command = items[0].json.command;
|
||||
const action = items[0].json.action;
|
||||
const payload = items[0].json.payload;
|
||||
return [{json: {command, action, payload}}];`
|
||||
},
|
||||
expectWarning: true
|
||||
},
|
||||
{
|
||||
name: 'Non-webhook data access (should not warn)',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `// Processing data from HTTP Request node
|
||||
const data = items[0].json.results;
|
||||
const status = items[0].json.status;
|
||||
return [{json: {data, status}}];`
|
||||
},
|
||||
expectWarning: false
|
||||
},
|
||||
{
|
||||
name: 'Mixed correct and incorrect access',
|
||||
config: {
|
||||
language: 'javaScript',
|
||||
jsCode: `// Mixed access patterns
|
||||
const webhookBody = items[0].json.body; // Correct
|
||||
const directAccess = items[0].json.command; // Incorrect if webhook
|
||||
return [{json: {webhookBody, directAccess}}];`
|
||||
},
|
||||
expectWarning: false // If user already uses .body, we assume they know the pattern
|
||||
}
|
||||
];
|
||||
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const test of testCases) {
|
||||
console.log(`Test: ${test.name}`);
|
||||
const result = EnhancedConfigValidator.validateWithMode(
|
||||
'nodes-base.code',
|
||||
test.config,
|
||||
[
|
||||
{ name: 'language', type: 'options', options: ['javaScript', 'python'] },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
],
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
|
||||
const hasWebhookWarning = result.warnings.some(w =>
|
||||
w.message.includes('Webhook data is nested under .body') ||
|
||||
w.message.includes('webhook data, remember it\'s nested under .body')
|
||||
);
|
||||
|
||||
const passed = hasWebhookWarning === test.expectWarning;
|
||||
|
||||
console.log(` Expected warning: ${test.expectWarning}`);
|
||||
console.log(` Has webhook warning: ${hasWebhookWarning}`);
|
||||
console.log(` Result: ${passed ? '✅ PASS' : '❌ FAIL'}`);
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
const relevantWarnings = result.warnings
|
||||
.filter(w => w.message.includes('webhook') || w.message.includes('Webhook'))
|
||||
.map(w => w.message);
|
||||
if (relevantWarnings.length > 0) {
|
||||
console.log(` Webhook warnings: ${relevantWarnings.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (passed) passCount++;
|
||||
else failCount++;
|
||||
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`\n📊 Results: ${passCount} passed, ${failCount} failed`);
|
||||
console.log(failCount === 0 ? '✅ All webhook validation tests passed!' : '❌ Some tests failed');
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Test direct workflow insert to Supabase
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const TELEMETRY_BACKEND = {
|
||||
URL: 'https://ydyufsohxdfpopqbubwk.supabase.co',
|
||||
ANON_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkeXVmc29oeGRmcG9wcWJ1YndrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTg3OTYyMDAsImV4cCI6MjA3NDM3MjIwMH0.xESphg6h5ozaDsm4Vla3QnDJGc6Nc_cpfoqTHRynkCk'
|
||||
};
|
||||
|
||||
async function testWorkflowInsert() {
|
||||
const supabase = createClient(TELEMETRY_BACKEND.URL, TELEMETRY_BACKEND.ANON_KEY, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
}
|
||||
});
|
||||
|
||||
const testWorkflow = {
|
||||
user_id: 'direct-test-' + Date.now(),
|
||||
workflow_hash: 'hash-direct-' + Date.now(),
|
||||
node_count: 2,
|
||||
node_types: ['webhook', 'http'],
|
||||
has_trigger: true,
|
||||
has_webhook: true,
|
||||
complexity: 'simple' as const,
|
||||
sanitized_workflow: {
|
||||
nodes: [
|
||||
{ id: '1', type: 'webhook', parameters: {} },
|
||||
{ id: '2', type: 'http', parameters: {} }
|
||||
],
|
||||
connections: {}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Attempting direct insert to telemetry_workflows...');
|
||||
console.log('Data:', JSON.stringify(testWorkflow, null, 2));
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('telemetry_workflows')
|
||||
.insert([testWorkflow]);
|
||||
|
||||
if (error) {
|
||||
console.error('\n❌ Error:', error);
|
||||
} else {
|
||||
console.log('\n✅ Success! Workflow inserted');
|
||||
if (data) {
|
||||
console.log('Response:', data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testWorkflowInsert().catch(console.error);
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Test workflow sanitizer
|
||||
*/
|
||||
|
||||
import { WorkflowSanitizer } from '../src/telemetry/workflow-sanitizer';
|
||||
|
||||
const testWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook1',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
name: 'Webhook',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
path: '/test-webhook',
|
||||
httpMethod: 'POST'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http1',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
name: 'HTTP Request',
|
||||
position: [250, 0],
|
||||
parameters: {
|
||||
url: 'https://api.example.com/endpoint',
|
||||
method: 'GET',
|
||||
authentication: 'genericCredentialType',
|
||||
sendHeaders: true,
|
||||
headerParameters: {
|
||||
parameters: [
|
||||
{
|
||||
name: 'Authorization',
|
||||
value: 'Bearer sk-1234567890abcdef'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'webhook1': {
|
||||
main: [[{ node: 'http1', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('🧪 Testing Workflow Sanitizer\n');
|
||||
console.log('Original workflow has', testWorkflow.nodes.length, 'nodes');
|
||||
|
||||
try {
|
||||
const sanitized = WorkflowSanitizer.sanitizeWorkflow(testWorkflow);
|
||||
|
||||
console.log('\n✅ Sanitization successful!');
|
||||
console.log('\nSanitized output:');
|
||||
console.log(JSON.stringify(sanitized, null, 2));
|
||||
|
||||
console.log('\n📊 Metrics:');
|
||||
console.log('- Workflow Hash:', sanitized.workflowHash);
|
||||
console.log('- Node Count:', sanitized.nodeCount);
|
||||
console.log('- Node Types:', sanitized.nodeTypes);
|
||||
console.log('- Has Trigger:', sanitized.hasTrigger);
|
||||
console.log('- Has Webhook:', sanitized.hasWebhook);
|
||||
console.log('- Complexity:', sanitized.complexity);
|
||||
} catch (error) {
|
||||
console.error('❌ Sanitization failed:', error);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Debug workflow tracking in telemetry manager
|
||||
*/
|
||||
|
||||
import { TelemetryManager } from '../src/telemetry/telemetry-manager';
|
||||
|
||||
// Get the singleton instance
|
||||
const telemetry = TelemetryManager.getInstance();
|
||||
|
||||
const testWorkflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook1',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
name: 'Webhook',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
path: '/test-' + Date.now(),
|
||||
httpMethod: 'POST'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http1',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
name: 'HTTP Request',
|
||||
position: [250, 0],
|
||||
parameters: {
|
||||
url: 'https://api.example.com/data',
|
||||
method: 'GET'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'slack1',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
name: 'Slack',
|
||||
position: [500, 0],
|
||||
parameters: {
|
||||
channel: '#general',
|
||||
text: 'Workflow complete!'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'webhook1': {
|
||||
main: [[{ node: 'http1', type: 'main', index: 0 }]]
|
||||
},
|
||||
'http1': {
|
||||
main: [[{ node: 'slack1', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('🧪 Testing Workflow Tracking\n');
|
||||
console.log('Workflow has', testWorkflow.nodes.length, 'nodes');
|
||||
|
||||
// Track the workflow
|
||||
console.log('Calling trackWorkflowCreation...');
|
||||
telemetry.trackWorkflowCreation(testWorkflow, true);
|
||||
|
||||
console.log('Waiting for async processing...');
|
||||
|
||||
// Wait for setImmediate to process
|
||||
setTimeout(async () => {
|
||||
console.log('\nForcing flush...');
|
||||
await telemetry.flush();
|
||||
console.log('✅ Flush complete!');
|
||||
|
||||
console.log('\nWorkflow should now be in the telemetry_workflows table.');
|
||||
console.log('Check with: SELECT * FROM telemetry_workflows ORDER BY created_at DESC LIMIT 1;');
|
||||
}, 2000);
|
||||
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test Workflow Versioning System
|
||||
*
|
||||
* Tests the complete workflow rollback and versioning functionality:
|
||||
* - Automatic backup creation
|
||||
* - Auto-pruning to 10 versions
|
||||
* - Version history retrieval
|
||||
* - Rollback with validation
|
||||
* - Manual pruning and cleanup
|
||||
* - Storage statistics
|
||||
*/
|
||||
|
||||
import { NodeRepository } from '../src/database/node-repository';
|
||||
import { createDatabaseAdapter } from '../src/database/database-adapter';
|
||||
import { WorkflowVersioningService } from '../src/services/workflow-versioning-service';
|
||||
import { logger } from '../src/utils/logger';
|
||||
import { existsSync } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Mock workflow for testing
|
||||
const createMockWorkflow = (id: string, name: string, nodeCount: number = 3) => ({
|
||||
id,
|
||||
name,
|
||||
active: false,
|
||||
nodes: Array.from({ length: nodeCount }, (_, i) => ({
|
||||
id: `node-${i}`,
|
||||
name: `Node ${i}`,
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 1,
|
||||
position: [250 + i * 200, 300],
|
||||
parameters: { values: { string: [{ name: `field${i}`, value: `value${i}` }] } }
|
||||
})),
|
||||
connections: nodeCount > 1 ? {
|
||||
'node-0': { main: [[{ node: 'node-1', type: 'main', index: 0 }]] },
|
||||
...(nodeCount > 2 && { 'node-1': { main: [[{ node: 'node-2', type: 'main', index: 0 }]] } })
|
||||
} : {},
|
||||
settings: {}
|
||||
});
|
||||
|
||||
async function runTests() {
|
||||
console.log('🧪 Testing Workflow Versioning System\n');
|
||||
|
||||
// Find database path
|
||||
const possiblePaths = [
|
||||
path.join(process.cwd(), 'data', 'nodes.db'),
|
||||
path.join(__dirname, '../../data', 'nodes.db'),
|
||||
'./data/nodes.db'
|
||||
];
|
||||
|
||||
let dbPath: string | null = null;
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p)) {
|
||||
dbPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dbPath) {
|
||||
console.error('❌ Database not found. Please run npm run rebuild first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`📁 Using database: ${dbPath}\n`);
|
||||
|
||||
// Initialize repository
|
||||
const db = await createDatabaseAdapter(dbPath);
|
||||
const repository = new NodeRepository(db);
|
||||
const service = new WorkflowVersioningService(repository);
|
||||
|
||||
const workflowId = 'test-workflow-001';
|
||||
let testsPassed = 0;
|
||||
let testsFailed = 0;
|
||||
|
||||
try {
|
||||
// Test 1: Create initial backup
|
||||
console.log('📝 Test 1: Create initial backup');
|
||||
const workflow1 = createMockWorkflow(workflowId, 'Test Workflow v1', 3);
|
||||
const backup1 = await service.createBackup(workflowId, workflow1, {
|
||||
trigger: 'partial_update',
|
||||
operations: [{ type: 'addNode', node: workflow1.nodes[0] }]
|
||||
});
|
||||
|
||||
if (backup1.versionId && backup1.versionNumber === 1 && backup1.pruned === 0) {
|
||||
console.log('✅ Initial backup created successfully');
|
||||
console.log(` Version ID: ${backup1.versionId}, Version Number: ${backup1.versionNumber}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to create initial backup');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 2: Create multiple backups to test auto-pruning
|
||||
console.log('\n📝 Test 2: Create 12 backups to test auto-pruning (should keep only 10)');
|
||||
for (let i = 2; i <= 12; i++) {
|
||||
const workflow = createMockWorkflow(workflowId, `Test Workflow v${i}`, 3 + i);
|
||||
await service.createBackup(workflowId, workflow, {
|
||||
trigger: i % 3 === 0 ? 'full_update' : 'partial_update',
|
||||
operations: [{ type: 'addNode', node: { id: `node-${i}` } }]
|
||||
});
|
||||
}
|
||||
|
||||
const versions = await service.getVersionHistory(workflowId, 100);
|
||||
if (versions.length === 10) {
|
||||
console.log(`✅ Auto-pruning works correctly (kept exactly 10 versions)`);
|
||||
console.log(` Latest version: ${versions[0].versionNumber}, Oldest: ${versions[9].versionNumber}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log(`❌ Auto-pruning failed (expected 10 versions, got ${versions.length})`);
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 3: Get version history
|
||||
console.log('\n📝 Test 3: Get version history');
|
||||
const history = await service.getVersionHistory(workflowId, 5);
|
||||
if (history.length === 5 && history[0].versionNumber > history[4].versionNumber) {
|
||||
console.log(`✅ Version history retrieved successfully (${history.length} versions)`);
|
||||
console.log(' Recent versions:');
|
||||
history.forEach(v => {
|
||||
console.log(` - v${v.versionNumber} (${v.trigger}) - ${v.workflowName} - ${(v.size / 1024).toFixed(2)} KB`);
|
||||
});
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to get version history');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 4: Get specific version
|
||||
console.log('\n📝 Test 4: Get specific version details');
|
||||
const specificVersion = await service.getVersion(history[2].id);
|
||||
if (specificVersion && specificVersion.workflowSnapshot) {
|
||||
console.log(`✅ Retrieved version ${specificVersion.versionNumber} successfully`);
|
||||
console.log(` Workflow name: ${specificVersion.workflowName}`);
|
||||
console.log(` Node count: ${specificVersion.workflowSnapshot.nodes.length}`);
|
||||
console.log(` Trigger: ${specificVersion.trigger}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to get specific version');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 5: Compare two versions
|
||||
console.log('\n📝 Test 5: Compare two versions');
|
||||
if (history.length >= 2) {
|
||||
const diff = await service.compareVersions(history[0].id, history[1].id);
|
||||
console.log(`✅ Version comparison successful`);
|
||||
console.log(` Comparing v${diff.version1Number} → v${diff.version2Number}`);
|
||||
console.log(` Added nodes: ${diff.addedNodes.length}`);
|
||||
console.log(` Removed nodes: ${diff.removedNodes.length}`);
|
||||
console.log(` Modified nodes: ${diff.modifiedNodes.length}`);
|
||||
console.log(` Connection changes: ${diff.connectionChanges}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Not enough versions to compare');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 6: Manual pruning
|
||||
console.log('\n📝 Test 6: Manual pruning (keep only 5 versions)');
|
||||
const pruneResult = await service.pruneVersions(workflowId, 5);
|
||||
if (pruneResult.pruned === 5 && pruneResult.remaining === 5) {
|
||||
console.log(`✅ Manual pruning successful`);
|
||||
console.log(` Pruned: ${pruneResult.pruned} versions, Remaining: ${pruneResult.remaining}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log(`❌ Manual pruning failed (expected 5 pruned, 5 remaining, got ${pruneResult.pruned} pruned, ${pruneResult.remaining} remaining)`);
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 7: Storage statistics
|
||||
console.log('\n📝 Test 7: Storage statistics');
|
||||
const stats = await service.getStorageStats();
|
||||
if (stats.totalVersions > 0 && stats.byWorkflow.length > 0) {
|
||||
console.log(`✅ Storage stats retrieved successfully`);
|
||||
console.log(` Total versions: ${stats.totalVersions}`);
|
||||
console.log(` Total size: ${stats.totalSizeFormatted}`);
|
||||
console.log(` Workflows with versions: ${stats.byWorkflow.length}`);
|
||||
stats.byWorkflow.forEach(w => {
|
||||
console.log(` - ${w.workflowName}: ${w.versionCount} versions, ${w.totalSizeFormatted}`);
|
||||
});
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to get storage stats');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 8: Delete specific version
|
||||
console.log('\n📝 Test 8: Delete specific version');
|
||||
const versionsBeforeDelete = await service.getVersionHistory(workflowId, 100);
|
||||
const versionToDelete = versionsBeforeDelete[versionsBeforeDelete.length - 1];
|
||||
const deleteResult = await service.deleteVersion(versionToDelete.id);
|
||||
const versionsAfterDelete = await service.getVersionHistory(workflowId, 100);
|
||||
|
||||
if (deleteResult.success && versionsAfterDelete.length === versionsBeforeDelete.length - 1) {
|
||||
console.log(`✅ Version deletion successful`);
|
||||
console.log(` Deleted version ${versionToDelete.versionNumber}`);
|
||||
console.log(` Remaining versions: ${versionsAfterDelete.length}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to delete version');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 9: Test different trigger types
|
||||
console.log('\n📝 Test 9: Test different trigger types');
|
||||
const workflow2 = createMockWorkflow(workflowId, 'Test Workflow Autofix', 2);
|
||||
const backupAutofix = await service.createBackup(workflowId, workflow2, {
|
||||
trigger: 'autofix',
|
||||
fixTypes: ['expression-format', 'typeversion-correction']
|
||||
});
|
||||
|
||||
const workflow3 = createMockWorkflow(workflowId, 'Test Workflow Full Update', 4);
|
||||
const backupFull = await service.createBackup(workflowId, workflow3, {
|
||||
trigger: 'full_update',
|
||||
metadata: { reason: 'Major refactoring' }
|
||||
});
|
||||
|
||||
const allVersions = await service.getVersionHistory(workflowId, 100);
|
||||
const autofixVersions = allVersions.filter(v => v.trigger === 'autofix');
|
||||
const fullUpdateVersions = allVersions.filter(v => v.trigger === 'full_update');
|
||||
const partialUpdateVersions = allVersions.filter(v => v.trigger === 'partial_update');
|
||||
|
||||
if (autofixVersions.length > 0 && fullUpdateVersions.length > 0 && partialUpdateVersions.length > 0) {
|
||||
console.log(`✅ All trigger types working correctly`);
|
||||
console.log(` Partial updates: ${partialUpdateVersions.length}`);
|
||||
console.log(` Full updates: ${fullUpdateVersions.length}`);
|
||||
console.log(` Autofixes: ${autofixVersions.length}`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to create versions with different trigger types');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 10: Cleanup - Delete all versions for workflow
|
||||
console.log('\n📝 Test 10: Delete all versions for workflow');
|
||||
const deleteAllResult = await service.deleteAllVersions(workflowId);
|
||||
const versionsAfterDeleteAll = await service.getVersionHistory(workflowId, 100);
|
||||
|
||||
if (deleteAllResult.deleted > 0 && versionsAfterDeleteAll.length === 0) {
|
||||
console.log(`✅ Delete all versions successful`);
|
||||
console.log(` Deleted ${deleteAllResult.deleted} versions`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Failed to delete all versions');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Test 11: Truncate all versions (requires confirmation)
|
||||
console.log('\n📝 Test 11: Test truncate without confirmation');
|
||||
const truncateResult1 = await service.truncateAllVersions(false);
|
||||
if (truncateResult1.deleted === 0 && truncateResult1.message.includes('not confirmed')) {
|
||||
console.log(`✅ Truncate safety check works (requires confirmation)`);
|
||||
testsPassed++;
|
||||
} else {
|
||||
console.log('❌ Truncate safety check failed');
|
||||
testsFailed++;
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 Test Summary');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`✅ Passed: ${testsPassed}`);
|
||||
console.log(`❌ Failed: ${testsFailed}`);
|
||||
console.log(`📈 Success Rate: ${((testsPassed / (testsPassed + testsFailed)) * 100).toFixed(1)}%`);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
if (testsFailed === 0) {
|
||||
console.log('\n🎉 All tests passed! Workflow versioning system is working correctly.');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('\n⚠️ Some tests failed. Please review the implementation.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('\n❌ Test suite failed with error:', error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests
|
||||
runTests().catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+192
@@ -0,0 +1,192 @@
|
||||
#!/bin/bash
|
||||
# Comprehensive script to update n8n dependencies, run tests, and prepare for npm publish
|
||||
# Based on MEMORY_N8N_UPDATE.md but enhanced with test suite and publish preparation
|
||||
|
||||
set -e
|
||||
|
||||
# Color codes for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}🚀 n8n Update and Publish Preparation Script${NC}"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
|
||||
# 1. Check current branch
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
if [ "$CURRENT_BRANCH" != "main" ]; then
|
||||
echo -e "${YELLOW}⚠️ Warning: Not on main branch (current: $CURRENT_BRANCH)${NC}"
|
||||
echo "It's recommended to run this on the main branch."
|
||||
read -p "Continue anyway? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Check for uncommitted changes
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
echo -e "${RED}❌ Error: You have uncommitted changes${NC}"
|
||||
echo "Please commit or stash your changes before updating."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Get current versions for comparison
|
||||
echo -e "${BLUE}📊 Current versions:${NC}"
|
||||
CURRENT_N8N=$(node -e "console.log(require('./package.json').dependencies['n8n-nodes-base'])" 2>/dev/null || echo "not installed")
|
||||
CURRENT_PROJECT=$(node -e "console.log(require('./package.json').version)")
|
||||
echo "- n8n-nodes-base: $CURRENT_N8N"
|
||||
echo "- n8n-mcp: $CURRENT_PROJECT"
|
||||
echo ""
|
||||
|
||||
# 4. Check for updates first
|
||||
echo -e "${BLUE}🔍 Checking for n8n updates...${NC}"
|
||||
npm run update:n8n:check
|
||||
|
||||
echo ""
|
||||
read -p "Do you want to proceed with the update? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Update cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 5. Update n8n dependencies
|
||||
echo ""
|
||||
echo -e "${BLUE}📦 Updating n8n dependencies...${NC}"
|
||||
npm run update:n8n
|
||||
|
||||
# 6. Run the test suite
|
||||
echo ""
|
||||
echo -e "${BLUE}🧪 Running comprehensive test suite (1,182 tests)...${NC}"
|
||||
npm test
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}❌ Tests failed! Please fix failing tests before proceeding.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✅ All tests passed!${NC}"
|
||||
|
||||
# 7. Run validation
|
||||
echo ""
|
||||
echo -e "${BLUE}✔️ Validating critical nodes...${NC}"
|
||||
npm run validate
|
||||
|
||||
# 8. Build the project
|
||||
echo ""
|
||||
echo -e "${BLUE}🔨 Building project...${NC}"
|
||||
npm run build
|
||||
|
||||
# 9. Bump version
|
||||
echo ""
|
||||
echo -e "${BLUE}📌 Bumping version...${NC}"
|
||||
# Get new n8n-nodes-base version
|
||||
NEW_N8N=$(node -e "console.log(require('./package.json').dependencies['n8n-nodes-base'])")
|
||||
# Bump patch version
|
||||
npm version patch --no-git-tag-version
|
||||
|
||||
# Get new project version
|
||||
NEW_PROJECT=$(node -e "console.log(require('./package.json').version)")
|
||||
|
||||
# 10. Update n8n version badge in README
|
||||
echo ""
|
||||
echo -e "${BLUE}📝 Updating n8n version badge...${NC}"
|
||||
sed -i.bak "s/n8n-v[0-9.]*/n8n-$NEW_N8N/" README.md && rm README.md.bak
|
||||
|
||||
# 11. Sync runtime version (this also updates the version badge in README)
|
||||
echo ""
|
||||
echo -e "${BLUE}🔄 Syncing runtime version and updating version badge...${NC}"
|
||||
npm run sync:runtime-version
|
||||
|
||||
# 12. Get update details for commit message
|
||||
echo ""
|
||||
echo -e "${BLUE}📊 Gathering update information...${NC}"
|
||||
# Get all tracked n8n package versions
|
||||
N8N_CORE=$(node -e "console.log(require('./package.json').dependencies['n8n-core'])")
|
||||
N8N_WORKFLOW=$(node -e "console.log(require('./package.json').dependencies['n8n-workflow'])")
|
||||
N8N_LANGCHAIN=$(node -e "console.log(require('./package.json').dependencies['@n8n/n8n-nodes-langchain'])")
|
||||
|
||||
# Get node count from database
|
||||
NODE_COUNT=$(node -e "
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database('./data/nodes.db', { readonly: true });
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM nodes').get().count;
|
||||
console.log(count);
|
||||
db.close();
|
||||
" 2>/dev/null || echo "unknown")
|
||||
|
||||
# Check if templates were sanitized
|
||||
TEMPLATES_SANITIZED=false
|
||||
if [ -f "./data/nodes.db" ]; then
|
||||
TEMPLATE_COUNT=$(node -e "
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database('./data/nodes.db', { readonly: true });
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM templates').get().count;
|
||||
console.log(count);
|
||||
db.close();
|
||||
" 2>/dev/null || echo "0")
|
||||
if [ "$TEMPLATE_COUNT" != "0" ]; then
|
||||
TEMPLATES_SANITIZED=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 13. Create commit message
|
||||
echo ""
|
||||
echo -e "${BLUE}📝 Creating commit...${NC}"
|
||||
COMMIT_MSG="chore: update n8n to $NEW_N8N and bump version to $NEW_PROJECT
|
||||
|
||||
- Updated n8n-nodes-base to $NEW_N8N
|
||||
- Updated n8n-core to $N8N_CORE
|
||||
- Updated n8n-workflow to $N8N_WORKFLOW
|
||||
- Updated @n8n/n8n-nodes-langchain to $N8N_LANGCHAIN
|
||||
- Rebuilt node database with $NODE_COUNT nodes"
|
||||
|
||||
if [ "$TEMPLATES_SANITIZED" = true ]; then
|
||||
COMMIT_MSG="$COMMIT_MSG
|
||||
- Sanitized $TEMPLATE_COUNT workflow templates"
|
||||
fi
|
||||
|
||||
COMMIT_MSG="$COMMIT_MSG
|
||||
- All 1,182 tests passing (933 unit, 249 integration)
|
||||
- All validation tests passing
|
||||
- Built and prepared for npm publish
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
|
||||
# 14. Stage all changes
|
||||
git add -A
|
||||
|
||||
# 15. Show what will be committed
|
||||
echo ""
|
||||
echo -e "${BLUE}📋 Changes to be committed:${NC}"
|
||||
git status --short
|
||||
|
||||
# 16. Commit changes
|
||||
git commit -m "$COMMIT_MSG"
|
||||
|
||||
# 17. Summary
|
||||
echo ""
|
||||
echo -e "${GREEN}✅ Update completed successfully!${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Summary:${NC}"
|
||||
echo "- Updated n8n-nodes-base from $CURRENT_N8N to $NEW_N8N"
|
||||
echo "- Bumped version from $CURRENT_PROJECT to $NEW_PROJECT"
|
||||
echo "- All 1,182 tests passed"
|
||||
echo "- Project built and ready for npm publish"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo "1. Push to GitHub:"
|
||||
echo -e " ${GREEN}git push origin $CURRENT_BRANCH${NC}"
|
||||
echo ""
|
||||
echo "2. Create a GitHub release (after push):"
|
||||
echo -e " ${GREEN}gh release create v$NEW_PROJECT --title \"v$NEW_PROJECT\" --notes \"Updated n8n to $NEW_N8N\"${NC}"
|
||||
echo ""
|
||||
echo "3. Publish to npm:"
|
||||
echo -e " ${GREEN}npm run prepare:publish${NC}"
|
||||
echo " Then follow the instructions to publish with OTP"
|
||||
echo ""
|
||||
echo -e "${BLUE}🎉 Done!${NC}"
|
||||
Executable
+294
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Update n8n dependencies to latest versions
|
||||
* Can be run manually or via GitHub Actions
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class N8nDependencyUpdater {
|
||||
constructor() {
|
||||
this.packageJsonPath = path.join(__dirname, '..', 'package.json');
|
||||
// Track n8n-nodes-base directly (the package our loader actually requires).
|
||||
// The full `n8n` meta package was dropped in favor of this leaner dep tree.
|
||||
this.mainPackage = 'n8n-nodes-base';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semver-ish versions. Returns -1 / 0 / 1 (a<b, a==b, a>b).
|
||||
* Enough for the "don't downgrade" guard; not a full semver parser.
|
||||
*/
|
||||
compareVersions(a, b) {
|
||||
const parse = (v) => v.split('.').map((p) => parseInt(p, 10) || 0);
|
||||
const [a1, a2, a3] = parse(a);
|
||||
const [b1, b2, b3] = parse(b);
|
||||
if (a1 !== b1) return a1 < b1 ? -1 : 1;
|
||||
if (a2 !== b2) return a2 < b2 ? -1 : 1;
|
||||
if (a3 !== b3) return a3 < b3 ? -1 : 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the set of n8n sub-package versions compatible with the current
|
||||
* `n8n@latest` release. The `n8n` meta package is the source of truth for
|
||||
* which sub-package versions constitute "n8n X.Y.Z" — individual
|
||||
* sub-packages (notably n8n-nodes-base, n8n-workflow) don't keep their
|
||||
* `latest` dist-tag in sync, so querying each one's tag can return
|
||||
* versions older than what n8n itself depends on.
|
||||
*/
|
||||
getN8nDependencySet() {
|
||||
try {
|
||||
const output = execSync('npm view n8n@latest dependencies --json', { encoding: 'utf8' });
|
||||
return JSON.parse(output);
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve n8n@latest dependencies:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version from package.json
|
||||
*/
|
||||
getCurrentVersion(packageName) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(this.packageJsonPath, 'utf8'));
|
||||
const version = packageJson.dependencies[packageName];
|
||||
return version ? version.replace(/^[\^~]/, '') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which packages need updates.
|
||||
*
|
||||
* Versions are resolved from `n8n@latest`'s dependency pins rather than
|
||||
* each sub-package's own `latest` dist-tag — n8n does not keep the
|
||||
* per-package tags in sync, which previously caused this script to
|
||||
* propose downgrades.
|
||||
*/
|
||||
async checkForUpdates() {
|
||||
console.log('🔍 Checking for n8n dependency updates...\n');
|
||||
|
||||
const trackedDeps = [
|
||||
'n8n-nodes-base',
|
||||
'n8n-core',
|
||||
'n8n-workflow',
|
||||
'@n8n/n8n-nodes-langchain',
|
||||
];
|
||||
|
||||
const metaDeps = this.getN8nDependencySet();
|
||||
if (!metaDeps) {
|
||||
console.error('Aborting: could not resolve n8n@latest dependency set');
|
||||
return [];
|
||||
}
|
||||
|
||||
const updates = [];
|
||||
for (const dep of trackedDeps) {
|
||||
const currentVersion = this.getCurrentVersion(dep);
|
||||
const latestVersion = metaDeps[dep];
|
||||
|
||||
if (!currentVersion) {
|
||||
console.error(`Failed to read current version for ${dep}`);
|
||||
continue;
|
||||
}
|
||||
if (!latestVersion) {
|
||||
console.error(`${dep} is not listed in n8n@latest dependencies — skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cmp = this.compareVersions(currentVersion, latestVersion);
|
||||
if (cmp === 0) {
|
||||
console.log(`✅ ${dep}: ${currentVersion} (up to date)`);
|
||||
} else if (cmp < 0) {
|
||||
console.log(`📦 ${dep}: ${currentVersion} → ${latestVersion} (update available)`);
|
||||
updates.push({
|
||||
package: dep,
|
||||
current: currentVersion,
|
||||
latest: latestVersion,
|
||||
});
|
||||
} else {
|
||||
console.log(`⏭️ ${dep}: ${currentVersion} is ahead of n8n@latest pin ${latestVersion} — skipping (no downgrade)`);
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update package.json with new versions
|
||||
*/
|
||||
updatePackageJson(updates) {
|
||||
if (updates.length === 0) {
|
||||
console.log('\n✨ All n8n dependencies are up to date and in sync!');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`\n📝 Updating ${updates.length} packages in package.json...`);
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(this.packageJsonPath, 'utf8'));
|
||||
|
||||
for (const update of updates) {
|
||||
// Exact pin (no caret) so a fresh `npm install` after a future minor release
|
||||
// can't slip in a different node set than the database was rebuilt against.
|
||||
// The DB rebuild step assumes these versions are reproducible.
|
||||
packageJson.dependencies[update.package] = update.latest;
|
||||
console.log(` Updated ${update.package} to ${update.latest}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
this.packageJsonPath,
|
||||
JSON.stringify(packageJson, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run npm install to update lock file
|
||||
*/
|
||||
runNpmInstall() {
|
||||
console.log('\n📥 Running npm install to update lock file...');
|
||||
try {
|
||||
execSync('npm install', {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
stdio: 'inherit'
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ npm install failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the node database
|
||||
*/
|
||||
rebuildDatabase() {
|
||||
console.log('\n🔨 Rebuilding node database...');
|
||||
try {
|
||||
execSync('npm run build && npm run rebuild', {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
stdio: 'inherit'
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Database rebuild failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run validation tests
|
||||
*/
|
||||
runValidation() {
|
||||
console.log('\n🧪 Running validation tests...');
|
||||
try {
|
||||
execSync('npm run validate', {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
stdio: 'inherit'
|
||||
});
|
||||
console.log('✅ All tests passed!');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Validation failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate update summary for PR/commit message
|
||||
*/
|
||||
generateUpdateSummary(updates) {
|
||||
if (updates.length === 0) return '';
|
||||
|
||||
const summary = ['Updated n8n dependencies:\n'];
|
||||
|
||||
for (const update of updates) {
|
||||
summary.push(`- ${update.package}: ${update.current} → ${update.latest}`);
|
||||
}
|
||||
|
||||
return summary.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Main update process
|
||||
*/
|
||||
async run(options = {}) {
|
||||
const { dryRun = false, skipTests = false } = options;
|
||||
|
||||
console.log('🚀 n8n Dependency Updater\n');
|
||||
console.log('Mode:', dryRun ? 'DRY RUN' : 'LIVE UPDATE');
|
||||
console.log('Skip tests:', skipTests ? 'YES' : 'NO');
|
||||
console.log('Strategy: Update n8n and sync its required dependencies');
|
||||
console.log('');
|
||||
|
||||
// Check for updates
|
||||
const updates = await this.checkForUpdates();
|
||||
|
||||
if (updates.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log('\n🔍 DRY RUN: No changes made');
|
||||
console.log('\nUpdate summary:');
|
||||
console.log(this.generateUpdateSummary(updates));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if (!this.updatePackageJson(updates)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Install dependencies
|
||||
if (!this.runNpmInstall()) {
|
||||
console.error('\n❌ Update failed at npm install step');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Rebuild database
|
||||
if (!this.rebuildDatabase()) {
|
||||
console.error('\n❌ Update failed at database rebuild step');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run tests
|
||||
if (!skipTests && !this.runValidation()) {
|
||||
console.error('\n❌ Update failed at validation step');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Success!
|
||||
console.log('\n✅ Update completed successfully!');
|
||||
console.log('\nUpdate summary:');
|
||||
console.log(this.generateUpdateSummary(updates));
|
||||
|
||||
// Write summary to file for GitHub Actions
|
||||
if (process.env.GITHUB_ACTIONS) {
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, '..', 'update-summary.txt'),
|
||||
this.generateUpdateSummary(updates),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CLI handling
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
dryRun: args.includes('--dry-run') || args.includes('-d'),
|
||||
skipTests: args.includes('--skip-tests') || args.includes('-s')
|
||||
};
|
||||
|
||||
const updater = new N8nDependencyUpdater();
|
||||
updater.run(options).catch(error => {
|
||||
console.error('Unexpected error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = N8nDependencyUpdater;
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read package.json
|
||||
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const version = packageJson.version;
|
||||
|
||||
// Read README.md
|
||||
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||
let readmeContent = fs.readFileSync(readmePath, 'utf8');
|
||||
|
||||
// Update the version badge on line 5
|
||||
// The pattern matches: []
|
||||
const versionBadgeRegex = /(\[!\[Version\]\(https:\/\/img\.shields\.io\/badge\/version-)[^-]+(-.+?\)\])/;
|
||||
const newVersionBadge = `$1${version}$2`;
|
||||
|
||||
readmeContent = readmeContent.replace(versionBadgeRegex, newVersionBadge);
|
||||
|
||||
// Write back to README.md
|
||||
fs.writeFileSync(readmePath, readmeContent);
|
||||
|
||||
console.log(`✅ Updated README.md version badge to v${version}`);
|
||||
Reference in New Issue
Block a user