From 39a2093dac3c6b4e5774f0fdb05e643ef12d199e Mon Sep 17 00:00:00 2001 From: Matsu Date: Thu, 2 Jul 2026 16:27:38 +0300 Subject: [PATCH] fix(core): Prevent duplicate zod instances that break npm installs (#33463) Co-authored-by: Claude Opus 4.8 --- lefthook.yml | 6 ++ package.json | 1 + packages/@n8n/agents/package.json | 7 +- packages/@n8n/api-types/package.json | 7 +- .../@n8n/eslint-config/src/configs/base.ts | 1 - packages/core/package.json | 4 +- packages/workflow/package.json | 4 +- pnpm-lock.yaml | 24 ++--- scripts/check-zod-peer-deps.mjs | 96 +++++++++++++++++++ 9 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 scripts/check-zod-peer-deps.mjs diff --git a/lefthook.yml b/lefthook.yml index 794a4925d2c..b06a5c5c8af 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -32,6 +32,12 @@ pre-commit: skip: - merge - rebase + zod_peer_deps_check: + glob: 'packages/{@n8n/api-types,workflow,core,@n8n/agents}/package.json' + run: node scripts/check-zod-peer-deps.mjs + skip: + - merge + - rebase skill_links_check: glob: '.agents/skills/**' run: node scripts/sync-agent-skill-links.mjs --check diff --git a/package.json b/package.json index 516192329da..9a5989db31b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "lint:ci": "turbo run lint lint:styles", "sync:skill-links": "node scripts/sync-agent-skill-links.mjs", "check:skill-links": "node scripts/sync-agent-skill-links.mjs --check", + "check:zod-peer-deps": "node scripts/check-zod-peer-deps.mjs", "optimize-svg": "find ./packages -name '*.svg' ! -name 'pipedrive.svg' -print0 | xargs -0 -P16 -L20 npx svgo", "setup-backend-module": "node scripts/ensure-zx.mjs && zx scripts/backend-module/setup.mjs", "start": "node scripts/os-normalize.mjs --dir packages/cli/bin n8n", diff --git a/packages/@n8n/agents/package.json b/packages/@n8n/agents/package.json index 2e549352038..3bc6d4d35a7 100644 --- a/packages/@n8n/agents/package.json +++ b/packages/@n8n/agents/package.json @@ -86,9 +86,11 @@ "langsmith": "catalog:", "undici": "catalog:undici-v7", "yaml": "catalog:", - "zod": "catalog:", "zod-to-json-schema": "catalog:" }, + "peerDependencies": { + "zod": "catalog:" + }, "peerDependenciesMeta": { "langsmith": { "optional": true @@ -113,7 +115,8 @@ "nock": "catalog:", "vite": "catalog:", "vitest": "catalog:", - "vitest-mock-extended": "catalog:" + "vitest-mock-extended": "catalog:", + "zod": "catalog:" }, "license": "LicenseRef-n8n-sustainable-use" } diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 242373482df..e84b21aaf62 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -30,13 +30,16 @@ "vite": "catalog:", "vitest": "catalog:", "vitest-mock-extended": "catalog:", - "minifaker": "1.34.1" + "minifaker": "1.34.1", + "zod": "catalog:" }, "dependencies": { "@n8n_io/ai-assistant-sdk": "catalog:", "n8n-workflow": "workspace:*", "xss": "catalog:", - "@n8n/permissions": "workspace:*", + "@n8n/permissions": "workspace:*" + }, + "peerDependencies": { "zod": "catalog:" }, "license": "LicenseRef-n8n-sustainable-use" diff --git a/packages/@n8n/eslint-config/src/configs/base.ts b/packages/@n8n/eslint-config/src/configs/base.ts index 3eb92e55665..77e47188879 100644 --- a/packages/@n8n/eslint-config/src/configs/base.ts +++ b/packages/@n8n/eslint-config/src/configs/base.ts @@ -351,7 +351,6 @@ export const baseConfig = tseslint.config( '**/*.stories.ts', ], optionalDependencies: false, - peerDependencies: false, }, ], diff --git a/packages/core/package.json b/packages/core/package.json index 225aef50d26..6612fd2adda 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -50,7 +50,8 @@ "reflect-metadata": "catalog:", "vite": "catalog:", "vitest": "catalog:", - "vitest-mock-extended": "catalog:" + "vitest-mock-extended": "catalog:", + "zod": "catalog:" }, "peerDependencies": { "zod": "catalog:" @@ -96,7 +97,6 @@ "uuid": "catalog:", "winston": "3.14.2", "xml2js": "catalog:", - "zod": "catalog:", "qs": "catalog:" }, "license": "LicenseRef-n8n-sustainable-use" diff --git a/packages/workflow/package.json b/packages/workflow/package.json index d523cbbbd77..6c094dcbe25 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -92,7 +92,9 @@ "transliteration": "2.3.5", "uuid": "catalog:", "xml2js": "catalog:", - "jsonrepair": "catalog:", + "jsonrepair": "catalog:" + }, + "peerDependencies": { "zod": "catalog:" }, "license": "LicenseRef-n8n-sustainable-use" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba7c05b148a..1f5cda9cb32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -959,9 +959,6 @@ importers: yaml: specifier: 'catalog:' version: 2.8.3 - zod: - specifier: 3.25.67 - version: 3.25.67 zod-to-json-schema: specifier: 'catalog:' version: 3.23.3(zod@3.25.67) @@ -996,6 +993,9 @@ importers: vitest-mock-extended: specifier: 'catalog:' version: 3.1.0(typescript@6.0.2)(vitest@4.1.9) + zod: + specifier: 3.25.67 + version: 3.25.67 packages/@n8n/ai-node-sdk: dependencies: @@ -1257,9 +1257,6 @@ importers: xss: specifier: 'catalog:' version: 1.0.15 - zod: - specifier: 3.25.67 - version: 3.25.67 devDependencies: '@n8n/config': specifier: workspace:* @@ -1285,6 +1282,9 @@ importers: vitest-mock-extended: specifier: 'catalog:' version: 3.1.0(typescript@6.0.2)(vitest@4.1.9) + zod: + specifier: 3.25.67 + version: 3.25.67 packages/@n8n/backend-common: dependencies: @@ -4393,9 +4393,6 @@ importers: xml2js: specifier: 'catalog:' version: 0.6.2 - zod: - specifier: 3.25.67 - version: 3.25.67 devDependencies: '@n8n/playwright-janitor': specifier: workspace:* @@ -4457,6 +4454,9 @@ importers: vitest-mock-extended: specifier: 'catalog:' version: 3.1.0(typescript@6.0.2)(vitest@4.1.9) + zod: + specifier: 3.25.67 + version: 3.25.67 packages/extensions/insights: dependencies: @@ -6417,9 +6417,6 @@ importers: xml2js: specifier: 'catalog:' version: 0.6.2 - zod: - specifier: 3.25.67 - version: 3.25.67 devDependencies: '@langchain/core': specifier: 'catalog:' @@ -6478,6 +6475,9 @@ importers: vitest-mock-extended: specifier: 'catalog:' version: 3.1.0(typescript@6.0.2)(vitest@4.1.9) + zod: + specifier: 3.25.67 + version: 3.25.67 packages: diff --git a/scripts/check-zod-peer-deps.mjs b/scripts/check-zod-peer-deps.mjs new file mode 100644 index 00000000000..908331525a0 --- /dev/null +++ b/scripts/check-zod-peer-deps.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +/** + * Guards against an npm-only startup break: workspace packages that compose zod + * schemas across package boundaries must declare `zod` as a **peerDependency**, + * not a regular dependency. + * + * `@n8n/api-types` builds a `z.discriminatedUnion` over schemas created in + * `n8n-workflow`. That only works when both packages resolve the *same* physical + * zod instance. In the pnpm monorepo the catalog forces one instance, so it always + * holds locally. But on `npm install n8n`, if these packages each list `zod` in + * `dependencies`, npm can install a separate nested copy per package (langchain + * pins `zod@4` at the root, pushing our `zod@3` down), so the cross-package schema + * composition resolves different instances and n8n crashes at boot. Declaring zod + * as a peerDependency makes the consuming app provide a single shared instance. + * + * This exact regression shipped in 2.28.0 (#32386 moved zod peer→dependencies), + * reverting the fix from #28604. This check keeps zod a peer so it can't recur. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +// Packages that share zod schema objects across the workspace and are runtime +// dependencies of the `n8n` cli. zod must be a peer here, never a plain dependency. +export const PACKAGES_REQUIRING_ZOD_PEER = [ + 'packages/@n8n/api-types', + 'packages/workflow', + 'packages/core', + 'packages/@n8n/agents', +]; + +/** Pure, testable core: return a problem string for a manifest, or null if OK. */ +export function checkManifest(name, pkg) { + const inDeps = pkg.dependencies?.zod; + const inPeer = pkg.peerDependencies?.zod; + if (inDeps) { + return `${name}: "zod" is in "dependencies" (${inDeps}); it must be a "peerDependency" instead.`; + } + if (!inPeer) { + return `${name}: "zod" is missing from "peerDependencies".`; + } + return null; +} + +function main() { + const problems = []; + for (const rel of PACKAGES_REQUIRING_ZOD_PEER) { + const file = join(root, rel, 'package.json'); + if (!existsSync(file)) continue; // package moved/renamed — don't hard-fail here + const pkg = JSON.parse(readFileSync(file, 'utf8')); + const problem = checkManifest(pkg.name ?? rel, pkg); + if (problem) problems.push(problem); + } + + if (problems.length > 0) { + console.error(''); + console.error('ERROR: zod must stay a peerDependency in schema-composing packages.'); + console.error(''); + console.error('Moving zod into "dependencies" lets npm install duplicate zod copies, which'); + console.error('breaks cross-package schema composition and crashes n8n at startup on'); + console.error('`npm install n8n` (regressed once in 2.28.0 via #32386).'); + console.error(''); + for (const p of problems) console.error(` - ${p}`); + console.error(''); + console.error( + 'Move "zod" to "peerDependencies" (keep it in "devDependencies" for local builds).', + ); + console.error(''); + process.exit(1); + } + + console.log( + `OK: zod is a peerDependency in all ${PACKAGES_REQUIRING_ZOD_PEER.length} schema-composing packages.`, + ); +} + +// `--self-test` exercises the core logic without touching the workspace. +if (process.argv.includes('--self-test')) { + const good = checkManifest('good', { peerDependencies: { zod: 'catalog:' } }) === null; + const badDep = checkManifest('bad', { dependencies: { zod: 'catalog:' } }) !== null; + const badMissing = checkManifest('missing', {}) !== null; + if (good && badDep && badMissing) { + console.log('self-test passed'); + process.exit(0); + } + console.error(`self-test FAILED (good=${good}, badDep=${badDep}, badMissing=${badMissing})`); + process.exit(1); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +}