mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-20 11:22:15 +00:00
refactor(core): Improve community node repo code (#3767)
* 📘 Tighten `NodeRequest` * :blue: Add `AuthAgent` type * ⚡ Add constants * 📘 Namespace npm types * 🧪 Set up `createAuthAgent` * 🧪 Refactor helpers tests * 🧪 Refactor endpoints tests * ⚡ Refactor CNR helpers * ⚡ Return promises in `packageModel` * ⚡ Refactor endpoints * ✏️ Restore naming * ➕ Expose dependency `jest-mock` * 📦 Update `package-lock.json` * 📦 Update `package-lock.json` * 👕 Fix lint * 🚚 Rename namespace * 🔥 Remove outdated comment * 🐛 Fix `Promise` comparison * ⏪ Undo `ResponseHelper` change * ✏️ Document `ResponseError` * 🎨 Fix formatting
This commit is contained in:
@@ -1,71 +1,89 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
/* eslint-disable import/no-cycle */
|
||||
/* 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 { LoggerProxy, PublicInstalledPackage } from 'n8n-workflow';
|
||||
import axios from 'axios';
|
||||
|
||||
import {
|
||||
NODE_PACKAGE_PREFIX,
|
||||
NPM_COMMAND_TOKENS,
|
||||
NPM_PACKAGE_STATUS_GOOD,
|
||||
RESPONSE_ERROR_MESSAGES,
|
||||
UNKNOWN_FAILURE_REASON,
|
||||
} from '../constants';
|
||||
import { NpmPackageStatusCheck, NpmUpdatesAvailable, ParsedNpmPackageName } from '../Interfaces';
|
||||
import { InstalledPackages } from '../databases/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);
|
||||
|
||||
export const parsePackageName = (originalString: string | undefined): ParsedNpmPackageName => {
|
||||
if (!originalString) {
|
||||
throw new Error('Package name was not provided');
|
||||
}
|
||||
const INVALID_OR_SUSPICIOUS_PACKAGE_NAME = /[^0-9a-z@\-./]/;
|
||||
|
||||
if (new RegExp(/[^0-9a-z@\-./]/).test(originalString)) {
|
||||
// Prevent any strings that are not valid npm package names or
|
||||
// could indicate malicous commands
|
||||
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 = originalString.includes('/') ? originalString.split('/')[0] : undefined;
|
||||
const scope = rawString.includes('/') ? rawString.split('/')[0] : undefined;
|
||||
|
||||
const packageNameWithoutScope = scope ? originalString.replace(`${scope}/`, '') : originalString;
|
||||
const packageNameWithoutScope = scope ? rawString.replace(`${scope}/`, '') : rawString;
|
||||
|
||||
if (!packageNameWithoutScope.startsWith(NODE_PACKAGE_PREFIX)) {
|
||||
throw new Error('Package name must start with n8n-nodes-');
|
||||
throw new Error(`Package name must start with ${NODE_PACKAGE_PREFIX}`);
|
||||
}
|
||||
|
||||
const version = packageNameWithoutScope.includes('@')
|
||||
? packageNameWithoutScope.split('@')[1]
|
||||
: undefined;
|
||||
|
||||
const packageName = version ? originalString.replace(`@${version}`, '') : originalString;
|
||||
const packageName = version ? rawString.replace(`@${version}`, '') : rawString;
|
||||
|
||||
return {
|
||||
packageName,
|
||||
scope,
|
||||
version,
|
||||
originalString,
|
||||
rawString,
|
||||
};
|
||||
};
|
||||
|
||||
export const sanitizeNpmPackageName = parseNpmPackageName;
|
||||
|
||||
export const executeCommand = async (
|
||||
command: string,
|
||||
options?: {
|
||||
doNotHandleError?: boolean;
|
||||
},
|
||||
options?: { doNotHandleError?: boolean },
|
||||
): Promise<string> => {
|
||||
const downloadFolder = UserSettings.getUserN8nFolderDowloadedNodesPath();
|
||||
// Make sure the node-download folder exists
|
||||
|
||||
try {
|
||||
await fsAccess(downloadFolder);
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (error) {
|
||||
} catch (_) {
|
||||
await fsMkdir(downloadFolder);
|
||||
}
|
||||
|
||||
const execOptions = {
|
||||
cwd: downloadFolder,
|
||||
env: {
|
||||
@@ -76,57 +94,48 @@ export const executeCommand = async (
|
||||
|
||||
try {
|
||||
const commandResult = await execAsync(command, execOptions);
|
||||
|
||||
return commandResult.stdout;
|
||||
} catch (error) {
|
||||
if (options?.doNotHandleError) {
|
||||
throw error;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const errorMessage = error.message as string;
|
||||
if (options?.doNotHandleError) throw error;
|
||||
|
||||
if (
|
||||
errorMessage.includes(NPM_COMMAND_TOKENS.NPM_PACKAGE_NOT_FOUND_ERROR) ||
|
||||
errorMessage.includes(NPM_COMMAND_TOKENS.NPM_NO_VERSION_AVAILABLE)
|
||||
) {
|
||||
throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND);
|
||||
}
|
||||
if (errorMessage.includes(NPM_COMMAND_TOKENS.NPM_PACKAGE_VERSION_NOT_FOUND_ERROR)) {
|
||||
throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_VERSION_NOT_FOUND);
|
||||
}
|
||||
if (
|
||||
errorMessage.includes(NPM_COMMAND_TOKENS.NPM_DISK_NO_SPACE) ||
|
||||
errorMessage.includes(NPM_COMMAND_TOKENS.NPM_DISK_INSUFFICIENT_SPACE)
|
||||
) {
|
||||
throw new Error(RESPONSE_ERROR_MESSAGES.DISK_IS_FULL);
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
LoggerProxy.warn('npm command failed; see message', { errorMessage });
|
||||
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,
|
||||
};
|
||||
|
||||
throw new Error('Package could not be installed - check logs for details');
|
||||
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(
|
||||
installedPackages: InstalledPackages[],
|
||||
availableUpdates?: NpmUpdatesAvailable,
|
||||
packages: InstalledPackages[],
|
||||
updates?: CommunityPackages.AvailableUpdates,
|
||||
): PublicInstalledPackage[] {
|
||||
if (!availableUpdates) {
|
||||
return installedPackages;
|
||||
}
|
||||
const hydratedPackageList = [] as PublicInstalledPackage[];
|
||||
if (!updates) return packages;
|
||||
|
||||
for (let i = 0; i < installedPackages.length; i++) {
|
||||
const installedPackage = installedPackages[i];
|
||||
const publicPackage = { ...installedPackage } as PublicInstalledPackage;
|
||||
return packages.reduce<PublicInstalledPackage[]>((acc, cur) => {
|
||||
const publicPackage: PublicInstalledPackage = { ...cur };
|
||||
|
||||
if (availableUpdates[installedPackage.packageName]) {
|
||||
publicPackage.updateAvailable = availableUpdates[installedPackage.packageName].latest;
|
||||
}
|
||||
hydratedPackageList.push(publicPackage);
|
||||
}
|
||||
const update = updates[cur.packageName];
|
||||
|
||||
return hydratedPackageList;
|
||||
if (update) publicPackage.updateAvailable = update.latest;
|
||||
|
||||
acc.push(publicPackage);
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function matchMissingPackages(
|
||||
@@ -138,7 +147,7 @@ export function matchMissingPackages(
|
||||
const missingPackagesList = missingPackageNames.map((missingPackageName: string) => {
|
||||
// Strip away versions but maintain scope and package name
|
||||
try {
|
||||
const parsedPackageData = parsePackageName(missingPackageName);
|
||||
const parsedPackageData = parseNpmPackageName(missingPackageName);
|
||||
return parsedPackageData.packageName;
|
||||
|
||||
// eslint-disable-next-line no-empty
|
||||
@@ -147,6 +156,7 @@ export function matchMissingPackages(
|
||||
});
|
||||
|
||||
const hydratedPackageList = [] as PublicInstalledPackage[];
|
||||
|
||||
installedPackages.forEach((installedPackage) => {
|
||||
const hydratedInstalledPackage = { ...installedPackage };
|
||||
if (missingPackagesList.includes(hydratedInstalledPackage.packageName)) {
|
||||
@@ -158,47 +168,38 @@ export function matchMissingPackages(
|
||||
return hydratedPackageList;
|
||||
}
|
||||
|
||||
export async function checkPackageStatus(packageName: string): Promise<NpmPackageStatusCheck> {
|
||||
// You can change this URL for testing - the default testing url below
|
||||
// is a postman mock service
|
||||
const n8nBackendServiceUrl = 'https://api.n8n.io/api/package';
|
||||
export async function checkNpmPackageStatus(
|
||||
packageName: string,
|
||||
): Promise<CommunityPackages.PackageStatusCheck> {
|
||||
const N8N_BACKEND_SERVICE_URL = 'https://api.n8n.io/api/package';
|
||||
|
||||
try {
|
||||
const output = await axios.post(
|
||||
n8nBackendServiceUrl,
|
||||
const response = await axios.post<CommunityPackages.PackageStatusCheck>(
|
||||
N8N_BACKEND_SERVICE_URL,
|
||||
{ name: packageName },
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
{ method: 'POST' },
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (output.data.status !== NPM_PACKAGE_STATUS_GOOD) {
|
||||
return output.data as NpmPackageStatusCheck;
|
||||
}
|
||||
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 hasPackageLoadedSuccessfully(packageName: string): boolean {
|
||||
try {
|
||||
const failedPackages = (config.get('nodes.packagesMissing') as string).split(' ');
|
||||
export function hasPackageLoaded(packageName: string): boolean {
|
||||
const missingPackages = config.get('nodes.packagesMissing') as string | undefined;
|
||||
|
||||
const packageFailedToLoad = failedPackages.find(
|
||||
if (!missingPackages) return true;
|
||||
|
||||
return !missingPackages
|
||||
.split(' ')
|
||||
.some(
|
||||
(packageNameAndVersion) =>
|
||||
packageNameAndVersion.startsWith(packageName) &&
|
||||
packageNameAndVersion.replace(packageName, '').startsWith('@'),
|
||||
);
|
||||
if (packageFailedToLoad) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (_error) {
|
||||
// If key doesn't exist it means all packages loaded fine
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function removePackageFromMissingList(packageName: string): void {
|
||||
@@ -216,3 +217,17 @@ export function removePackageFromMissingList(packageName: string): void {
|
||||
// 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user