From 2bde2e66cd4c83b582db1435327cbf66e5ba9576 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:07:39 +0300 Subject: [PATCH] fix: Fallback to cli command if http request failed in npm-utils for community packages (#19413) --- .../__tests__/npm-utils.test.ts | 445 ++++++++++++++---- .../community-packages.service.ts | 4 +- .../modules/community-packages/npm-utils.ts | 112 +++-- 3 files changed, 449 insertions(+), 112 deletions(-) diff --git a/packages/cli/src/modules/community-packages/__tests__/npm-utils.test.ts b/packages/cli/src/modules/community-packages/__tests__/npm-utils.test.ts index 268005fde09..bfc52d20dbd 100644 --- a/packages/cli/src/modules/community-packages/__tests__/npm-utils.test.ts +++ b/packages/cli/src/modules/community-packages/__tests__/npm-utils.test.ts @@ -1,7 +1,27 @@ import { UnexpectedError } from 'n8n-workflow'; import nock from 'nock'; -import { verifyIntegrity, isVersionExists } from '../npm-utils'; +const mockAsyncExec = jest.fn(); + +jest.mock('node:child_process', () => ({ + ...jest.requireActual('node:child_process'), + execFile: jest.fn(), +})); + +jest.mock('node:util', () => { + const actual = jest.requireActual('node:util'); + return { + ...actual, + promisify: jest.fn((fn) => { + if (fn === require('node:child_process').execFile) { + return mockAsyncExec; + } + return actual.promisify(fn); + }), + }; +}); + +import { verifyIntegrity, checkIfVersionExistsOrThrow } from '../npm-utils'; describe('verifyIntegrity', () => { const registryUrl = 'https://registry.npmjs.org'; @@ -9,8 +29,14 @@ describe('verifyIntegrity', () => { const version = '1.0.0'; const integrity = 'sha512-hash=='; + beforeEach(() => { + jest.clearAllMocks(); + mockAsyncExec.mockReset(); + }); + afterEach(() => { nock.cleanAll(); + jest.clearAllMocks(); }); it('should verify integrity successfully', async () => { @@ -39,57 +65,36 @@ describe('verifyIntegrity', () => { ); }); - it('should throw error if metadata request fails', async () => { + it('should throw error if metadata request fails and CLI fallback also fails', async () => { nock(registryUrl) .get(`/${encodeURIComponent(packageName)}/${version}`) .reply(500); - await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow(); + mockAsyncExec.mockRejectedValue(new Error('CLI command failed')); + + await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow( + UnexpectedError, + ); }); - it('should throw UnexpectedError and preserve original error as cause', async () => { - const integrity = 'sha512-somerandomhash=='; - + it('should throw UnexpectedError and preserve original error as cause when CLI fallback fails', async () => { nock(registryUrl) .get(`/${encodeURIComponent(packageName)}/${version}`) .replyWithError('Network failure'); - try { - await verifyIntegrity(packageName, version, registryUrl, integrity); - throw new Error('Expected error was not thrown'); - } catch (error: any) { - expect(error).toBeInstanceOf(UnexpectedError); - expect(error.message).toBe('Checksum verification failed'); - expect(error.cause).toBeDefined(); - expect(error.cause.message).toContain('Network failure'); - } + mockAsyncExec.mockRejectedValue(new Error('CLI command failed')); + + await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow( + new UnexpectedError('Checksum verification failed'), + ); }); - it('should return generic message for DNS getaddrinfo errors', async () => { - const integrity = 'sha512-somerandomhash=='; - + it('should return generic message for DNS getaddrinfo errors when CLI fallback fails', async () => { nock(registryUrl) .get(`/${encodeURIComponent(packageName)}/${version}`) .replyWithError('getaddrinfo ENOTFOUND internal.registry.local'); - try { - await verifyIntegrity(packageName, version, registryUrl, integrity); - throw new Error('Expected error was not thrown'); - } catch (error: any) { - expect(error).toBeInstanceOf(UnexpectedError); - expect(error.message).toBe( - 'Checksum verification failed. Please check your network connection and try again.', - ); - expect(error.cause).toBeUndefined(); - } - }); - - it('should return generic message for DNS ENOTFOUND errors', async () => { - const integrity = 'sha512-somerandomhash=='; - - nock(registryUrl) - .get(`/${encodeURIComponent(packageName)}/${version}`) - .replyWithError('ENOTFOUND some.internal.registry'); + mockAsyncExec.mockRejectedValue(new Error('getaddrinfo ENOTFOUND registry.npmjs.org')); await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow( new UnexpectedError( @@ -97,15 +102,153 @@ describe('verifyIntegrity', () => { ), ); }); + + it('should return generic message for DNS ENOTFOUND errors when CLI fallback fails', async () => { + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .replyWithError('ENOTFOUND some.internal.registry'); + + mockAsyncExec.mockRejectedValue(new Error('ENOTFOUND registry.npmjs.org')); + + await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow( + new UnexpectedError( + 'Checksum verification failed. Please check your network connection and try again.', + ), + ); + }); + + describe('CLI fallback functionality', () => { + it('should fallback to npm CLI when HTTP request fails and succeed', async () => { + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .replyWithError('Network failure'); + + mockAsyncExec.mockResolvedValue({ + stdout: JSON.stringify(integrity), + stderr: '', + }); + + await expect( + verifyIntegrity(packageName, version, registryUrl, integrity), + ).resolves.not.toThrow(); + + expect(mockAsyncExec).toHaveBeenCalledTimes(1); + expect(mockAsyncExec).toHaveBeenCalledWith('npm', [ + 'view', + `${packageName}@${version}`, + 'dist.integrity', + `--registry=${registryUrl}`, + '--json', + ]); + }); + + it('should fallback to npm CLI and throw error when integrity does not match', async () => { + const wrongIntegrity = 'sha512-wronghash=='; + + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .replyWithError('Network failure'); + + mockAsyncExec.mockResolvedValue({ + stdout: JSON.stringify(wrongIntegrity), + stderr: '', + }); + + await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow( + new UnexpectedError('Checksum verification failed'), + ); + + expect(mockAsyncExec).toHaveBeenCalledTimes(1); + }); + + it('should handle special characters in package name and version safely', async () => { + const specialPackageName = 'test-package; rm -rf /'; + const specialVersion = '1.0.0 && echo "hacked"'; + + nock(registryUrl) + .get(`/${encodeURIComponent(specialPackageName)}/${specialVersion}`) + .replyWithError('Network failure'); + + mockAsyncExec.mockResolvedValue({ + stdout: JSON.stringify(integrity), + stderr: '', + }); + + await verifyIntegrity(specialPackageName, specialVersion, registryUrl, integrity); + + expect(mockAsyncExec).toHaveBeenCalledTimes(1); + expect(mockAsyncExec).toHaveBeenCalledWith('npm', [ + 'view', + `${specialPackageName}@${specialVersion}`, + 'dist.integrity', + `--registry=${registryUrl}`, + '--json', + ]); + }); + + it('should handle DNS errors in CLI fallback', async () => { + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .replyWithError('Network failure'); + + mockAsyncExec.mockRejectedValue(new Error('getaddrinfo ENOTFOUND registry.npmjs.org')); + + await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow( + new UnexpectedError( + 'Checksum verification failed. Please check your network connection and try again.', + ), + ); + + expect(mockAsyncExec).toHaveBeenCalledTimes(1); + }); + + it('should handle npm errors in CLI fallback', async () => { + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .replyWithError('Network failure'); + + mockAsyncExec.mockRejectedValue( + new Error('npm ERR! 404 Not Found - GET https://registry.npmjs.org/nonexistent-package'), + ); + + await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow( + new UnexpectedError( + 'Checksum verification failed. Please check your network connection and try again.', + ), + ); + + expect(mockAsyncExec).toHaveBeenCalledTimes(1); + }); + + it('should handle generic CLI errors', async () => { + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .replyWithError('Network failure'); + + mockAsyncExec.mockRejectedValue(new Error('Some other error')); + + await expect(verifyIntegrity(packageName, version, registryUrl, integrity)).rejects.toThrow( + new UnexpectedError('Checksum verification failed'), + ); + + expect(mockAsyncExec).toHaveBeenCalledTimes(1); + }); + }); }); -describe('isVersionExists', () => { +describe('checkIfVersionExistsOrThrow', () => { const registryUrl = 'https://registry.npmjs.org'; const packageName = 'test-package'; const version = '1.0.0'; + beforeEach(() => { + jest.clearAllMocks(); + mockAsyncExec.mockReset(); + }); + afterEach(() => { nock.cleanAll(); + jest.clearAllMocks(); }); it('should return true when package version exists', async () => { @@ -116,92 +259,232 @@ describe('isVersionExists', () => { version, }); - const result = await isVersionExists(packageName, version, registryUrl); + const result = await checkIfVersionExistsOrThrow(packageName, version, registryUrl); expect(result).toBe(true); }); - it('should throw UnexpectedError when package version does not exist (404)', async () => { + it('should throw UnexpectedError when package version does not exist (404) and CLI fallback also fails', async () => { nock(registryUrl) .get(`/${encodeURIComponent(packageName)}/${version}`) .reply(404); - await expect(isVersionExists(packageName, version, registryUrl)).rejects.toThrow( - UnexpectedError, + mockAsyncExec.mockRejectedValue(new Error('E404 Not Found')); + + await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow( + new UnexpectedError('Package version does not exist'), ); }); - it('should throw UnexpectedError with proper message on 404', async () => { + it('should throw UnexpectedError with proper message on 404 when CLI fallback fails', async () => { nock(registryUrl) .get(`/${encodeURIComponent(packageName)}/${version}`) .reply(404); - try { - await isVersionExists(packageName, version, registryUrl); - throw new Error('Expected error was not thrown'); - } catch (error: any) { - expect(error).toBeInstanceOf(UnexpectedError); - expect(error.message).toBe('Package version does not exist'); - expect(error.cause).toBeDefined(); - } + mockAsyncExec.mockRejectedValue(new Error('Some error')); + + await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow( + new UnexpectedError('Failed to check package version existence'), + ); }); - it('should throw UnexpectedError for network failures', async () => { + it('should throw UnexpectedError for network failures when CLI fallback fails', async () => { nock(registryUrl) .get(`/${encodeURIComponent(packageName)}/${version}`) .replyWithError('Network failure'); - try { - await isVersionExists(packageName, version, registryUrl); - throw new Error('Expected error was not thrown'); - } catch (error: any) { - expect(error).toBeInstanceOf(UnexpectedError); - expect(error.message).toBe('Failed to check package version existence'); - expect(error.cause).toBeDefined(); - expect(error.cause.message).toContain('Network failure'); - } + mockAsyncExec.mockRejectedValue(new Error('CLI network failure')); + + await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow( + new UnexpectedError('Failed to check package version existence'), + ); }); - it('should throw UnexpectedError for server errors (500)', async () => { + it('should throw UnexpectedError for server errors (500) when CLI fallback fails', async () => { nock(registryUrl) .get(`/${encodeURIComponent(packageName)}/${version}`) .reply(500); - await expect(isVersionExists(packageName, version, registryUrl)).rejects.toThrow( + mockAsyncExec.mockRejectedValue(new Error('CLI error')); + + await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow( UnexpectedError, ); }); - it('should return generic message for DNS getaddrinfo errors', async () => { + it('should return generic message for DNS getaddrinfo errors when CLI fallback fails', async () => { nock(registryUrl) .get(`/${encodeURIComponent(packageName)}/${version}`) .replyWithError('getaddrinfo ENOTFOUND internal.registry.local'); - try { - await isVersionExists(packageName, version, registryUrl); - throw new Error('Expected error was not thrown'); - } catch (error: any) { - expect(error).toBeInstanceOf(UnexpectedError); - expect(error.message).toBe( + mockAsyncExec.mockRejectedValue(new Error('getaddrinfo ENOTFOUND registry.npmjs.org')); + + await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow( + new UnexpectedError( 'The community nodes service is temporarily unreachable. Please try again later.', - ); - expect(error.cause).toBeUndefined(); - } + ), + ); }); - it('should return generic message for DNS ENOTFOUND errors', async () => { + it('should return generic message for DNS ENOTFOUND errors when CLI fallback fails', async () => { nock(registryUrl) .get(`/${encodeURIComponent(packageName)}/${version}`) .replyWithError('ENOTFOUND some.internal.registry'); - try { - await isVersionExists(packageName, version, registryUrl); - throw new Error('Expected error was not thrown'); - } catch (error: any) { - expect(error).toBeInstanceOf(UnexpectedError); - expect(error.message).toBe( + mockAsyncExec.mockRejectedValue(new Error('ENOTFOUND registry.npmjs.org')); + + await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow( + new UnexpectedError( 'The community nodes service is temporarily unreachable. Please try again later.', + ), + ); + }); + + describe('CLI fallback functionality', () => { + it('should fallback to npm CLI when HTTP request fails and return true', async () => { + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .replyWithError('Network failure'); + + mockAsyncExec.mockResolvedValue({ + stdout: JSON.stringify(version), + stderr: '', + }); + + const result = await checkIfVersionExistsOrThrow(packageName, version, registryUrl); + expect(result).toBe(true); + expect(mockAsyncExec).toHaveBeenCalledTimes(1); + expect(mockAsyncExec).toHaveBeenCalledWith('npm', [ + 'view', + `${packageName}@${version}`, + 'version', + `--registry=${registryUrl}`, + '--json', + ]); + }); + + it('should fallback to npm CLI and throw error when version does not match', async () => { + const differentVersion = '2.0.0'; + + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .replyWithError('Network failure'); + + mockAsyncExec.mockResolvedValue({ + stdout: JSON.stringify(differentVersion), + stderr: '', + }); + + await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow( + new UnexpectedError('Failed to check package version existence'), ); - expect(error.cause).toBeUndefined(); - } + expect(mockAsyncExec).toHaveBeenCalledTimes(1); + }); + + it('should handle special characters in package name and version safely for checkIfVersionExistsOrThrow', async () => { + const specialPackageName = 'test-package; rm -rf /'; + const specialVersion = '1.0.0 && echo "hacked"'; + + nock(registryUrl) + .get(`/${encodeURIComponent(specialPackageName)}/${specialVersion}`) + .replyWithError('Network failure'); + + mockAsyncExec.mockResolvedValue({ + stdout: JSON.stringify(specialVersion), + stderr: '', + }); + + const result = await checkIfVersionExistsOrThrow( + specialPackageName, + specialVersion, + registryUrl, + ); + expect(result).toBe(true); + expect(mockAsyncExec).toHaveBeenCalledTimes(1); + expect(mockAsyncExec).toHaveBeenCalledWith('npm', [ + 'view', + `${specialPackageName}@${specialVersion}`, + 'version', + `--registry=${registryUrl}`, + '--json', + ]); + }); + + it('should handle 404 errors in CLI fallback', async () => { + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .replyWithError('Network failure'); + + mockAsyncExec.mockRejectedValue( + new Error('E404 Not Found - GET https://registry.npmjs.org/nonexistent-package'), + ); + + await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow( + new UnexpectedError('Package version does not exist'), + ); + + expect(mockAsyncExec).toHaveBeenCalledTimes(1); + }); + + it('should handle DNS errors in CLI fallback for checkIfVersionExistsOrThrow', async () => { + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .replyWithError('Network failure'); + + mockAsyncExec.mockRejectedValue(new Error('getaddrinfo ENOTFOUND registry.npmjs.org')); + + await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow( + new UnexpectedError( + 'The community nodes service is temporarily unreachable. Please try again later.', + ), + ); + + expect(mockAsyncExec).toHaveBeenCalledTimes(1); + }); + + it('should handle npm errors in CLI fallback for checkIfVersionExistsOrThrow', async () => { + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .replyWithError('Network failure'); + + mockAsyncExec.mockRejectedValue(new Error('npm ERR! 500 Internal Server Error')); + + await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow( + new UnexpectedError( + 'The community nodes service is temporarily unreachable. Please try again later.', + ), + ); + + expect(mockAsyncExec).toHaveBeenCalledTimes(1); + }); + + it('should handle generic CLI errors for checkIfVersionExistsOrThrow', async () => { + nock(registryUrl) + .get(`/${encodeURIComponent(packageName)}/${version}`) + .replyWithError('Network failure'); + + mockAsyncExec.mockRejectedValue(new Error('Some other error')); + + await expect(checkIfVersionExistsOrThrow(packageName, version, registryUrl)).rejects.toThrow( + new UnexpectedError('Failed to check package version existence'), + ); + + expect(mockAsyncExec).toHaveBeenCalledTimes(1); + }); + }); + + describe('Helper functions', () => { + it('should sanitize registry URL by removing trailing slashes', async () => { + const registryWithSlashes = 'https://registry.npmjs.org///'; + + nock('https://registry.npmjs.org') + .get(`/${encodeURIComponent(packageName)}/${version}`) + .reply(200, { + name: packageName, + version, + }); + + const result = await checkIfVersionExistsOrThrow(packageName, version, registryWithSlashes); + expect(result).toBe(true); + }); }); }); diff --git a/packages/cli/src/modules/community-packages/community-packages.service.ts b/packages/cli/src/modules/community-packages/community-packages.service.ts index f5bc1fd0d7f..2339cb9cb72 100644 --- a/packages/cli/src/modules/community-packages/community-packages.service.ts +++ b/packages/cli/src/modules/community-packages/community-packages.service.ts @@ -28,7 +28,7 @@ import { CommunityPackagesConfig } from './community-packages.config'; import type { CommunityPackages } from './community-packages.types'; import { InstalledPackages } from './installed-packages.entity'; import { InstalledPackagesRepository } from './installed-packages.repository'; -import { isVersionExists, verifyIntegrity } from './npm-utils'; +import { checkIfVersionExistsOrThrow, verifyIntegrity } from './npm-utils'; const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; const NPM_COMMON_ARGS = ['--audit=false', '--fund=false']; @@ -398,7 +398,7 @@ export class CommunityPackagesService { await verifyIntegrity(packageName, packageVersion, this.getNpmRegistry(), options.checksum); } - await isVersionExists(packageName, packageVersion, this.getNpmRegistry()); + await checkIfVersionExistsOrThrow(packageName, packageVersion, this.getNpmRegistry()); try { await this.downloadPackage(packageName, packageVersion); diff --git a/packages/cli/src/modules/community-packages/npm-utils.ts b/packages/cli/src/modules/community-packages/npm-utils.ts index a818e01a8d1..7b08137464f 100644 --- a/packages/cli/src/modules/community-packages/npm-utils.ts +++ b/packages/cli/src/modules/community-packages/npm-utils.ts @@ -1,12 +1,30 @@ import axios from 'axios'; -import { UnexpectedError } from 'n8n-workflow'; +import { jsonParse, UnexpectedError } from 'n8n-workflow'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const asyncExecFile = promisify(execFile); + +const REQUEST_TIMEOUT = 30000; function isDnsError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return message.includes('getaddrinfo') || message.includes('ENOTFOUND'); } -const REQUEST_TIMEOUT = 30000; +function isNpmError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('npm ERR!') || + message.includes('E404') || + message.includes('404 Not Found') || + message.includes('ENOTFOUND') + ); +} + +function sanitizeRegistryUrl(registryUrl: string): string { + return registryUrl.replace(/\/+$/, ''); +} export async function verifyIntegrity( packageName: string, @@ -14,50 +32,86 @@ export async function verifyIntegrity( registryUrl: string, expectedIntegrity: string, ) { - const timeoutOption = { timeout: REQUEST_TIMEOUT }; + const url = `${sanitizeRegistryUrl(registryUrl)}/${encodeURIComponent(packageName)}`; try { - const url = `${registryUrl.replace(/\/+$/, '')}/${encodeURIComponent(packageName)}`; - const metadata = await axios.get<{ dist: { integrity: string } }>( - `${url}/${version}`, - timeoutOption, - ); + const metadata = await axios.get<{ dist: { integrity?: string } }>(`${url}/${version}`, { + timeout: REQUEST_TIMEOUT, + }); - if (metadata?.data?.dist?.integrity !== expectedIntegrity) { + const integrity = metadata?.data?.dist?.integrity; + if (integrity !== expectedIntegrity) { throw new UnexpectedError('Checksum verification failed. Package integrity does not match.'); } + return; } catch (error) { - if (isDnsError(error)) { - throw new UnexpectedError( - 'Checksum verification failed. Please check your network connection and try again.', - ); + try { + const { stdout } = await asyncExecFile('npm', [ + 'view', + `${packageName}@${version}`, + 'dist.integrity', + `--registry=${sanitizeRegistryUrl(registryUrl)}`, + '--json', + ]); + + const integrity = jsonParse(stdout); + if (integrity !== expectedIntegrity) { + throw new UnexpectedError( + 'Checksum verification failed. Package integrity does not match.', + ); + } + return; + } catch (cliError) { + if (isDnsError(cliError) || isNpmError(cliError)) { + throw new UnexpectedError( + 'Checksum verification failed. Please check your network connection and try again.', + ); + } + throw new UnexpectedError('Checksum verification failed'); } - throw new UnexpectedError('Checksum verification failed', { cause: error }); } } -export async function isVersionExists( +export async function checkIfVersionExistsOrThrow( packageName: string, version: string, registryUrl: string, -): Promise { - const timeoutOption = { timeout: REQUEST_TIMEOUT }; +): Promise { + const url = `${sanitizeRegistryUrl(registryUrl)}/${encodeURIComponent(packageName)}`; try { - const url = `${registryUrl.replace(/\/+$/, '')}/${encodeURIComponent(packageName)}`; - await axios.get(`${url}/${version}`, timeoutOption); + await axios.get(`${url}/${version}`, { timeout: REQUEST_TIMEOUT }); return true; } catch (error) { - if (axios.isAxiosError(error) && error.response?.status === 404) { - throw new UnexpectedError('Package version does not exist', { - cause: error, - }); + try { + const { stdout } = await asyncExecFile('npm', [ + 'view', + `${packageName}@${version}`, + 'version', + `--registry=${sanitizeRegistryUrl(registryUrl)}`, + '--json', + ]); + + const versionInfo = jsonParse(stdout); + if (versionInfo === version) { + return true; + } + + throw new UnexpectedError('Failed to check package version existence'); + } catch (cliError) { + const message = cliError instanceof Error ? cliError.message : String(cliError); + + if (message.includes('E404') || message.includes('404 Not Found')) { + throw new UnexpectedError('Package version does not exist'); + } + + if (isDnsError(cliError) || isNpmError(cliError)) { + throw new UnexpectedError( + 'The community nodes service is temporarily unreachable. Please try again later.', + ); + } + + throw new UnexpectedError('Failed to check package version existence'); } - if (isDnsError(error)) { - throw new UnexpectedError( - 'The community nodes service is temporarily unreachable. Please try again later.', - ); - } - throw new UnexpectedError('Failed to check package version existence', { cause: error }); } }