File size: 16,965 Bytes
da2e594
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
import * as fs from 'fs/promises';
import * as path from 'path';
import { logger } from './logger';

export interface NodeSourceInfo {
  nodeType: string;
  sourceCode: string;
  credentialCode?: string;
  packageInfo?: any;
  location: string;
}

export class NodeSourceExtractor {
  private n8nBasePaths = [
    '/usr/local/lib/node_modules/n8n/node_modules',
    '/app/node_modules',
    '/home/node/.n8n/custom/nodes',
    './node_modules',
    // Docker volume paths
    '/var/lib/docker/volumes/n8n-mcp_n8n_modules/_data',
    '/n8n-modules',
    // Common n8n installation paths
    process.env.N8N_CUSTOM_EXTENSIONS || '',
    // Additional local path for testing
    path.join(process.cwd(), 'node_modules'),
  ].filter(Boolean);

  /**
   * Extract source code for a specific n8n node
   */
  async extractNodeSource(nodeType: string): Promise<NodeSourceInfo> {
    logger.info(`Extracting source code for node: ${nodeType}`);
    
    // Parse node type to get package and node name
    const { packageName, nodeName } = this.parseNodeType(nodeType);
    
    // Search for the node in known locations
    for (const basePath of this.n8nBasePaths) {
      try {
        const nodeInfo = await this.searchNodeInPath(basePath, packageName, nodeName);
        if (nodeInfo) {
          logger.info(`Found node source at: ${nodeInfo.location}`);
          return nodeInfo;
        }
      } catch (error) {
        logger.debug(`Failed to search in ${basePath}: ${error}`);
      }
    }
    
    throw new Error(`Node source code not found for: ${nodeType}`);
  }

  /**
   * Parse node type identifier
   */
  private parseNodeType(nodeType: string): { packageName: string; nodeName: string } {
    // Handle different formats:
    // - @n8n/n8n-nodes-langchain.Agent
    // - n8n-nodes-base.HttpRequest
    // - customNode
    
    if (nodeType.includes('.')) {
      const [pkg, node] = nodeType.split('.');
      return { packageName: pkg, nodeName: node };
    }
    
    // Default to n8n-nodes-base for simple node names
    return { packageName: 'n8n-nodes-base', nodeName: nodeType };
  }

  /**
   * Search for node in a specific path
   */
  private async searchNodeInPath(
    basePath: string,
    packageName: string,
    nodeName: string
  ): Promise<NodeSourceInfo | null> {
    try {
      // Try both the provided case and capitalized first letter
      const nodeNameVariants = [
        nodeName,
        nodeName.charAt(0).toUpperCase() + nodeName.slice(1), // Capitalize first letter
        nodeName.toLowerCase(), // All lowercase
        nodeName.toUpperCase(), // All uppercase
      ];
      
      // First, try standard patterns with all case variants
      for (const nameVariant of nodeNameVariants) {
        const standardPatterns = [
          `${packageName}/dist/nodes/${nameVariant}/${nameVariant}.node.js`,
          `${packageName}/dist/nodes/${nameVariant}.node.js`,
          `${packageName}/nodes/${nameVariant}/${nameVariant}.node.js`,
          `${packageName}/nodes/${nameVariant}.node.js`,
          `${nameVariant}/${nameVariant}.node.js`,
          `${nameVariant}.node.js`,
        ];

        // Additional patterns for nested node structures (e.g., agents/Agent)
        const nestedPatterns = [
          `${packageName}/dist/nodes/*/${nameVariant}/${nameVariant}.node.js`,
          `${packageName}/dist/nodes/**/${nameVariant}/${nameVariant}.node.js`,
          `${packageName}/nodes/*/${nameVariant}/${nameVariant}.node.js`,
          `${packageName}/nodes/**/${nameVariant}/${nameVariant}.node.js`,
        ];

        // Try standard patterns first
        for (const pattern of standardPatterns) {
          const fullPath = path.join(basePath, pattern);
          const result = await this.tryLoadNodeFile(fullPath, packageName, nodeName, basePath);
          if (result) return result;
        }

        // Try nested patterns (with glob-like search)
        for (const pattern of nestedPatterns) {
          const result = await this.searchWithGlobPattern(basePath, pattern, packageName, nodeName);
          if (result) return result;
        }
      }

      // If basePath contains .pnpm, search in pnpm structure
      if (basePath.includes('node_modules')) {
        const pnpmPath = path.join(basePath, '.pnpm');
        try {
          await fs.access(pnpmPath);
          const result = await this.searchInPnpm(pnpmPath, packageName, nodeName);
          if (result) return result;
        } catch {
          // .pnpm directory doesn't exist
        }
      }
    } catch (error) {
      logger.debug(`Error searching in path ${basePath}: ${error}`);
    }

    return null;
  }

