/* eslint-disable no-restricted-syntax */ /* eslint-disable @typescript-eslint/naming-convention */ import { promisify } from 'util'; import { exec } from 'child_process'; import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises'; import axios from 'axios'; import { UserSettings } from 'n8n-core'; import type { PublicInstalledPackage } from 'n8n-workflow'; import { LoggerProxy } from 'n8n-workflow'; import { NODE_PACKAGE_PREFIX, NPM_COMMAND_TOKENS, NPM_PACKAGE_STATUS_GOOD, RESPONSE_ERROR_MESSAGES, UNKNOWN_FAILURE_REASON, } from '@/constants'; import type { InstalledPackages } from '@db/entities/InstalledPackages'; import config from '@/config'; import type { CommunityPackages } from '@/Interfaces'; const { PACKAGE_NAME_NOT_PROVIDED, DISK_IS_FULL, PACKAGE_FAILED_TO_INSTALL, PACKAGE_VERSION_NOT_FOUND, PACKAGE_DOES_NOT_CONTAIN_NODES, PACKAGE_NOT_FOUND, } = RESPONSE_ERROR_MESSAGES; const { NPM_PACKAGE_NOT_FOUND_ERROR, NPM_NO_VERSION_AVAILABLE, NPM_DISK_NO_SPACE, NPM_DISK_INSUFFICIENT_SPACE, NPM_PACKAGE_VERSION_NOT_FOUND_ERROR, } = NPM_COMMAND_TOKENS; const execAsync = promisify(exec); const INVALID_OR_SUSPICIOUS_PACKAGE_NAME = /[^0-9a-z@\-./]/; export const parseNpmPackageName = (rawString?: string): CommunityPackages.ParsedPackageName => { if (!rawString) throw new Error(PACKAGE_NAME_NOT_PROVIDED); if (INVALID_OR_SUSPICIOUS_PACKAGE_NAME.test(rawString)) throw new Error('Package name must be a single word'); const scope = rawString.includes('/') ? rawString.split('/')[0] : undefined; const packageNameWithoutScope = scope ? rawString.replace(`${scope}/`, '') : rawString; if (!packageNameWithoutScope.startsWith(NODE_PACKAGE_PREFIX)) { throw new Error(`Package name must start with ${NODE_PACKAGE_PREFIX}`); } const version = packageNameWithoutScope.includes('@') ? packageNameWithoutScope.split('@')[1] : undefined; const packageName = version ? rawString.replace(`@${version}`, '') : rawString; return { packageName, scope, version, rawString, }; }; export const sanitizeNpmPackageName = parseNpmPackageName; export const executeCommand = async ( command: string, options?: { doNotHandleError?: boolean }, ): Promise => { const downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath(); const execOptions = { cwd: downloadFolder, env: { NODE_PATH: process.env.NODE_PATH, PATH: process.env.PATH, APPDATA: process.env.APPDATA, }, }; try { await fsAccess(downloadFolder); } catch (_) { await fsMkdir(downloadFolder); // Also init the folder since some versions // of npm complain if the folder is empty await execAsync('npm init -y', execOptions); } try { const commandResult = await execAsync(command, execOptions); return commandResult.stdout; } catch (error) { if (options?.doNotHandleError) throw error; const errorMessage = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON; const map = { [NPM_PACKAGE_NOT_FOUND_ERROR]: PACKAGE_NOT_FOUND, [NPM_NO_VERSION_AVAILABLE]: PACKAGE_NOT_FOUND, [NPM_PACKAGE_VERSION_NOT_FOUND_ERROR]: PACKAGE_VERSION_NOT_FOUND, [NPM_DISK_NO_SPACE]: DISK_IS_FULL, [NPM_DISK_INSUFFICIENT_SPACE]: DISK_IS_FULL, }; Object.entries(map).forEach(([npmMessage, n8nMessage]) => { if (errorMessage.includes(npmMessage)) throw new Error(n8nMessage); }); LoggerProxy.warn('npm command failed', { errorMessage }); throw new Error(PACKAGE_FAILED_TO_INSTALL); } }; export function matchPackagesWithUpdates( packages: InstalledPackages[], updates?: CommunityPackages.AvailableUpdates, ): PublicInstalledPackage[] { if (!updates) return packages; return packages.reduce((acc, cur) => { const publicPackage: PublicInstalledPackage = { ...cur }; const update = updates[cur.packageName]; if (update) publicPackage.updateAvailable = update.latest; acc.push(publicPackage); return acc; }, []); } export function matchMissingPackages( installedPackages: PublicInstalledPackage[], missingPackages: string, ): PublicInstalledPackage[] { const missingPackageNames = missingPackages.split(' '); const missingPackagesList = missingPackageNames.map((missingPackageName: string) => { // Strip away versions but maintain scope and package name try { const parsedPackageData = parseNpmPackageName(missingPackageName); return parsedPackageData.packageName; // eslint-disable-next-line no-empty } catch (_) {} return undefined; }); const hydratedPackageList = [] as PublicInstalledPackage[]; installedPackages.forEach((installedPackage) => { const hydratedInstalledPackage = { ...installedPackage }; if (missingPackagesList.includes(hydratedInstalledPackage.packageName)) { hydratedInstalledPackage.failedLoading = true; } hydratedPackageList.push(hydratedInstalledPackage); }); return hydratedPackageList; } export async function checkNpmPackageStatus( packageName: string, ): Promise { const N8N_BACKEND_SERVICE_URL = 'https://api.n8n.io/api/package'; try { const response = await axios.post( N8N_BACKEND_SERVICE_URL, { name: packageName }, { method: 'POST' }, ); if (response.data.status !== NPM_PACKAGE_STATUS_GOOD) return response.data; } catch (error) { // Do nothing if service is unreachable } return { status: NPM_PACKAGE_STATUS_GOOD }; } export function hasPackageLoaded(packageName: string): boolean { const missingPackages = config.get('nodes.packagesMissing') as string | undefined; if (!missingPackages) return true; return !missingPackages .split(' ') .some( (packageNameAndVersion) => packageNameAndVersion.startsWith(packageName) && packageNameAndVersion.replace(packageName, '').startsWith('@'), ); } export function removePackageFromMissingList(packageName: string): void { try { const failedPackages = (config.get('nodes.packagesMissing') as string).split(' '); const packageFailedToLoad = failedPackages.filter( (packageNameAndVersion) => !packageNameAndVersion.startsWith(packageName) || !packageNameAndVersion.replace(packageName, '').startsWith('@'), ); config.set('nodes.packagesMissing', packageFailedToLoad.join(' ')); } catch (_error) { // Do nothing } } export const isClientError = (error: Error): boolean => { const clientErrors = [ PACKAGE_VERSION_NOT_FOUND, PACKAGE_DOES_NOT_CONTAIN_NODES, PACKAGE_NOT_FOUND, ]; return clientErrors.some((message) => error.message.includes(message)); }; export function isNpmError(error: unknown): error is { code: number; stdout: string } { return typeof error === 'object' && error !== null && 'code' in error && 'stdout' in error; }