From e0f9506912aa6a129df332185063291f0627f9ca Mon Sep 17 00:00:00 2001 From: Dana <152518854+dana-gill@users.noreply.github.com> Date: Mon, 24 Mar 2025 09:11:25 +0100 Subject: [PATCH] feat(core): Add tool to uninstall a community node (#14026) --- .../commands/__tests__/community-node.test.ts | 272 ++++++++++++++++++ packages/cli/src/commands/community-node.ts | 166 +++++++++++ 2 files changed, 438 insertions(+) create mode 100644 packages/cli/src/commands/__tests__/community-node.test.ts create mode 100644 packages/cli/src/commands/community-node.ts diff --git a/packages/cli/src/commands/__tests__/community-node.test.ts b/packages/cli/src/commands/__tests__/community-node.test.ts new file mode 100644 index 00000000000..35d999393a1 --- /dev/null +++ b/packages/cli/src/commands/__tests__/community-node.test.ts @@ -0,0 +1,272 @@ +import { type Config } from '@oclif/core'; +import { mock } from 'jest-mock-extended'; + +import { type CredentialsEntity } from '@/databases/entities/credentials-entity'; +import { type InstalledNodes } from '@/databases/entities/installed-nodes'; +import { type User } from '@/databases/entities/user'; + +import { CommunityNode } from '../community-node'; + +describe('uninstallCredential', () => { + const userId = '1234'; + + const config: Config = mock(); + const communityNode = new CommunityNode(['--uninstall', '--credential', 'evolutionApi'], config); + + beforeEach(() => { + communityNode.deleteCredential = jest.fn(); + communityNode.findCredentialsByType = jest.fn(); + communityNode.findUserById = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should delete a credential', async () => { + const credentialType = 'evolutionApi'; + + const credential = mock(); + credential.id = '666'; + + const user = mock(); + const credentials = [credential]; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { credential: credentialType, uninstall: true, userId }, + }); + communityNode.findCredentialsByType = jest.fn().mockReturnValue(credentials); + communityNode.findUserById = jest.fn().mockReturnValue(user); + + const deleteCredential = jest.spyOn(communityNode, 'deleteCredential'); + const findCredentialsByType = jest.spyOn(communityNode, 'findCredentialsByType'); + const findUserById = jest.spyOn(communityNode, 'findUserById'); + + await communityNode.run(); + + expect(findCredentialsByType).toHaveBeenCalledTimes(1); + expect(findCredentialsByType).toHaveBeenCalledWith(credentialType); + + expect(findUserById).toHaveBeenCalledTimes(1); + expect(findUserById).toHaveBeenCalledWith(userId); + + expect(deleteCredential).toHaveBeenCalledTimes(1); + expect(deleteCredential).toHaveBeenCalledWith(user, credential.id); + }); + + it('should return if the user is not found', async () => { + const credentialType = 'evolutionApi'; + + const credential = mock(); + credential.id = '666'; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { credential: credentialType, uninstall: true, userId }, + }); + communityNode.findUserById = jest.fn().mockReturnValue(null); + + const deleteCredential = jest.spyOn(communityNode, 'deleteCredential'); + const findCredentialsByType = jest.spyOn(communityNode, 'findCredentialsByType'); + const findUserById = jest.spyOn(communityNode, 'findUserById'); + + await communityNode.run(); + + expect(findUserById).toHaveBeenCalledTimes(1); + expect(findUserById).toHaveBeenCalledWith(userId); + + expect(findCredentialsByType).toHaveBeenCalledTimes(0); + expect(deleteCredential).toHaveBeenCalledTimes(0); + }); + + it('should return if the credential is not found', async () => { + const credentialType = 'evolutionApi'; + + const credential = mock(); + credential.id = '666'; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { credential: credentialType, uninstall: true, userId }, + }); + communityNode.findUserById = jest.fn().mockReturnValue(mock()); + communityNode.findCredentialsByType = jest.fn().mockReturnValue(null); + + const deleteCredential = jest.spyOn(communityNode, 'deleteCredential'); + const findCredentialsByType = jest.spyOn(communityNode, 'findCredentialsByType'); + const findUserById = jest.spyOn(communityNode, 'findUserById'); + + await communityNode.run(); + + expect(findUserById).toHaveBeenCalledTimes(1); + expect(findUserById).toHaveBeenCalledWith(userId); + + expect(findCredentialsByType).toHaveBeenCalledTimes(1); + expect(findCredentialsByType).toHaveBeenCalledWith(credentialType); + + expect(deleteCredential).toHaveBeenCalledTimes(0); + }); + + it('should delete multiple credentials', async () => { + const credentialType = 'evolutionApi'; + + const credential1 = mock(); + credential1.id = '666'; + + const credential2 = mock(); + credential2.id = '777'; + + const user = mock(); + const credentials = [credential1, credential2]; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { credential: credentialType, uninstall: true, userId }, + }); + communityNode.findCredentialsByType = jest.fn().mockReturnValue(credentials); + communityNode.findUserById = jest.fn().mockReturnValue(user); + + const deleteCredential = jest.spyOn(communityNode, 'deleteCredential'); + const findCredentialsByType = jest.spyOn(communityNode, 'findCredentialsByType'); + const findUserById = jest.spyOn(communityNode, 'findUserById'); + + await communityNode.run(); + + expect(findCredentialsByType).toHaveBeenCalledTimes(1); + expect(findCredentialsByType).toHaveBeenCalledWith(credentialType); + + expect(findUserById).toHaveBeenCalledTimes(1); + expect(findUserById).toHaveBeenCalledWith(userId); + + expect(deleteCredential).toHaveBeenCalledTimes(2); + expect(deleteCredential).toHaveBeenCalledWith(user, credential1.id); + expect(deleteCredential).toHaveBeenCalledWith(user, credential2.id); + }); +}); + +describe('uninstallPackage', () => { + const config: Config = mock(); + const communityNode = new CommunityNode( + ['--uninstall', '--package', 'n8n-nodes-evolution-api.evolutionApi'], + config, + ); + + beforeEach(() => { + communityNode.removeCommunityPackage = jest.fn(); + communityNode.deleteCommunityNode = jest.fn(); + communityNode.pruneDependencies = jest.fn(); + communityNode.findCommunityPackage = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should uninstall the package', async () => { + const installedNode = mock(); + const communityPackage = { + installedNodes: [installedNode], + }; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, + }); + communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage); + + const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); + const removeCommunityPackageSpy = jest.spyOn(communityNode, 'removeCommunityPackage'); + const findCommunityPackage = jest.spyOn(communityNode, 'findCommunityPackage'); + + await communityNode.run(); + + expect(findCommunityPackage).toHaveBeenCalledTimes(1); + expect(findCommunityPackage).toHaveBeenCalledWith('n8n-nodes-evolution-api'); + + expect(deleteCommunityNode).toHaveBeenCalledTimes(1); + expect(deleteCommunityNode).toHaveBeenCalledWith(installedNode); + + expect(removeCommunityPackageSpy).toHaveBeenCalledTimes(1); + expect(removeCommunityPackageSpy).toHaveBeenCalledWith( + 'n8n-nodes-evolution-api', + communityPackage, + ); + }); + + it('should uninstall all nodes from a package', async () => { + const installedNode0 = mock(); + const installedNode1 = mock(); + + const communityPackage = { + installedNodes: [installedNode0, installedNode1], + }; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, + }); + communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage); + + const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); + const removeCommunityPackageSpy = jest.spyOn(communityNode, 'removeCommunityPackage'); + const findCommunityPackage = jest.spyOn(communityNode, 'findCommunityPackage'); + + await communityNode.run(); + + expect(findCommunityPackage).toHaveBeenCalledTimes(1); + expect(findCommunityPackage).toHaveBeenCalledWith('n8n-nodes-evolution-api'); + + expect(deleteCommunityNode).toHaveBeenCalledTimes(2); + expect(deleteCommunityNode).toHaveBeenCalledWith(installedNode0); + expect(deleteCommunityNode).toHaveBeenCalledWith(installedNode1); + + expect(removeCommunityPackageSpy).toHaveBeenCalledTimes(1); + expect(removeCommunityPackageSpy).toHaveBeenCalledWith( + 'n8n-nodes-evolution-api', + communityPackage, + ); + }); + + it('should return if a package is not found', async () => { + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, + }); + communityNode.findCommunityPackage = jest.fn().mockReturnValue(null); + + const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); + const removeCommunityPackageSpy = jest.spyOn(communityNode, 'removeCommunityPackage'); + const findCommunityPackage = jest.spyOn(communityNode, 'findCommunityPackage'); + + await communityNode.run(); + + expect(findCommunityPackage).toHaveBeenCalledTimes(1); + expect(findCommunityPackage).toHaveBeenCalledWith('n8n-nodes-evolution-api'); + + expect(deleteCommunityNode).toHaveBeenCalledTimes(0); + + expect(removeCommunityPackageSpy).toHaveBeenCalledTimes(0); + }); + + it('should return if nodes are not found', async () => { + const communityPackage = { + installedNodes: [], + }; + + communityNode.parseFlags = jest.fn().mockReturnValue({ + flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, + }); + communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage); + + const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); + const removeCommunityPackageSpy = jest.spyOn(communityNode, 'removeCommunityPackage'); + const findCommunityPackage = jest.spyOn(communityNode, 'findCommunityPackage'); + + await communityNode.run(); + + expect(findCommunityPackage).toHaveBeenCalledTimes(1); + expect(findCommunityPackage).toHaveBeenCalledWith('n8n-nodes-evolution-api'); + + expect(deleteCommunityNode).toHaveBeenCalledTimes(0); + + expect(removeCommunityPackageSpy).toHaveBeenCalledTimes(1); + expect(removeCommunityPackageSpy).toHaveBeenCalledWith( + 'n8n-nodes-evolution-api', + communityPackage, + ); + }); +}); diff --git a/packages/cli/src/commands/community-node.ts b/packages/cli/src/commands/community-node.ts new file mode 100644 index 00000000000..1c76d5515d0 --- /dev/null +++ b/packages/cli/src/commands/community-node.ts @@ -0,0 +1,166 @@ +import { Container } from '@n8n/di'; +import { Flags } from '@oclif/core'; + +import { CredentialsService } from '@/credentials/credentials.service'; +import { type InstalledNodes } from '@/databases/entities/installed-nodes'; +import { type InstalledPackages } from '@/databases/entities/installed-packages'; +import { type User } from '@/databases/entities/user'; +import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { InstalledNodesRepository } from '@/databases/repositories/installed-nodes.repository'; +import { UserRepository } from '@/databases/repositories/user.repository'; +import { CommunityPackagesService } from '@/services/community-packages.service'; + +import { BaseCommand } from './base-command'; + +export class CommunityNode extends BaseCommand { + static description = '\nUninstall a community node and its credentials'; + + static examples = [ + '$ n8n community-node --uninstall --package n8n-nodes-evolution-api', + '$ n8n community-node --uninstall --credential evolutionApi --userId 1234', + ]; + + static flags = { + help: Flags.help({ char: 'h' }), + uninstall: Flags.boolean({ + description: 'Uninstalls the node', + }), + package: Flags.string({ + description: 'Package name of the community node.', + }), + credential: Flags.string({ + description: + "Type of the credential.\nGet this value by visiting the node's .credential.ts file and getting the value of `name`", + }), + userId: Flags.string({ + description: + 'The ID of the user who owns the credential.\nOn self-hosted, query the database.\nOn cloud, query the API with your API key', + }), + }; + + async init() { + await super.init(); + } + + async run() { + const { flags } = await this.parseFlags(); + + const packageName = flags.package; + const credentialType = flags.credential; + const userId = flags.userId; + + if (!flags) { + this.logger.info('Please set flags. See help for more information.'); + return; + } + + if (!flags.uninstall) { + this.logger.info('"--uninstall" has to be set!'); + return; + } + + if (!packageName && !credentialType) { + this.logger.info('"--package" or "--credential" has to be set!'); + return; + } + + if (packageName) { + await this.uninstallPackage(packageName); + return; + } + + if (credentialType && userId) { + await this.uninstallCredential(credentialType, userId); + } else { + this.logger.info('"--userId" has to be set!'); + } + } + + async catch(error: Error) { + this.logger.error('Error in node command:'); + this.logger.error(error.message); + } + + async uninstallCredential(credentialType: string, userId: string) { + const user = await this.findUserById(userId); + + if (user === null) { + this.logger.info(`User ${userId} not found`); + return; + } + + const credentials = await this.findCredentialsByType(credentialType); + + if (credentials === null) { + this.logger.info(`Credentials with type ${credentialType} not found`); + return; + } + + credentials.forEach(async (credential) => { + await this.deleteCredential(user, credential.id); + }); + + this.logger.info(`All credentials with type ${credentialType} successfully uninstalled`); + } + + async findUserById(userId: string) { + return await Container.get(UserRepository).findOneBy({ id: userId }); + } + + async findCredentialsByType(credentialType: string) { + return await Container.get(CredentialsRepository).findBy({ type: credentialType }); + } + + async deleteCredential(user: User, credentialId: string) { + return await Container.get(CredentialsService).delete(user, credentialId); + } + + async uninstallPackage(packageName: string) { + const communityPackage = await this.findCommunityPackage(packageName); + + if (communityPackage === null) { + this.logger.info(`Package ${packageName} not found`); + return; + } + + await this.removeCommunityPackage(packageName, communityPackage); + + const installedNodes = communityPackage?.installedNodes; + + if (!installedNodes) { + this.logger.info(`Nodes in ${packageName} not found`); + return; + } + + for (const node of installedNodes) { + await this.deleteCommunityNode(node); + } + + await this.pruneDependencies(); + } + + async pruneDependencies() { + await Container.get(CommunityPackagesService).executeNpmCommand('npm prune'); + } + + async parseFlags() { + return await this.parse(CommunityNode); + } + + async deleteCommunityNode(node: InstalledNodes) { + return await Container.get(InstalledNodesRepository).delete({ + type: node.type, + }); + } + + async removeCommunityPackage(packageName: string, communityPackage: InstalledPackages) { + return await Container.get(CommunityPackagesService).removePackage( + packageName, + communityPackage, + ); + } + + async findCommunityPackage(packageName: string) { + return await Container.get(CommunityPackagesService).findInstalledPackage(packageName); + } +}