  /**
   * Search for nodes in pnpm's special directory structure
   */
  private async searchInPnpm(
    pnpmPath: string,
    packageName: string,
    nodeName: string
  ): Promise<NodeSourceInfo | null> {
    try {
      const entries = await fs.readdir(pnpmPath);
      
      // Filter entries that might contain our package
      const packageEntries = entries.filter(entry => 
        entry.includes(packageName.replace('/', '+')) || 
        entry.includes(packageName)
      );

      for (const entry of packageEntries) {
        const entryPath = path.join(pnpmPath, entry, 'node_modules', packageName);
        
        // Search patterns within the pnpm package directory
        const patterns = [
          `dist/nodes/${nodeName}/${nodeName}.node.js`,
          `dist/nodes/${nodeName}.node.js`,
          `dist/nodes/*/${nodeName}/${nodeName}.node.js`,
          `dist/nodes/**/${nodeName}/${nodeName}.node.js`,
        ];

        for (const pattern of patterns) {
          if (pattern.includes('*')) {
            const result = await this.searchWithGlobPattern(entryPath, pattern, packageName, nodeName);
            if (result) return result;
          } else {
            const fullPath = path.join(entryPath, pattern);
            const result = await this.tryLoadNodeFile(fullPath, packageName, nodeName, entryPath);
            if (result) return result;
          }
        }
      }
    } catch (error) {
      logger.debug(`Error searching in pnpm directory: ${error}`);
    }

    return null;
  }

  /**
   * Search for files matching a glob-like pattern
   */
  private async searchWithGlobPattern(
    basePath: string,
    pattern: string,
    packageName: string,
    nodeName: string
  ): Promise<NodeSourceInfo | null> {
    // Convert glob pattern to regex parts
    const parts = pattern.split('/');
    const targetFile = `${nodeName}.node.js`;
    
    async function searchDir(currentPath: string, remainingParts: string[]): Promise<string | null> {
      if (remainingParts.length === 0) return null;
      
      const part = remainingParts[0];
      const isLastPart = remainingParts.length === 1;
      
      try {
        if (isLastPart && part === targetFile) {
          // Check if file exists
          const fullPath = path.join(currentPath, part);
          await fs.access(fullPath);
          return fullPath;
        }
        
        const entries = await fs.readdir(currentPath, { withFileTypes: true });
        
        for (const entry of entries) {
          if (!entry.isDirectory() && !isLastPart) continue;
          
          if (part === '*' || part === '**') {
            // Match any directory
            if (entry.isDirectory()) {
              const result = await searchDir(
                path.join(currentPath, entry.name),
                part === '**' ? remainingParts : remainingParts.slice(1)
              );
              if (result) return result;
            }
          } else if (entry.name === part || (isLastPart && entry.name === targetFile)) {
            if (isLastPart && entry.isFile()) {
              return path.join(currentPath, entry.name);
            } else if (!isLastPart && entry.isDirectory()) {
              const result = await searchDir(
                path.join(currentPath, entry.name),
                remainingParts.slice(1)
              );
              if (result) return result;
            }
          }
        }
      } catch {
        // Directory doesn't exist or can't be read
      }
      
      return null;
    }
    
    const foundPath = await searchDir(basePath, parts);
    if (foundPath) {
      return this.tryLoadNodeFile(foundPath, packageName, nodeName, basePath);
    }
    
    return null;
  }

