diff --git a/packages/@n8n/constants/src/community-nodes.ts b/packages/@n8n/constants/src/community-nodes.ts index fc2e0d882ef..a825667b07b 100644 --- a/packages/@n8n/constants/src/community-nodes.ts +++ b/packages/@n8n/constants/src/community-nodes.ts @@ -1,16 +1,7 @@ export const NPM_COMMUNITY_NODE_SEARCH_API_URL = 'https://api.npms.io/v2/'; /** - * Node-authoring API level supported by the runtime that bundles this copy. - * - * Compatibility rule for community packages: - * `package.n8n.n8nNodesApiVersion <= N8N_NODES_API_VERSION`. - * - * The level mirrors the n8n major for the v3 transition: `1` on master - * (which ships as 2.x during the v3 window) and `3` on the `3.x` branch, - * where it also serves as the v3 feature flag for node-authoring APIs. - * - * Lives here, not in `n8n-workflow`, so tooling that cannot depend on the - * runtime packages (e.g. `@n8n/node-cli`) can read the same value. + * Node-authoring API level this runtime supports, and so the highest + * `n8nNodesApiVersion` a node may declare to be installed. */ -export const N8N_NODES_API_VERSION = 1; +export const N8N_NODES_API_VERSION = '1.0'; diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/n8n-object-validation.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/n8n-object-validation.md index 19c8c72e28b..0799edc8f00 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/n8n-object-validation.md +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/n8n-object-validation.md @@ -17,8 +17,9 @@ register at install time and the failure is opaque to the user. This rule enforces the structural contract: - `package.json` must contain an `n8n` object. -- `n8n.n8nNodesApiVersion` must be present and a positive integer. It must live - inside `n8n`, not at the root. +- `n8n.n8nNodesApiVersion` must be present and a positive integer, or a + `""` or `"."` string such as `"3"` or `"3.1"`. It must + live inside `n8n`, not at the root. - `n8n.nodes` must be a non-empty array of strings, each starting with `dist/`. - `n8n.credentials`, if present, must be an array of strings, each starting with `dist/`. @@ -53,7 +54,7 @@ the templates published by `@n8n/node-cli` and `@n8n/create-node`. { "name": "n8n-nodes-example", "n8n": { - "n8nNodesApiVersion": "1", + "n8nNodesApiVersion": "v1", "nodes": ["nodes/Foo/Foo.node.js"] } } diff --git a/packages/@n8n/eslint-plugin-community-nodes/package.json b/packages/@n8n/eslint-plugin-community-nodes/package.json index caf705c0d28..478afc45833 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/package.json +++ b/packages/@n8n/eslint-plugin-community-nodes/package.json @@ -28,6 +28,7 @@ "watch": "tsc --watch --project tsconfig.build.json" }, "dependencies": { + "@n8n/utils": "workspace:*", "@typescript-eslint/typescript-estree": "catalog:", "@typescript-eslint/utils": "catalog:", "fastest-levenshtein": "catalog:" diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/n8n-object-validation.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/n8n-object-validation.test.ts index aaf0fbb5e5e..a08412f1f1e 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/n8n-object-validation.test.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/n8n-object-validation.test.ts @@ -26,6 +26,21 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, { filename: 'package.json', code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": 2, "nodes": ["dist/x.js"] } }', }, + { + name: 'major-only string api version is allowed', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3", "nodes": ["dist/x.js"] } }', + }, + { + name: 'major.minor string api version is allowed', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1", "nodes": ["dist/x.js"] } }', + }, + { + name: 'two-digit minor string api version is allowed', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.10", "nodes": ["dist/x.js"] } }', + }, { name: 'non-package.json file is ignored', filename: 'some-config.json', @@ -77,10 +92,22 @@ ruleTester.run('n8n-object-validation', N8nObjectValidationRule, { errors: [{ messageId: 'missingNodesApiVersion' }], }, { - name: 'n8nNodesApiVersion is a string', + name: 'n8nNodesApiVersion is a non-numeric string', filename: 'package.json', - code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "1", "nodes": ["dist/x.js"] } }', - errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '1' } }], + code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "v3", "nodes": ["dist/x.js"] } }', + errors: [{ messageId: 'invalidNodesApiVersion', data: { value: 'v3' } }], + }, + { + name: 'n8nNodesApiVersion is a string with a patch part', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "3.1.0", "nodes": ["dist/x.js"] } }', + errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '3.1.0' } }], + }, + { + name: 'n8nNodesApiVersion is a zero-major string', + filename: 'package.json', + code: '{ "name": "n8n-nodes-example", "n8n": { "n8nNodesApiVersion": "0.1", "nodes": ["dist/x.js"] } }', + errors: [{ messageId: 'invalidNodesApiVersion', data: { value: '0.1' } }], }, { name: 'n8nNodesApiVersion is zero', diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/n8n-object-validation.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/n8n-object-validation.ts index ef3ac4a31f6..8ea961314c3 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/n8n-object-validation.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/n8n-object-validation.ts @@ -1,3 +1,4 @@ +import { parseNodesApiLevel } from '@n8n/utils/nodes-api-level'; import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; @@ -34,9 +35,9 @@ export const N8nObjectValidationRule = createRule<[], MessageIds>({ wrongLocationApiVersion: '"n8nNodesApiVersion" must be inside the "n8n" section, not at the root level of package.json.', missingNodesApiVersion: - 'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer).', + 'The "n8n" object must declare "n8nNodesApiVersion" (a positive integer, or a "" or "." string).', invalidNodesApiVersion: - '"n8n.n8nNodesApiVersion" must be a positive integer, got {{ value }}.', + '"n8n.n8nNodesApiVersion" must be a positive integer, or a "" or "." string, got {{ value }}.', missingN8nNodes: 'The "n8n" object must declare "nodes" as an array of "dist/" paths.', n8nNodesNotArray: '"n8n.nodes" must be an array of "dist/" paths.', emptyN8nNodes: '"n8n.nodes" must contain at least one path.', @@ -108,7 +109,7 @@ function validateApiVersion(context: Context, n8nObject: TSESTree.ObjectExpressi } const valueNode = apiVersionProp.value; - if (valueNode.type !== AST_NODE_TYPES.Literal || !isPositiveInteger(valueNode.value)) { + if (valueNode.type !== AST_NODE_TYPES.Literal || parseNodesApiLevel(valueNode.value) === null) { context.report({ node: apiVersionProp, messageId: 'invalidNodesApiVersion', @@ -194,7 +195,3 @@ function validatePathArray( } } } - -function isPositiveInteger(value: unknown): boolean { - return typeof value === 'number' && Number.isInteger(value) && value > 0; -} diff --git a/packages/@n8n/utils/package.json b/packages/@n8n/utils/package.json index 059b65304cb..921065975b3 100644 --- a/packages/@n8n/utils/package.json +++ b/packages/@n8n/utils/package.json @@ -41,13 +41,15 @@ }, "dependencies": { "@n8n/constants": "workspace:*", - "nanoid": "catalog:" + "nanoid": "catalog:", + "semver": "catalog:" }, "devDependencies": { "@n8n/oxlint-config": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", "@testing-library/jest-dom": "catalog:frontend", + "@types/semver": "catalog:", "fast-check": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", diff --git a/packages/@n8n/utils/src/nodes-api-level.test.ts b/packages/@n8n/utils/src/nodes-api-level.test.ts new file mode 100644 index 00000000000..296f866e8bf --- /dev/null +++ b/packages/@n8n/utils/src/nodes-api-level.test.ts @@ -0,0 +1,58 @@ +import { formatNodesApiLevel, parseNodesApiLevel } from './nodes-api-level'; + +describe('parseNodesApiLevel', () => { + it.each([ + ['3.1', { major: 3, minor: 1 }], + ['3.0', { major: 3, minor: 0 }], + ['3', { major: 3, minor: 0 }], + ['3.10', { major: 3, minor: 10 }], + ])('parses the string %p as %p', (value, expected) => { + expect(parseNodesApiLevel(value)).toMatchObject(expected); + }); + + it.each([ + [1, { major: 1, minor: 0 }], + [3, { major: 3, minor: 0 }], + ])('reads the integer %p as %p', (value, expected) => { + expect(parseNodesApiLevel(value)).toMatchObject(expected); + }); + + it.each([ + 0, + -1, + 2.5, + 3.1, + '0.0', + '0.1', + '01.1', + '03', + '3.01', + '3.', + '.1', + ' 3.2 ', + '3.1.2', + 'v3', + '', + null, + NaN, + Infinity, + true, + {}, + ])('rejects %p', (value) => { + expect(parseNodesApiLevel(value)).toBeNull(); + }); + + it.each([2 ** 53, '9007199254740992', '1.9007199254740992'])( + 'rejects the level %p above the safe integer range', + (value) => { + expect(parseNodesApiLevel(value)).toBeNull(); + }, + ); +}); + +describe('formatNodesApiLevel', () => { + it('writes major and minor', () => { + expect(formatNodesApiLevel(parseNodesApiLevel('3.0')!)).toBe('3.0'); + expect(formatNodesApiLevel(parseNodesApiLevel('3.10')!)).toBe('3.10'); + }); +}); diff --git a/packages/@n8n/utils/src/nodes-api-level.ts b/packages/@n8n/utils/src/nodes-api-level.ts new file mode 100644 index 00000000000..3253fab7e64 --- /dev/null +++ b/packages/@n8n/utils/src/nodes-api-level.ts @@ -0,0 +1,19 @@ +import { coerce, type SemVer } from 'semver'; + +// A float is rejected, since an unquoted `3.10` in package.json is read as +// the number `3.1`. The input must print back from its coerced version as +// `` or `.`. +export function parseNodesApiLevel(value: unknown): SemVer | null { + const text = typeof value === 'number' && Number.isInteger(value) ? String(value) : value; + if (typeof text !== 'string') return null; + + const version = coerce(text); + if (version === null || version.major < 1) return null; + if (text !== `${version.major}` && text !== `${version.major}.${version.minor}`) return null; + + return version; +} + +export function formatNodesApiLevel({ major, minor }: SemVer): string { + return `${major}.${minor}`; +} diff --git a/packages/cli/src/errors/response-errors/incompatible-nodes-api-version.error.ts b/packages/cli/src/errors/response-errors/incompatible-nodes-api-version.error.ts index d49fe274df1..12036535d13 100644 --- a/packages/cli/src/errors/response-errors/incompatible-nodes-api-version.error.ts +++ b/packages/cli/src/errors/response-errors/incompatible-nodes-api-version.error.ts @@ -9,9 +9,9 @@ export class IncompatibleNodesApiVersionError extends ResponseError { message: string, readonly meta: { /** API version the package requires, or `null` if the declared value is malformed. */ - requiredNodesApiVersion: number | null; + requiredNodesApiVersion: string | null; /** Node API version this runtime supports. */ - supportedNodesApiVersion: number; + supportedNodesApiVersion: string; }, cause?: unknown, ) { diff --git a/packages/cli/src/modules/community-packages/__tests__/community-packages.integration.test.ts b/packages/cli/src/modules/community-packages/__tests__/community-packages.integration.test.ts index 1260d797261..7e9bf9e190b 100644 --- a/packages/cli/src/modules/community-packages/__tests__/community-packages.integration.test.ts +++ b/packages/cli/src/modules/community-packages/__tests__/community-packages.integration.test.ts @@ -7,7 +7,7 @@ import { Logger } from '@n8n/backend-common'; import { mockInstance } from '@n8n/backend-test-utils'; import { Container } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; -import { N8N_NODES_API_VERSION } from 'n8n-workflow'; +import { N8N_NODES_API_VERSION, parseNodesApiLevel } from 'n8n-workflow'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'path'; @@ -305,9 +305,10 @@ describe('node API compatibility at startup', () => { }); test('boots with an incompatible package on disk and registers no loader for it', async () => { + const { major, minor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!; writePackage('n8n-nodes-future', { nodes: ['dist/nodes/Future.node.js'], - n8nNodesApiVersion: N8N_NODES_API_VERSION + 1, + n8nNodesApiVersion: `${major}.${minor + 1}`, }); writePackage('n8n-nodes-good'); diff --git a/packages/cli/src/modules/community-packages/__tests__/community-packages.lifecycle.service.test.ts b/packages/cli/src/modules/community-packages/__tests__/community-packages.lifecycle.service.test.ts index f58f717d51d..0d261088b60 100644 --- a/packages/cli/src/modules/community-packages/__tests__/community-packages.lifecycle.service.test.ts +++ b/packages/cli/src/modules/community-packages/__tests__/community-packages.lifecycle.service.test.ts @@ -310,7 +310,7 @@ describe('CommunityPackagesLifecycleService', () => { communityPackagesService.installPackage.mockRejectedValue( new IncompatibleNodesApiVersionError( 'This community node requires n8n node API version 3, but this instance supports up to 1.', - { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 }, + { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' }, ), ); @@ -318,7 +318,7 @@ describe('CommunityPackagesLifecycleService', () => { await expect(promise).rejects.toBeInstanceOf(IncompatibleNodesApiVersionError); await expect(promise).rejects.toMatchObject({ httpStatusCode: 400, - meta: { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 }, + meta: { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' }, message: 'This community node requires n8n node API version 3, but this instance supports up to 1.', }); @@ -666,7 +666,7 @@ describe('CommunityPackagesLifecycleService', () => { communityPackagesService.updatePackage.mockRejectedValue( new IncompatibleNodesApiVersionError( "This community node isn't compatible with your version of n8n. Update n8n to use it.", - { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 }, + { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' }, ), ); @@ -678,7 +678,7 @@ describe('CommunityPackagesLifecycleService', () => { await expect(promise).rejects.toBeInstanceOf(IncompatibleNodesApiVersionError); await expect(promise).rejects.toMatchObject({ httpStatusCode: 400, - meta: { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 }, + meta: { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' }, }); }); @@ -691,8 +691,8 @@ describe('CommunityPackagesLifecycleService', () => { communityPackagesService.findInstalledPackage.mockResolvedValue(mockPackage('1.0.0')); communityPackagesService.updatePackage.mockRejectedValue( new IncompatibleNodesApiVersionError('Not compatible', { - requiredNodesApiVersion: 3, - supportedNodesApiVersion: 1, + requiredNodesApiVersion: '3', + supportedNodesApiVersion: '1', }), ); diff --git a/packages/cli/src/modules/community-packages/__tests__/community-packages.rollback.test.ts b/packages/cli/src/modules/community-packages/__tests__/community-packages.rollback.test.ts index 0371a5dfd09..8d37af2890e 100644 --- a/packages/cli/src/modules/community-packages/__tests__/community-packages.rollback.test.ts +++ b/packages/cli/src/modules/community-packages/__tests__/community-packages.rollback.test.ts @@ -3,6 +3,7 @@ import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network'; import { mockInstance } from '@n8n/backend-test-utils'; import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core'; import { N8N_NODES_API_VERSION } from '@n8n/constants'; +import { parseNodesApiLevel } from 'n8n-workflow'; import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; @@ -34,6 +35,9 @@ vi.mock('../npm-utils', async () => ({ const PACKAGE_NAME = 'n8n-nodes-test'; const TARBALL_NAME = `${PACKAGE_NAME}-2.0.0.tgz`; +const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!; +const oneMinorAboveSupported = `${supportedMajor}.${supportedMinor + 1}`; + /** * Exercises the install rollback against a real filesystem rather than mocked `fs` * calls. The bug being guarded here is a directory that is deleted with no backup @@ -264,7 +268,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => { }; test('rejects an update and leaves directory, ledger, and database unchanged', async () => { - await expect(updateToIncompatible(N8N_NODES_API_VERSION + 1)).rejects.toThrow( + await expect(updateToIncompatible(oneMinorAboveSupported)).rejects.toThrow( "isn't compatible with your version of n8n", ); @@ -284,7 +288,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => { }); test('rejects an update with a malformed declared version', async () => { - await expect(updateToIncompatible('3')).rejects.toThrow('invalid n8n node API version'); + await expect(updateToIncompatible(3.1)).rejects.toThrow('invalid n8n node API version'); expect(existsSync(markerPath())).toBe(true); expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled(); @@ -314,7 +318,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => { downloadedPackageJson = { name: PACKAGE_NAME, version: '2.0.0', - n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 }, + n8n: { n8nNodesApiVersion: oneMinorAboveSupported }, }; await expect(communityPackagesService.installPackage(PACKAGE_NAME)).rejects.toThrow( @@ -334,7 +338,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => { downloadedPackageJson = { name: PACKAGE_NAME, version: '2.0.0', - n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 }, + n8n: { n8nNodesApiVersion: oneMinorAboveSupported }, }; // The follower resolves the version to install from the leader's database record. installedPackageRepository.findOne.mockResolvedValue( @@ -359,7 +363,7 @@ describe('CommunityPackagesService install rollback (real filesystem)', () => { packageName: PACKAGE_NAME, reason: expect.stringContaining("isn't compatible with your version of n8n"), // The operator log names both versions, unlike the user-facing message. - requiredNodesApiVersion: N8N_NODES_API_VERSION + 1, + requiredNodesApiVersion: oneMinorAboveSupported, supportedNodesApiVersion: N8N_NODES_API_VERSION, }), ); diff --git a/packages/cli/src/modules/community-packages/__tests__/community-packages.service.test.ts b/packages/cli/src/modules/community-packages/__tests__/community-packages.service.test.ts index 13a043b1e2f..1e9c447881f 100644 --- a/packages/cli/src/modules/community-packages/__tests__/community-packages.service.test.ts +++ b/packages/cli/src/modules/community-packages/__tests__/community-packages.service.test.ts @@ -3,7 +3,11 @@ import type { HttpRequestClient, OutboundHttp } from '@n8n/backend-network'; import { mockInstance, randomName } from '@n8n/backend-test-utils'; import { LICENSE_FEATURES } from '@n8n/constants'; import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core'; -import { N8N_NODES_API_VERSION, type PublicInstalledPackage } from 'n8n-workflow'; +import { + N8N_NODES_API_VERSION, + parseNodesApiLevel, + type PublicInstalledPackage, +} from 'n8n-workflow'; import { execFile } from 'node:child_process'; import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import path, { join } from 'node:path'; @@ -52,6 +56,9 @@ const execMock: typeof execFile = ((...args) => { vi.mocked(execFile).mockImplementation(execMock); +const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!; +const oneMinorAboveSupported = `${supportedMajor}.${supportedMinor + 1}`; + describe('CommunityPackagesService', () => { const license = mock(); const configDefaults = { @@ -675,7 +682,7 @@ describe('CommunityPackagesService', () => { test('should reject the update when the package requires a newer node API version', async () => { license.isCustomNpmRegistryEnabled.mockReturnValue(true); - await expect(updateToIncompatible(N8N_NODES_API_VERSION + 1)).rejects.toThrow( + await expect(updateToIncompatible(oneMinorAboveSupported)).rejects.toThrow( "This community node isn't compatible with your version of n8n. Update n8n to use it.", ); @@ -687,7 +694,7 @@ describe('CommunityPackagesService', () => { test('should reject the update when the declared node API version is malformed', async () => { license.isCustomNpmRegistryEnabled.mockReturnValue(true); - await expect(updateToIncompatible('3')).rejects.toThrow('invalid n8n node API version'); + await expect(updateToIncompatible(3.1)).rejects.toThrow('invalid n8n node API version'); expect(loadNodesAndCredentials.loadPackage).not.toHaveBeenCalled(); expect(installedPackageRepository.replaceInstalledPackageWithNodes).not.toHaveBeenCalled(); @@ -701,6 +708,16 @@ describe('CommunityPackagesService', () => { expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME); expect(installedPackageRepository.replaceInstalledPackageWithNodes).toHaveBeenCalled(); }); + + test('should update to a package that declares the supported level with an explicit minor', async () => { + license.isCustomNpmRegistryEnabled.mockReturnValue(true); + + await expect(updateToIncompatible(`${supportedMajor}.${supportedMinor}`)).resolves.toBe( + packageAfterUpdate, + ); + + expect(loadNodesAndCredentials.loadPackage).toHaveBeenCalledWith(PACKAGE_NAME); + }); }); test('should remove the package.json dependency when a fresh install fails', async () => { @@ -1418,7 +1435,7 @@ describe('CommunityPackagesService', () => { JSON.stringify({ name: 'package-1', version: '1.0.0', - n8n: { n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 }, + n8n: { n8nNodesApiVersion: oneMinorAboveSupported }, }), ); @@ -1438,7 +1455,7 @@ describe('CommunityPackagesService', () => { loadNodesAndCredentials.isKnownNode.mockReturnValue(false); config.reinstallMissing = true; vi.mocked(readFile).mockResolvedValue( - JSON.stringify({ name: 'package-1', version: '1.0.0', n8n: { n8nNodesApiVersion: '3' } }), + JSON.stringify({ name: 'package-1', version: '1.0.0', n8n: { n8nNodesApiVersion: 3.1 } }), ); await communityPackagesService.checkForMissingPackages(); diff --git a/packages/cli/src/modules/community-packages/community-node-types-utils.ts b/packages/cli/src/modules/community-packages/community-node-types-utils.ts index 2fd43093a73..9fae4acc532 100644 --- a/packages/cli/src/modules/community-packages/community-node-types-utils.ts +++ b/packages/cli/src/modules/community-packages/community-node-types-utils.ts @@ -60,7 +60,7 @@ export async function getCommunityNodeTypes( environment: 'staging' | 'production', qs: { filters?: StrapiFilters; fields?: string[] } = {}, maxAiNodeSdk: number, - maxN8nNodesApiVersion: number, + maxN8nNodesApiVersion: string, ): Promise { const url = getUrl(environment); const params = { @@ -78,7 +78,7 @@ export async function getCommunityNodeTypes( export async function getCommunityNodesMetadata( environment: 'staging' | 'production', maxAiNodeSdk: number, - maxN8nNodesApiVersion: number, + maxN8nNodesApiVersion: string, ): Promise { const url = getUrl(environment); const params = { diff --git a/packages/cli/src/modules/community-packages/community-packages.config.ts b/packages/cli/src/modules/community-packages/community-packages.config.ts index 8939db887e9..b1b73d853ce 100644 --- a/packages/cli/src/modules/community-packages/community-packages.config.ts +++ b/packages/cli/src/modules/community-packages/community-packages.config.ts @@ -40,7 +40,7 @@ export class CommunityPackagesConfig { readonly aiNodeSdkVersion: number = AI_NODE_SDK_VERSION; /** Highest community node API version this instance supports, sent to Strapi API */ - readonly nodesApiVersion: number = N8N_NODES_API_VERSION; + readonly nodesApiVersion: string = N8N_NODES_API_VERSION; @Env('N8N_DISABLED_MODULES') private disabledModules: CommaSeparatedStringArray = []; 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 56480e2fecf..87312318dbc 100644 --- a/packages/cli/src/modules/community-packages/community-packages.service.ts +++ b/packages/cli/src/modules/community-packages/community-packages.service.ts @@ -304,14 +304,17 @@ export class CommunityPackagesService { const check = checkNodesApiVersion(packageJson); if (check.compatible) return; - const isMalformed = check.reason === 'malformed'; + if (check.reason === 'malformed') { + throw new IncompatibleNodesApiVersionError( + `This community node declares an invalid n8n node API version (${JSON.stringify(check.declared)}). Install a version of the package with valid metadata or contact the package author.`, + { requiredNodesApiVersion: null, supportedNodesApiVersion: N8N_NODES_API_VERSION }, + ); + } throw new IncompatibleNodesApiVersionError( - isMalformed - ? `This community node declares an invalid n8n node API version (${JSON.stringify(check.declared)}). Install a version of the package with valid metadata or contact the package author.` - : "This community node isn't compatible with your version of n8n. Update n8n to use it.", + "This community node isn't compatible with your version of n8n. Update n8n to use it.", { - requiredNodesApiVersion: isMalformed ? null : Number(check.declared), + requiredNodesApiVersion: check.required, supportedNodesApiVersion: N8N_NODES_API_VERSION, }, ); @@ -370,7 +373,7 @@ export class CommunityPackagesService { const requirement = apiVersionCheck.reason === 'malformed' ? `an invalid n8nNodesApiVersion (${JSON.stringify(apiVersionCheck.declared)})` - : `node API version ${String(apiVersionCheck.declared)}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`; + : `node API version ${apiVersionCheck.required}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`; this.logger.warn( `Not reinstalling package "${installedPackage.packageName}": it requires ${requirement}. Upgrade n8n to use this package, or uninstall it in Settings > Community nodes.`, ); diff --git a/packages/cli/src/utils/strapi-utils.ts b/packages/cli/src/utils/strapi-utils.ts index faf82c8968a..2be376fda2a 100644 --- a/packages/cli/src/utils/strapi-utils.ts +++ b/packages/cli/src/utils/strapi-utils.ts @@ -48,7 +48,7 @@ type PaginationRequestParams = { pageSize: number; }; maxAiNodeSdk?: number; - maxN8nNodesApiVersion?: number; + maxN8nNodesApiVersion?: string; version?: number; }; diff --git a/packages/core/src/nodes-loader/__tests__/scan-directory-for-packages.fs.test.ts b/packages/core/src/nodes-loader/__tests__/scan-directory-for-packages.fs.test.ts index ac06132f1ad..d3e10e3fa39 100644 --- a/packages/core/src/nodes-loader/__tests__/scan-directory-for-packages.fs.test.ts +++ b/packages/core/src/nodes-loader/__tests__/scan-directory-for-packages.fs.test.ts @@ -1,5 +1,5 @@ import { Logger } from '@n8n/backend-common'; -import { N8N_NODES_API_VERSION } from 'n8n-workflow'; +import { N8N_NODES_API_VERSION, parseNodesApiLevel } from 'n8n-workflow'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -26,6 +26,9 @@ describe('scanDirectoryForPackages (real filesystem)', () => { rmSync(nodeModulesDir, { recursive: true, force: true }); }); + const { major, minor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!; + const unsupportedLevel = `${major}.${minor + 1}`; + const writePackage = (name: string, n8n?: object) => { const dir = path.join(nodeModulesDir, name); mkdirSync(dir); @@ -93,7 +96,7 @@ describe('scanDirectoryForPackages (real filesystem)', () => { it('registers no loader for a package requiring an unsupported node API version', async () => { writePackage('n8n-nodes-future', { nodes: ['dist/nodes/Future.node.js'], - n8nNodesApiVersion: N8N_NODES_API_VERSION + 1, + n8nNodesApiVersion: unsupportedLevel, }); writePackage('n8n-nodes-good'); @@ -102,7 +105,7 @@ describe('scanDirectoryForPackages (real filesystem)', () => { expect(loaders.map((loader) => loader.packageName)).toEqual(['n8n-nodes-good']); expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('n8n-nodes-future')); const [message] = vi.mocked(logger.warn).mock.calls[0]; - expect(message).toContain(`node API version ${N8N_NODES_API_VERSION + 1}`); + expect(message).toContain(`node API version ${unsupportedLevel}`); expect(message).toContain(`supports up to ${N8N_NODES_API_VERSION}`); expect(message).toContain('Upgrade n8n'); }); diff --git a/packages/core/src/nodes-loader/__tests__/scan-directory-for-packages.test.ts b/packages/core/src/nodes-loader/__tests__/scan-directory-for-packages.test.ts index c4b0e4d998e..f0d8a229e39 100644 --- a/packages/core/src/nodes-loader/__tests__/scan-directory-for-packages.test.ts +++ b/packages/core/src/nodes-loader/__tests__/scan-directory-for-packages.test.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line import-x/order import { mock } from 'vitest-mock-extended'; import { Logger } from '@n8n/backend-common'; -import { N8N_NODES_API_VERSION } from 'n8n-workflow'; +import { N8N_NODES_API_VERSION, parseNodesApiLevel } from 'n8n-workflow'; import * as fs from 'node:fs'; import type * as fsPromises from 'node:fs/promises'; @@ -36,6 +36,9 @@ describe('scanDirectoryForPackages', () => { const firecrawlPackageJson = (n8n?: object) => JSON.stringify({ name: '@mendable/n8n-nodes-firecrawl', version: '2.1.2', ...{ n8n } }); + const { major, minor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!; + const unsupportedLevel = `${major}.${minor + 1}`; + const enoent = (file: string): NodeJS.ErrnoException => { const error: NodeJS.ErrnoException = new Error( `ENOENT: no such file or directory, open '${file}'`, @@ -116,7 +119,7 @@ describe('scanDirectoryForPackages', () => { }); it('skips a package requiring an unsupported node API version and keeps compatible ones', async () => { - mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 })); + mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: unsupportedLevel })); const loaders = await scanDirectoryForPackages(nodeModulesDir); @@ -127,7 +130,7 @@ describe('scanDirectoryForPackages', () => { }); it('returns a loader for a package declaring a supported node API version', async () => { - mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: 1 })); + mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: '1.0' })); const loaders = await scanDirectoryForPackages(nodeModulesDir); @@ -144,7 +147,7 @@ describe('scanDirectoryForPackages', () => { }); it('logs name, declared version, supported version, and remediation for an unsupported package', async () => { - mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: N8N_NODES_API_VERSION + 1 })); + mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: unsupportedLevel })); await scanDirectoryForPackages(nodeModulesDir); @@ -152,13 +155,13 @@ describe('scanDirectoryForPackages', () => { expect.stringContaining('@mendable/n8n-nodes-firecrawl'), ); const [message] = vi.mocked(logger.warn).mock.calls[0]; - expect(message).toContain(`node API version ${N8N_NODES_API_VERSION + 1}`); + expect(message).toContain(`node API version ${unsupportedLevel}`); expect(message).toContain(`supports up to ${N8N_NODES_API_VERSION}`); expect(message).toContain('Upgrade n8n'); }); it('skips a package with a malformed node API version', async () => { - mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: '3' })); + mockPackageJsonOnDisk(firecrawlPackageJson({ n8nNodesApiVersion: 3.1 })); const loaders = await scanDirectoryForPackages(nodeModulesDir); diff --git a/packages/core/src/nodes-loader/scan-directory-for-packages.ts b/packages/core/src/nodes-loader/scan-directory-for-packages.ts index d24b96039d5..4e354aa5c5e 100644 --- a/packages/core/src/nodes-loader/scan-directory-for-packages.ts +++ b/packages/core/src/nodes-loader/scan-directory-for-packages.ts @@ -49,7 +49,7 @@ export async function scanDirectoryForPackages( const requirement = check.reason === 'malformed' ? `an invalid n8nNodesApiVersion (${JSON.stringify(check.declared)})` - : `node API version ${String(check.declared)}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`; + : `node API version ${check.required}, but this n8n version supports up to ${N8N_NODES_API_VERSION}`; logger.warn( `Skipping package "${loader.packageName}": it requires ${requirement}. Upgrade n8n to use this package, or uninstall it in Settings > Community nodes.`, ); diff --git a/packages/core/src/nodes-loader/types.ts b/packages/core/src/nodes-loader/types.ts index f1f99036f08..d389174c391 100644 --- a/packages/core/src/nodes-loader/types.ts +++ b/packages/core/src/nodes-loader/types.ts @@ -5,7 +5,8 @@ export namespace n8n { n8n?: { credentials?: string[]; nodes?: string[]; - n8nNodesApiVersion?: number; + /** A positive integer or `"."`, as declared by the package. */ + n8nNodesApiVersion?: number | string; }; author?: { name?: string; diff --git a/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.test.ts b/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.test.ts index 62ef1b61de7..7bc01e9c4d5 100644 --- a/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.test.ts @@ -52,13 +52,13 @@ describe('isCommunityPackageUpdateAvailable', () => { }); describe('isNodesApiVersionError', () => { - const incompatibleNodesApiVersionError = (requiredNodesApiVersion: number | null): unknown => ({ + const incompatibleNodesApiVersionError = (requiredNodesApiVersion: string | null): unknown => ({ httpStatusCode: 400, - meta: { requiredNodesApiVersion, supportedNodesApiVersion: 1 }, + meta: { requiredNodesApiVersion, supportedNodesApiVersion: '1' }, }); it('should match the error metadata, not the message', () => { - expect(isNodesApiVersionError(incompatibleNodesApiVersionError(3))).toBe(true); + expect(isNodesApiVersionError(incompatibleNodesApiVersionError('3.1'))).toBe(true); // Malformed declared values carry `null` and must be recognized too. expect(isNodesApiVersionError(incompatibleNodesApiVersionError(null))).toBe(true); // The same copy without the metadata is a generic error. diff --git a/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.ts b/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.ts index b85c285864a..0427db12ffe 100644 --- a/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.ts +++ b/packages/frontend/editor-ui/src/features/settings/communityNodes/communityNodes.utils.ts @@ -43,9 +43,9 @@ export function isCommunityPackageUpdateAvailable({ interface IncompatibleNodesApiVersionErrorResponse { httpStatusCode: number; meta: { - /** API version the package requires, or `null` if the declared value is malformed. */ - requiredNodesApiVersion: number | null; - supportedNodesApiVersion: number; + /** `null` if the declared value is malformed. */ + requiredNodesApiVersion: string | null; + supportedNodesApiVersion: string; }; } diff --git a/packages/frontend/editor-ui/src/features/settings/communityNodes/composables/useInstallNode.test.ts b/packages/frontend/editor-ui/src/features/settings/communityNodes/composables/useInstallNode.test.ts index cf648838c00..39fdf72e5b5 100644 --- a/packages/frontend/editor-ui/src/features/settings/communityNodes/composables/useInstallNode.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/communityNodes/composables/useInstallNode.test.ts @@ -400,7 +400,7 @@ describe('useInstallNode', () => { new Error('This community node requires n8n node API version 3.'), { httpStatusCode: 400, - meta: { requiredNodesApiVersion: 3, supportedNodesApiVersion: 1 }, + meta: { requiredNodesApiVersion: '3', supportedNodesApiVersion: '1' }, }, ); vi.mocked(communityNodesStore.installPackage).mockRejectedValue(error); diff --git a/packages/quality/testing/playwright/tests/e2e/settings/community-nodes/node-api-version-guard.spec.ts b/packages/quality/testing/playwright/tests/e2e/settings/community-nodes/node-api-version-guard.spec.ts index 5f01e64fac0..aa436d5306f 100644 --- a/packages/quality/testing/playwright/tests/e2e/settings/community-nodes/node-api-version-guard.spec.ts +++ b/packages/quality/testing/playwright/tests/e2e/settings/community-nodes/node-api-version-guard.spec.ts @@ -81,9 +81,9 @@ test.describe( expect(response.status()).toBe(400); const rejection = await api.communityPackages.readRejection(response); expect(rejection.message).toContain("isn't compatible with your version of n8n"); - expect(String(rejection.meta.requiredNodesApiVersion)).toBe('3'); - expect(String(rejection.meta.supportedNodesApiVersion)).toBe( - String(supportedNodesApiVersion), + expect(rejection.meta.requiredNodesApiVersion).toBe('3.0'); + expect(String(rejection.meta.supportedNodesApiVersion)).toMatch( + new RegExp(`^${supportedNodesApiVersion}(\\.\\d+)?$`), ); await expectAbsent(api, packageDisk, v3); }); @@ -109,7 +109,7 @@ test.describe( const response = await api.communityPackages.update(legacyV3.name, legacyV3.version); expect(response.status()).toBe(400); const rejection = await api.communityPackages.readRejection(response); - expect(String(rejection.meta.requiredNodesApiVersion)).toBe('3'); + expect(rejection.meta.requiredNodesApiVersion).toBe('3.0'); await expectInstalled(api, packageDisk, legacy); const state = await packageDisk.stateOf(legacyV3.name); diff --git a/packages/workflow/src/nodes-api-version.ts b/packages/workflow/src/nodes-api-version.ts index daacbc65125..0c9696ef04f 100644 --- a/packages/workflow/src/nodes-api-version.ts +++ b/packages/workflow/src/nodes-api-version.ts @@ -1,54 +1,53 @@ import { N8N_NODES_API_VERSION } from '@n8n/constants'; +import { formatNodesApiLevel, parseNodesApiLevel } from '@n8n/utils/nodes-api-level'; -export { N8N_NODES_API_VERSION }; +export { N8N_NODES_API_VERSION, formatNodesApiLevel, parseNodesApiLevel }; /** Minimal package.json shape needed to check node API compatibility. */ export interface NodesApiVersionPackageJson { n8n?: { - /** - * Node-authoring API level the package requires. Absent in legacy - * packages, which are treated as requiring level 1. - */ n8nNodesApiVersion?: unknown; }; } export type NodesApiVersionCheck = + | { compatible: true } | { - compatible: true; - /** Effective required API level; 1 for packages without metadata. */ - version: number; + compatible: false; + reason: 'malformed'; + declared: unknown; } | { compatible: false; - reason: 'malformed' | 'unsupported'; - /** The declared value, as read from package.json. */ + reason: 'unsupported'; declared: unknown; + /** As `.`, for messages and metadata. */ + required: string; }; -/** - * Read a community package's declared node-authoring API level and check it - * against the level this runtime supports (`required <= N8N_NODES_API_VERSION`). - * - * Missing `n8n.n8nNodesApiVersion` means a legacy package and resolves to - * level 1. Malformed values (non-integer, non-positive, non-number) are - * reported as incompatible — the runtime cannot distinguish an old package - * from a corrupt or hostile one if both fall back to legacy. - */ +function supportedLevel() { + const level = parseNodesApiLevel(N8N_NODES_API_VERSION); + if (level === null) + throw new Error(`N8N_NODES_API_VERSION is not a level: ${N8N_NODES_API_VERSION}`); + return level; +} + +// A missing declaration is a legacy package at level 1. A malformed one is +// incompatible: the runtime cannot tell an old package from a corrupt one. export function checkNodesApiVersion(pkgJson: NodesApiVersionPackageJson): NodesApiVersionCheck { const declared = pkgJson?.n8n?.n8nNodesApiVersion; - if (declared === undefined) { - return { compatible: true, version: 1 }; - } + if (declared === undefined) return { compatible: true }; - if (typeof declared !== 'number' || !Number.isInteger(declared) || declared < 1) { - return { compatible: false, reason: 'malformed', declared }; - } + const required = parseNodesApiLevel(declared); + if (required === null) return { compatible: false, reason: 'malformed', declared }; - if (declared <= N8N_NODES_API_VERSION) { - return { compatible: true, version: declared }; - } + if (required.compare(supportedLevel()) <= 0) return { compatible: true }; - return { compatible: false, reason: 'unsupported', declared }; + return { + compatible: false, + reason: 'unsupported', + declared, + required: formatNodesApiLevel(required), + }; } diff --git a/packages/workflow/test/nodes-api-version.test.ts b/packages/workflow/test/nodes-api-version.test.ts index ca5fd25e112..7c44a72a086 100644 --- a/packages/workflow/test/nodes-api-version.test.ts +++ b/packages/workflow/test/nodes-api-version.test.ts @@ -1,49 +1,82 @@ -import { N8N_NODES_API_VERSION, checkNodesApiVersion } from '../src/nodes-api-version'; +import { + N8N_NODES_API_VERSION, + checkNodesApiVersion, + formatNodesApiLevel, + parseNodesApiLevel, +} from '../src/nodes-api-version'; const pkg = (n8nNodesApiVersion?: unknown) => ({ n8n: n8nNodesApiVersion === undefined ? {} : { n8nNodesApiVersion }, }); +const { major: supportedMajor, minor: supportedMinor } = parseNodesApiLevel(N8N_NODES_API_VERSION)!; + +describe('N8N_NODES_API_VERSION', () => { + it('is a level written as major.minor', () => { + expect(N8N_NODES_API_VERSION).toBe( + formatNodesApiLevel(parseNodesApiLevel(N8N_NODES_API_VERSION)!), + ); + }); +}); + describe('checkNodesApiVersion', () => { it('treats a missing n8n section as legacy level 1', () => { - expect(checkNodesApiVersion({})).toEqual({ compatible: true, version: 1 }); + expect(checkNodesApiVersion({})).toEqual({ compatible: true }); }); it('treats a missing n8nNodesApiVersion as legacy level 1', () => { - expect(checkNodesApiVersion(pkg())).toEqual({ compatible: true, version: 1 }); + expect(checkNodesApiVersion(pkg())).toEqual({ compatible: true }); }); - // The rule under test is `required <= supported`. The expectations below - // state that rule at its exact boundaries instead of pinning the - // constant's value, so they survive a deliberate bump without edits. - // They also fail if the constant degenerates (below 1 or non-integer). - it('accepts the floor level 1', () => { - expect(checkNodesApiVersion(pkg(1))).toEqual({ compatible: true, version: 1 }); + it('accepts the integer 1', () => { + expect(checkNodesApiVersion(pkg(1))).toEqual({ compatible: true }); }); - it('accepts a package that requires exactly the supported level', () => { - expect(checkNodesApiVersion(pkg(N8N_NODES_API_VERSION))).toEqual({ + it('accepts the supported level', () => { + expect(checkNodesApiVersion(pkg(N8N_NODES_API_VERSION))).toEqual({ compatible: true }); + }); + + it('accepts a minor below the supported one', () => { + if (supportedMinor === 0) return; + expect(checkNodesApiVersion(pkg(`${supportedMajor}.${supportedMinor - 1}`))).toEqual({ compatible: true, - version: N8N_NODES_API_VERSION, }); }); - it('rejects a package that requires one level above the supported level', () => { - const above = N8N_NODES_API_VERSION + 1; + it('rejects one minor above the supported level', () => { + const above = `${supportedMajor}.${supportedMinor + 1}`; expect(checkNodesApiVersion(pkg(above))).toEqual({ compatible: false, reason: 'unsupported', declared: above, + required: above, }); }); - for (const declared of ['3', 0, -1, 2.5, null, NaN, Infinity, true, {}]) { - it(`rejects malformed value ${String(declared)}`, () => { + it('rejects the next major at minor 0', () => { + const above = `${supportedMajor + 1}.0`; + expect(checkNodesApiVersion(pkg(above))).toEqual({ + compatible: false, + reason: 'unsupported', + declared: above, + required: above, + }); + }); + + it('reports an integer level as major.minor', () => { + expect(checkNodesApiVersion(pkg(supportedMajor + 1))).toMatchObject({ + required: `${supportedMajor + 1}.0`, + }); + }); + + it.each(['3.1.0', 'three', 0, -1, 2.5, 3.1, null, NaN, true, {}, '9007199254740992'])( + 'rejects the malformed value %p', + (declared) => { expect(checkNodesApiVersion(pkg(declared))).toEqual({ compatible: false, reason: 'malformed', declared, }); - }); - } + }, + ); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cb2002d4c0..a032984fe4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2886,6 +2886,9 @@ importers: packages/@n8n/eslint-plugin-community-nodes: dependencies: + '@n8n/utils': + specifier: workspace:* + version: link:../utils '@typescript-eslint/typescript-estree': specifier: 'catalog:' version: 8.70.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) @@ -4589,6 +4592,9 @@ importers: nanoid: specifier: 'catalog:' version: 3.3.18 + semver: + specifier: 7.7.3 + version: 7.7.3 devDependencies: '@n8n/oxlint-config': specifier: workspace:* @@ -4602,6 +4608,9 @@ importers: '@testing-library/jest-dom': specifier: catalog:frontend version: 6.9.1 + '@types/semver': + specifier: 'catalog:' + version: 7.7.0 fast-check: specifier: 'catalog:' version: 3.23.2