diff --git a/packages/cli/src/CommunityNodes/helpers.ts b/packages/cli/src/CommunityNodes/helpers.ts new file mode 100644 index 0000000000000..d6e6f72c0b2b7 --- /dev/null +++ b/packages/cli/src/CommunityNodes/helpers.ts @@ -0,0 +1,65 @@ +/* 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 { UserSettings } from 'n8n-core'; +import { NODE_PACKAGE_PREFIX, RESPONSE_ERROR_MESSAGES } from '../constants'; +import { IParsedNpmPackageName } from '../Interfaces'; + +const execAsync = promisify(exec); + +export const parsePackageName = (originalString: string | undefined): IParsedNpmPackageName => { + if (!originalString) { + throw new Error('Package name was not provided'); + } + + const scope = originalString.includes('/') ? originalString.split('/')[0] : undefined; + + const packageNameWithoutScope = scope ? originalString.replace(scope, '') : originalString; + + if (!packageNameWithoutScope.startsWith(NODE_PACKAGE_PREFIX)) { + throw new Error('Package name is not valid'); + } + + const version = packageNameWithoutScope.includes('@') + ? packageNameWithoutScope.split('@')[1] + : undefined; + + const packageName = version ? originalString.replace(`@${version}`, '') : originalString; + + return { + packageName, + scope, + version, + originalString, + }; +}; + +export const executeCommand = async (command: string): Promise => { + const downloadFolder = UserSettings.getUserN8nFolderDowloadedNodesPath(); + // Make sure the node-download folder exists + try { + await fsAccess(downloadFolder); + // eslint-disable-next-line no-empty + } catch (error) { + await fsMkdir(downloadFolder); + } + const execOptions = { + cwd: downloadFolder, + env: { + NODE_PATH: process.env.NODE_PATH, + PATH: process.env.PATH, + }, + }; + + try { + await execAsync(command, execOptions); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + if (error.message.includes('404 Not Found')) { + throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND); + } + throw error; + } +}; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 7e7c80aea6c3f..8ad0285f89db1 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -657,3 +657,10 @@ export interface IWorkflowExecuteProcess { } export type WhereClause = Record; + +export interface IParsedNpmPackageName { + packageName: string; + originalString: string; + scope?: string; + version?: string; +} diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index cee9df7760d9e..0e027261ce521 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -25,23 +25,20 @@ import { import { access as fsAccess, - mkdir as fsMkdir, readdir as fsReaddir, readFile as fsReadFile, stat as fsStat, } from 'fs/promises'; import glob from 'fast-glob'; import path from 'path'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import { IN8nNodePackageJson } from './Interfaces'; import { getLogger } from './Logger'; import config from '../config'; import { Db } from '.'; import { InstalledPackages } from './databases/entities/InstalledPackages'; import { InstalledNodes } from './databases/entities/InstalledNodes'; - -const execAsync = promisify(exec); +import { executeCommand, parsePackageName } from './CommunityNodes/helpers'; +import { RESPONSE_ERROR_MESSAGES } from './constants'; const CUSTOM_NODES_CATEGORY = 'Custom Nodes'; @@ -205,42 +202,23 @@ class LoadNodesAndCredentialsClass { } async loadNpmModule(packageName: string): Promise { + const parsedPackaeName = parsePackageName(packageName); const downloadFolder = UserSettings.getUserN8nFolderDowloadedNodesPath(); - - // Make sure the node-download folder exists - try { - await fsAccess(downloadFolder); - // eslint-disable-next-line no-empty - } catch (error) { - await fsMkdir(downloadFolder); - } - const command = `npm install ${packageName}`; - const execOptions = { - cwd: downloadFolder, - env: { - NODE_PATH: process.env.NODE_PATH, - PATH: process.env.PATH, - }, - }; try { - await execAsync(command, execOptions); + await executeCommand(command); } catch (error) { - if (error.message.includes('404 Not Found')) { + if (error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) { throw new Error(`The npm package "${packageName}" could not be found.`); } throw error; } - const packageNameWithoutVersion = packageName.includes('@') - ? packageName.split('@')[0] - : packageName; - const finalNodeUnpackedPath = path.join( downloadFolder, 'node_modules', - packageNameWithoutVersion, + parsedPackaeName.packageName, ); const loadedNodes = await this.loadDataFromPackage(finalNodeUnpackedPath); @@ -254,7 +232,7 @@ class LoadNodesAndCredentialsClass { const promises = []; const installedPackage = Object.assign(new InstalledPackages(), { - packageName: packageNameWithoutVersion, + packageName: parsedPackaeName.packageName, installedVersion: packageFile.version, }); await transactionManager.save(installedPackage); @@ -265,7 +243,7 @@ class LoadNodesAndCredentialsClass { name: loadedNode.name, type: loadedNode.name, latestVersion: loadedNode.version, - package: packageNameWithoutVersion, + package: parsedPackaeName.packageName, }); return transactionManager.save(installedNode); }), @@ -279,31 +257,21 @@ class LoadNodesAndCredentialsClass { } } else { // Remove this package since it contains no loadable nodes + const removeCommand = `npm remove ${packageName}`; + try { + await executeCommand(removeCommand); + } catch (error) { + // Do nothing + } } return loadedNodes; } async removeNpmModule(packageName: string, installedNodes: InstalledNodes[]): Promise { - const downloadFolder = UserSettings.getUserN8nFolderDowloadedNodesPath(); - const command = `npm remove ${packageName}`; - const execOptions = { - cwd: downloadFolder, - env: { - NODE_PATH: process.env.NODE_PATH, - PATH: process.env.PATH, - }, - }; - try { - await execAsync(command, execOptions); - } catch (error) { - if (error.message.includes('404 Not Found')) { - throw new Error(`The npm package "${packageName}" could not be found.`); - } - throw error; - } + await executeCommand(command); installedNodes.forEach((installedNode) => { delete this.nodeTypes[installedNode.name]; @@ -317,18 +285,11 @@ class LoadNodesAndCredentialsClass { const downloadFolder = UserSettings.getUserN8nFolderDowloadedNodesPath(); const command = `npm update ${packageName}`; - const execOptions = { - cwd: downloadFolder, - env: { - NODE_PATH: process.env.NODE_PATH, - PATH: process.env.PATH, - }, - }; try { - await execAsync(command, execOptions); + await executeCommand(command); } catch (error) { - if (error.message.includes('404 Not Found')) { + if (error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) { throw new Error(`The npm package "${packageName}" could not be found.`); } throw error; @@ -384,6 +345,12 @@ class LoadNodesAndCredentialsClass { } } else { // Remove this package since it contains no loadable nodes + const removeCommand = `npm remove ${packageName}`; + try { + await executeCommand(removeCommand); + } catch (error) { + // Do nothing + } } return loadedNodes; diff --git a/packages/cli/src/api/nodes.api.ts b/packages/cli/src/api/nodes.api.ts index b97fdd833abee..ebf700149f7aa 100644 --- a/packages/cli/src/api/nodes.api.ts +++ b/packages/cli/src/api/nodes.api.ts @@ -6,6 +6,7 @@ import { getLogger } from '../Logger'; import { Db, ResponseHelper, LoadNodesAndCredentials, Push } from '..'; import { NodeRequest } from '../requests'; +import { RESPONSE_ERROR_MESSAGES } from '../constants'; export const nodesController = express.Router(); @@ -26,7 +27,11 @@ nodesController.post( ResponseHelper.send(async (req: NodeRequest.Post) => { const { name } = req.body; if (!name) { - throw new ResponseHelper.ResponseError(`The parameter "name" is missing!`, undefined, 400); + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.PACKAGE_NAME_NOT_PROVIDED, + undefined, + 400, + ); } try { @@ -69,8 +74,13 @@ nodesController.delete( ResponseHelper.send(async (req: NodeRequest.Delete) => { const { name } = req.body; if (!name) { - throw new ResponseHelper.ResponseError(`The parameter "name" is missing!`, undefined, 400); + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.PACKAGE_NAME_NOT_PROVIDED, + undefined, + 400, + ); } + const installedPackage = await Db.collections.InstalledPackages.findOne({ where: { packageName: name, @@ -79,7 +89,11 @@ nodesController.delete( }); if (!installedPackage) { - throw new ResponseHelper.ResponseError('Package is not installed', undefined, 400); + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_INSTALLED, + undefined, + 400, + ); } try { @@ -112,7 +126,11 @@ nodesController.patch( ResponseHelper.send(async (req: NodeRequest.Update) => { const { name } = req.body; if (!name) { - throw new ResponseHelper.ResponseError(`The parameter "name" is missing!`, undefined, 400); + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.PACKAGE_NAME_NOT_PROVIDED, + undefined, + 400, + ); } const installedPackage = await Db.collections.InstalledPackages.findOne({ where: { @@ -122,7 +140,11 @@ nodesController.patch( }); if (!installedPackage) { - throw new ResponseHelper.ResponseError('Package is not installed', undefined, 400); + throw new ResponseHelper.ResponseError( + RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_INSTALLED, + undefined, + 400, + ); } try { @@ -145,6 +167,13 @@ nodesController.patch( pushInstance.send('reloadNodeType', nodeData); }); } catch (error) { + installedPackage.installedNodes.forEach((installedNode) => { + const pushInstance = Push.getInstance(); + pushInstance.send('removeNodeType', { + name: installedNode.type, + version: installedNode.latestVersion, + }); + }); throw new ResponseHelper.ResponseError( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access `Error updating package "${name}": ${error.message}`, diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 59062c46448c5..d67d4566f6e93 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -4,9 +4,15 @@ import { RESPONSE_ERROR_MESSAGES as CORE_RESPONSE_ERROR_MESSAGES } from 'n8n-core'; +export const NODE_PACKAGE_PREFIX = 'n8n-nodes-'; + export const RESPONSE_ERROR_MESSAGES = { NO_CREDENTIAL: 'Credential not found', NO_ENCRYPTION_KEY: CORE_RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY, + PACKAGE_NAME_NOT_PROVIDED: 'Package name is required', + PACKAGE_NAME_NOT_VALID: `Package name is not valid - it must start with "${NODE_PACKAGE_PREFIX}"`, + PACKAGE_NOT_INSTALLED: 'This package is not installed - you must install it first', + PACKAGE_NOT_FOUND: 'Failed installing package: package was not found', }; export const AUTH_COOKIE_NAME = 'n8n-auth';