  /**
   * Try to load a node file and its associated files
   */
  private async tryLoadNodeFile(
    fullPath: string,
    packageName: string,
    nodeName: string,
    packageBasePath: string
  ): Promise<NodeSourceInfo | null> {
    try {
      const sourceCode = await fs.readFile(fullPath, 'utf-8');
      
      // Try to find credential files
      let credentialCode: string | undefined;
      
      // First, try alongside the node file
      const credentialPath = fullPath.replace('.node.js', '.credentials.js');
      try {
        credentialCode = await fs.readFile(credentialPath, 'utf-8');
      } catch {
        // Try in the credentials directory
        const possibleCredentialPaths = [
          // Standard n8n structure: dist/credentials/NodeNameApi.credentials.js
          path.join(packageBasePath, packageName, 'dist/credentials', `${nodeName}Api.credentials.js`),
          path.join(packageBasePath, packageName, 'dist/credentials', `${nodeName}OAuth2Api.credentials.js`),
          path.join(packageBasePath, packageName, 'credentials', `${nodeName}Api.credentials.js`),
          path.join(packageBasePath, packageName, 'credentials', `${nodeName}OAuth2Api.credentials.js`),
          // Without packageName in path
          path.join(packageBasePath, 'dist/credentials', `${nodeName}Api.credentials.js`),
          path.join(packageBasePath, 'dist/credentials', `${nodeName}OAuth2Api.credentials.js`),
          path.join(packageBasePath, 'credentials', `${nodeName}Api.credentials.js`),
          path.join(packageBasePath, 'credentials', `${nodeName}OAuth2Api.credentials.js`),
          // Try relative to node location
          path.join(path.dirname(path.dirname(fullPath)), 'credentials', `${nodeName}Api.credentials.js`),
          path.join(path.dirname(path.dirname(fullPath)), 'credentials', `${nodeName}OAuth2Api.credentials.js`),
          path.join(path.dirname(path.dirname(path.dirname(fullPath))), 'credentials', `${nodeName}Api.credentials.js`),
          path.join(path.dirname(path.dirname(path.dirname(fullPath))), 'credentials', `${nodeName}OAuth2Api.credentials.js`),
        ];
        
        // Try to find any credential file
        const allCredentials: string[] = [];
        for (const credPath of possibleCredentialPaths) {
          try {
            const content = await fs.readFile(credPath, 'utf-8');
            allCredentials.push(content);
            logger.debug(`Found credential file at: ${credPath}`);
          } catch {
            // Continue searching
          }
        }
        
        // If we found credentials, combine them
        if (allCredentials.length > 0) {
          credentialCode = allCredentials.join('\n\n// --- Next Credential File ---\n\n');
        }
      }

      // Try to get package.json info
      let packageInfo: any;
      const possiblePackageJsonPaths = [
        path.join(packageBasePath, 'package.json'),
        path.join(packageBasePath, packageName, 'package.json'),
        path.join(path.dirname(path.dirname(fullPath)), 'package.json'),
        path.join(path.dirname(path.dirname(path.dirname(fullPath))), 'package.json'),
        // Try to go up from the node location to find package.json
        path.join(fullPath.split('/dist/')[0], 'package.json'),
        path.join(fullPath.split('/nodes/')[0], 'package.json'),
      ];

      for (const packageJsonPath of possiblePackageJsonPaths) {
        try {
          const packageJson = await fs.readFile(packageJsonPath, 'utf-8');
          packageInfo = JSON.parse(packageJson);
          logger.debug(`Found package.json at: ${packageJsonPath}`);
          break;
        } catch {
          // Try next path
        }
      }

      return {
        nodeType: `${packageName}.${nodeName}`,
        sourceCode,
        credentialCode,
        packageInfo,
        location: fullPath,
      };
    } catch {
      return null;
    }
  }

