From a99862dbf5f52c4efae2b028cef54e5394ce2ba2 Mon Sep 17 00:00:00 2001 From: Bernhard Wittmann Date: Wed, 24 Jun 2026 19:52:43 +0200 Subject: [PATCH] fix(core): Prevent startup failure from partially-installed community packages (#32909) --- .../scan-directory-for-packages.test.ts | 106 ++++++++++++++++++ .../scan-directory-for-packages.ts | 31 +++-- 2 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/nodes-loader/__tests__/scan-directory-for-packages.test.ts diff --git a/packages/core/src/nodes-loader/__tests__/scan-directory-for-packages.test.ts b/packages/core/src/nodes-loader/__tests__/scan-directory-for-packages.test.ts new file mode 100644 index 00000000000..100f23a19f3 --- /dev/null +++ b/packages/core/src/nodes-loader/__tests__/scan-directory-for-packages.test.ts @@ -0,0 +1,106 @@ +// eslint-disable-next-line import-x/order +import { mock } from 'vitest-mock-extended'; +import { Logger } from '@n8n/backend-common'; +import * as fs from 'node:fs'; +import type * as fsPromises from 'node:fs/promises'; + +vi.mock('node:fs', () => mock()); +vi.mock('node:fs/promises', () => mock()); + +const mockFs = mock(fs); + +vi.mock('fast-glob', () => ({ + default: async (pattern: string) => { + if (pattern === '@*/n8n-nodes-*') { + return ['@mendable/n8n-nodes-firecrawl', '@elevenlabs/n8n-nodes-elevenlabs']; + } + return []; + }, +})); + +import { mockInstance } from '@test/utils'; + +import { LazyPackageDirectoryLoader } from '../lazy-package-directory-loader'; +import { scanDirectoryForPackages } from '../scan-directory-for-packages'; + +describe('scanDirectoryForPackages', () => { + const nodeModulesDir = '/data/nodes/node_modules'; + let logger: ReturnType>; + + const packageJsonFor = (filePath: string) => + filePath.includes('elevenlabs') + ? JSON.stringify({ name: '@elevenlabs/n8n-nodes-elevenlabs', version: '1.0.0' }) + : JSON.stringify({ name: '@mendable/n8n-nodes-firecrawl', version: '2.1.2' }); + + const enoent = (file: string): NodeJS.ErrnoException => { + const error: NodeJS.ErrnoException = new Error( + `ENOENT: no such file or directory, open '${file}'`, + ); + error.code = 'ENOENT'; + return error; + }; + + beforeEach(() => { + vi.clearAllMocks(); + logger = mockInstance(Logger); + // Symlink resolution at construction time resolves to the same path. + mockFs.realpathSync.mockImplementation((p) => p as string); + }); + + it('skips a directory whose package.json is missing and returns the valid loaders', async () => { + mockFs.readFileSync.mockImplementation((filePath) => { + const file = String(filePath); + if (file.includes('firecrawl')) throw enoent(file); + return packageJsonFor(file); + }); + + const loaders = await scanDirectoryForPackages(nodeModulesDir); + + expect(loaders).toHaveLength(1); + expect(loaders[0]).toBeInstanceOf(LazyPackageDirectoryLoader); + expect((loaders[0] as LazyPackageDirectoryLoader).packageName).toBe( + '@elevenlabs/n8n-nodes-elevenlabs', + ); + }); + + it('skips a directory whose package.json is malformed and returns the valid loaders', async () => { + mockFs.readFileSync.mockImplementation((filePath) => { + const file = String(filePath); + if (file.includes('firecrawl')) return '{ not valid json'; + return packageJsonFor(file); + }); + + const loaders = await scanDirectoryForPackages(nodeModulesDir); + + expect(loaders).toHaveLength(1); + expect((loaders[0] as LazyPackageDirectoryLoader).packageName).toBe( + '@elevenlabs/n8n-nodes-elevenlabs', + ); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it('logs a warning for each skipped directory', async () => { + mockFs.readFileSync.mockImplementation((filePath) => { + const file = String(filePath); + if (file.includes('firecrawl')) throw enoent(file); + return packageJsonFor(file); + }); + + await scanDirectoryForPackages(nodeModulesDir); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('@mendable/n8n-nodes-firecrawl'), + expect.objectContaining({ error: expect.any(Error) }), + ); + }); + + it('returns a loader for every directory when all are well-formed', async () => { + mockFs.readFileSync.mockImplementation((filePath) => packageJsonFor(String(filePath))); + + const loaders = await scanDirectoryForPackages(nodeModulesDir); + + expect(loaders).toHaveLength(2); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/nodes-loader/scan-directory-for-packages.ts b/packages/core/src/nodes-loader/scan-directory-for-packages.ts index d66f04feee6..67db8b7451e 100644 --- a/packages/core/src/nodes-loader/scan-directory-for-packages.ts +++ b/packages/core/src/nodes-loader/scan-directory-for-packages.ts @@ -1,3 +1,5 @@ +import { Logger } from '@n8n/backend-common'; +import { Container } from '@n8n/di'; import glob from 'fast-glob'; import { type NodeLoader } from 'n8n-workflow'; import path from 'path'; @@ -24,12 +26,25 @@ export async function scanDirectoryForPackages( ...(await glob('@*/n8n-nodes-*', { ...globOptions, deep: 2 })), ]; - return installedPackagePaths.map( - (packagePath) => - new LazyPackageDirectoryLoader( - path.join(nodeModulesDir, packagePath), - options.excludeNodes, - options.includeNodes, - ), - ); + const logger = Container.get(Logger); + const loaders: NodeLoader[] = []; + + for (const packagePath of installedPackagePaths) { + try { + loaders.push( + new LazyPackageDirectoryLoader( + path.join(nodeModulesDir, packagePath), + options.excludeNodes, + options.includeNodes, + ), + ); + } catch (error) { + logger.warn( + `Skipping package directory "${packagePath}": failed to load package metadata. The package may be partially installed or corrupted.`, + { error }, + ); + } + } + + return loaders; }