  /**
   * List all available nodes
   */
  async listAvailableNodes(category?: string, search?: string): Promise<any[]> {
    const nodes: any[] = [];
    const seenNodes = new Set<string>(); // Track unique nodes
    
    for (const basePath of this.n8nBasePaths) {
      try {
        // Check for n8n-nodes-base specifically
        const n8nNodesBasePath = path.join(basePath, 'n8n-nodes-base', 'dist', 'nodes');
        try {
          await fs.access(n8nNodesBasePath);
          await this.scanDirectoryForNodes(n8nNodesBasePath, nodes, category, search, seenNodes);
        } catch {
          // Try without dist
          const altPath = path.join(basePath, 'n8n-nodes-base', 'nodes');
          try {
            await fs.access(altPath);
            await this.scanDirectoryForNodes(altPath, nodes, category, search, seenNodes);
          } catch {
            // Try the base path directly
            await this.scanDirectoryForNodes(basePath, nodes, category, search, seenNodes);
          }
        }
      } catch (error) {
        logger.debug(`Failed to scan ${basePath}: ${error}`);
      }
    }

    return nodes;
  }

  /**
   * Scan directory for n8n nodes
   */
  private async scanDirectoryForNodes(
    dirPath: string,
    nodes: any[],
    category?: string,
    search?: string,
    seenNodes?: Set<string>
  ): Promise<void> {
    try {
      const entries = await fs.readdir(dirPath, { withFileTypes: true });
      
      for (const entry of entries) {
        if (entry.isFile() && entry.name.endsWith('.node.js')) {
          try {
            const fullPath = path.join(dirPath, entry.name);
            const content = await fs.readFile(fullPath, 'utf-8');
            
            // Extract basic info from the source
            const nameMatch = content.match(/displayName:\s*['"`]([^'"`]+)['"`]/);
            const descriptionMatch = content.match(/description:\s*['"`]([^'"`]+)['"`]/);
            
            if (nameMatch) {
              const nodeName = entry.name.replace('.node.js', '');
              
              // Skip if we've already seen this node
              if (seenNodes && seenNodes.has(nodeName)) {
                continue;
              }
              
              const nodeInfo = {
                name: nodeName,
                displayName: nameMatch[1],
                description: descriptionMatch ? descriptionMatch[1] : '',
                location: fullPath,
              };

              // Apply filters
              if (category && !nodeInfo.displayName.toLowerCase().includes(category.toLowerCase())) {
                continue;
              }
              if (search && !nodeInfo.displayName.toLowerCase().includes(search.toLowerCase()) &&
                  !nodeInfo.description.toLowerCase().includes(search.toLowerCase())) {
                continue;
              }

              nodes.push(nodeInfo);
              if (seenNodes) {
                seenNodes.add(nodeName);
              }
            }
          } catch {
            // Skip files we can't read
          }
        } else if (entry.isDirectory()) {
          // Special handling for .pnpm directories
          if (entry.name === '.pnpm') {
            await this.scanPnpmDirectory(path.join(dirPath, entry.name), nodes, category, search, seenNodes);
          } else if (entry.name !== 'node_modules') {
            // Recursively scan subdirectories
            await this.scanDirectoryForNodes(path.join(dirPath, entry.name), nodes, category, search, seenNodes);
          }
        }
      }
    } catch (error) {
      logger.debug(`Error scanning directory ${dirPath}: ${error}`);
    }
  }

  /**
   * Scan pnpm directory structure for nodes
   */
  private async scanPnpmDirectory(
    pnpmPath: string,
    nodes: any[],
    category?: string,
    search?: string,
    seenNodes?: Set<string>
  ): Promise<void> {
    try {
      const entries = await fs.readdir(pnpmPath);
      
      for (const entry of entries) {
        const entryPath = path.join(pnpmPath, entry, 'node_modules');
        try {
          await fs.access(entryPath);
          await this.scanDirectoryForNodes(entryPath, nodes, category, search, seenNodes);
        } catch {
          // Skip if node_modules doesn't exist
        }
      }
    } catch (error) {
      logger.debug(`Error scanning pnpm directory ${pnpmPath}: ${error}`);
    }
  }

  /**
   * Extract AI Agent node specifically
   */
  async extractAIAgentNode(): Promise<NodeSourceInfo> {
    // AI Agent is typically in @n8n/n8n-nodes-langchain package
    return this.extractNodeSource('@n8n/n8n-nodes-langchain.Agent');
  }
}