Skip to content

fix(build): ship & harden plugin/node_modules so hooks resolve zod/v3 (#2379)#2710

Open
praxstack wants to merge 2 commits into
thedotmack:mainfrom
praxstack:fix/ship-and-harden-plugin-node-modules
Open

fix(build): ship & harden plugin/node_modules so hooks resolve zod/v3 (#2379)#2710
praxstack wants to merge 2 commits into
thedotmack:mainfrom
praxstack:fix/ship-and-harden-plugin-node-modules

Conversation

@praxstack

@praxstack praxstack commented May 29, 2026

Copy link
Copy Markdown

Problem

The bundled worker-service.cjs marks zod, shell-quote, and the tree-sitter-* grammars as esbuild externals, so they must exist in plugin/node_modules at runtime. On a marketplace / npm install they don't, and every hook crashes with Cannot find module 'zod/v3' — surfacing in Claude Code as repeating SessionStart/UserPromptSubmit hook error … node:internal/modules/cjs/loader:1478.

Tracked in #2379; reported across #2407, #2453, #2637, #2640.

Reproduced on a clean v13.2.0 marketplace install (macOS arm64, Node 26). With bun's auto-install on, the missing zod/v3 external degenerates into GET https://registry.npmjs.org/@%2f-darwin-arm64 → 404; with bun --no-install the real cause Cannot find module 'zod/v3' is unmasked.

Fix

Three changes, combining and extending #2531 and #2597:

  1. Install plugin deps at build timenpm install --omit=dev --no-audit --no-fund via spawnSync, with shell:true on win32 (npm is a .cmd shim) and __dirname-anchored cwd. Fail-soft.
  2. Ship it — root package.json files gains "plugin/node_modules". npm force-strips only a top-level node_modules, not a nested one named explicitly in files — verified via npm pack --dry-run. (fix(packaging): bundle plugin/node_modules so marketplace install ships runtime deps (closes #2407) #2531 adds this; fix(build): populate plugin/node_modules during build (partial #2407) #2597 does not, so fix(build): populate plugin/node_modules during build (partial #2407) #2597 alone still ships an empty tarball.)
  3. Harden + gate — tree-sitter grammars move to optionalDependencies, and a hard resolve gate (createRequire(plugin/package.json).resolve('zod/v3') + shell-quote) fails the build loudly if the install left zod missing.

Why (3) matters — the gap both prior PRs miss

Grammars are native node-gyp/prebuild-install builds. On a Node version with no prebuilt binding (Node 26 here), a grammar build fails → npm install exits nonzero → as hard dependencies that aborts the entire install, so zod never lands and node_modules ships empty. Confirmed locally: plain npm install aborted leaving plugin/node_modules with 0 entries.

As optionalDependencies the failure degrades only code-graph parsing; zod + shell-quote still install and hooks still boot. The resolve gate then guarantees a broken artifact can never ship silently — which lines up with plan-04's "fail loudly, no false success" goal.

Verification

macOS arm64, Node 26:

  • With grammars as optionalDependencies, npm install --omit=dev succeeds despite the grammar gyp failure.
  • createRequire(plugin/package.json).resolve('zod/v3') and resolve('shell-quote') both succeed → hooks boot.
  • npm pack --dry-run confirms plugin/node_modules/... ships in the tarball.
  • node --check scripts/build-hooks.js passes.

Relationship to existing PRs

Supersedes the partial approaches in #2531 (good files entry, crude execSync 'npm install --production') and #2597 (robust install, no files entry). Happy to instead fold these commits into either PR or the plan-04 branch if the maintainer prefers.

Refs #2379, #2531, #2597. Closes #2407.

🤖 Generated with Claude Code


PR comparison verdict

files entry (ships tarball) robust install grammars optional resolve gate
#2531 ❌ crude execSync
#2597
#2710 (ours)

Local workaround (until this merges)

cd <plugin cache dir>/<version> && bun add zod@^4.3.6 — populates plugin/node_modules so hooks boot. Recurs on next plugin update unless #2710 (or equivalent) lands upstream.

@greptile-apps

greptile-apps Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the Cannot find module 'zod/v3' crash on fresh marketplace installs by shipping plugin/node_modules in the npm tarball, running npm install at build time, and adding a runtime self-heal in bun-runner.js for git-clone (marketplace) installs. Tree-sitter grammars are moved to optionalDependencies so native build failures on unsupported Node versions (e.g. Node 26) can no longer block zod from installing.

  • Build-time install + tarball inclusion: build-hooks.js now runs npm install --omit=dev in plugin/ during the build, and package.json adds plugin/node_modules to files so it ships in the tarball.
  • Runtime self-heal: bun-runner.js checks for zod/v3 on every invocation and, if missing, runs a targeted npm install --ignore-scripts zod shell-quote before spawning the worker — covering the marketplace / git-clone path.
  • Hard resolve gate: A createRequire-based check at the end of build-hooks.js ensures zod/v3 and shell-quote actually resolve after the install step, failing the build loudly if they do not."

Confidence Score: 4/5

The npm stdout inheritance in bun-runner.js will corrupt structured hook responses on first run for marketplace installs — exactly the scenario this PR is meant to fix.

The self-heal logic in bun-runner.js uses stdio: ['ignore', 'inherit', 'inherit'], routing npm's stdout directly to the hook process's stdout. Claude Code reads that stdout as the hook response JSON, so npm's 'added N packages' summary line would be prepended to the hook's output and cause a parse failure on first run of a fresh marketplace install.

plugin/scripts/bun-runner.js — the stdio configuration for the self-heal npm install needs attention before this ships.

Important Files Changed

Filename Overview
plugin/scripts/bun-runner.js Adds ensureRuntimeDeps() self-heal for marketplace installs — resolves well for most cases, but npm stdout is inherited to hook stdout (risks corrupting structured hook responses), and the zod version floor in the self-heal (^4.3.6) is lower than the package manifest (^4.4.3).
scripts/build-hooks.js Adds build-time npm install in plugin/, moves grammars to optionalDependencies, and installs a hard resolve gate — solid approach; the gate's inclusion of shell-quote is misleading since that module is statically bundled (not external) in worker-service.cjs.
package.json Adds plugin/node_modules to the files list — correctly enables nested node_modules to survive npm pack; tarball will include platform-specific grammar .node binaries compiled at publish time.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User installs plugin] --> B{Install channel?}
    B -->|npm tarball| C[plugin/node_modules shipped\nbuild-time npm install]
    B -->|Marketplace git clone| D[plugin/node_modules gitignored\nnot present]
    C --> E[bun-runner.js invoked by hook]
    D --> E
    E --> F{zod/v3 resolvable?}
    F -->|Yes| G[Spawn worker-service.cjs]
    F -->|No| H[ensureRuntimeDeps: npm install\n--ignore-scripts zod shell-quote]
    H --> I{npm install result}
    I -->|Success| G
    I -->|Failure| J[Log error, spawn worker\nworker crashes]
    G --> K[Hook executes normally]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[User installs plugin] --> B{Install channel?}
    B -->|npm tarball| C[plugin/node_modules shipped\nbuild-time npm install]
    B -->|Marketplace git clone| D[plugin/node_modules gitignored\nnot present]
    C --> E[bun-runner.js invoked by hook]
    D --> E
    E --> F{zod/v3 resolvable?}
    F -->|Yes| G[Spawn worker-service.cjs]
    F -->|No| H[ensureRuntimeDeps: npm install\n--ignore-scripts zod shell-quote]
    H --> I{npm install result}
    I -->|Success| G
    I -->|Failure| J[Log error, spawn worker\nworker crashes]
    G --> K[Hook executes normally]
Loading

Reviews (3): Last reviewed commit: "fix(runtime): self-heal missing zod/v3 i..." | Re-trigger Greptile

Comment thread scripts/build-hooks.js
Comment thread package.json
"plugin/scripts/CLAUDE.md",
"plugin/skills",
"plugin/ui",
"plugin/node_modules",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 plugin/node_modules includes platform-specific native binaries

Including plugin/node_modules in files means the published tarball will contain the tree-sitter grammar .node prebuilt binaries compiled for the platform on which npm run build was run (macOS arm64, x64, Linux, etc.). Users who install the package on a different platform will silently get non-functional grammars even though they would otherwise succeed with an on-machine install. Since the grammars are optionalDependencies, this degrades code-graph parsing rather than causing a crash, but it is worth being aware that each publish will be inadvertently platform-scoped for the optional features. Depending on the size of the grammar binaries (each can be several MB), the tarball size may also increase substantially — worth running npm pack --dry-run to verify the delta before releasing.

Prompt To Fix With AI
This is a comment left during a code review.
Path: package.json
Line: 59

Comment:
**`plugin/node_modules` includes platform-specific native binaries**

Including `plugin/node_modules` in `files` means the published tarball will contain the tree-sitter grammar `.node` prebuilt binaries compiled for the platform on which `npm run build` was run (macOS arm64, x64, Linux, etc.). Users who install the package on a different platform will silently get non-functional grammars even though they would otherwise succeed with an on-machine install. Since the grammars are `optionalDependencies`, this degrades code-graph parsing rather than causing a crash, but it is worth being aware that each publish will be inadvertently platform-scoped for the optional features. Depending on the size of the grammar binaries (each can be several MB), the tarball size may also increase substantially — worth running `npm pack --dry-run` to verify the delta before releasing.

How can I resolve this? If you propose a fix, please make it concise.

@praxstack

Copy link
Copy Markdown
Author

Update — added runtime self-heal for the marketplace (git-clone) channel.

While dogfooding the fix I hit the original crash again on a fresh session, and traced it to a second distribution path the build-time fix can't reach:

Channel How plugin/node_modules arrives Fixed by build-time install + files?
npm tarball (npx claude-mem install) shipped in tarball ✅ yes
marketplace (~/.claude/plugins/marketplaces/thedotmack/) git clone of this repoplugin/node_modules is gitignored, never committed ❌ no

The marketplace copy is a git clone (confirmed .git present), and the live session passes CLAUDE_PLUGIN_ROOT pointing at it. Since node_modules/ is gitignored, the build-time npm install output never reaches a marketplace user — so that copy still crashes with Cannot find module 'zod/v3'. (version-check.js only prints "run npx install"; nothing populates deps.)

New commit df172d94 adds a runtime self-heal in bun-runner.js: on first run, if zod/v3 doesn't resolve from the plugin root, it installs zod + shell-quote with --ignore-scripts, then proceeds. Idempotent (no-op once resolvable).

--ignore-scripts is load-bearing: npm resolves the full tree from the existing package.json, and the tree-sitter grammars are native node-gyp builds that abort the whole install on Node versions without a prebuilt binding (e.g. Node 26) — which would leave zod uninstalled. Skipping scripts keeps the hook recovery reliable; grammar/code-graph deps still heal via the full npx claude-mem install.

Verified on macOS arm64 + Node 26: fresh plugin root with no node_modules recovers on first hook invocation, zod/v3 + shell-quote resolve, second run does zero work.

So the PR now covers both channels: build-time bundling for npm, runtime self-heal for git-clone marketplace. Refs #2379.

Comment on lines +126 to +131
const install = spawnSync('npm', ['install', '--no-save', '--no-audit', '--no-fund', '--ignore-scripts', 'zod@^4.3.6', 'shell-quote@^1.8.3'], {
cwd: RESOLVED_PLUGIN_ROOT,
stdio: ['ignore', 'inherit', 'inherit'],
// npm on Windows is a .cmd shim — spawn without shell hits ENOENT.
shell: IS_WINDOWS,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 npm install stdout contaminates hook's structured output

stdio: ['ignore', 'inherit', 'inherit'] passes npm's stdout straight to bun-runner.js's own stdout, which Claude Code reads as the hook's response. For UserPromptSubmit (and any hook that must return structured JSON on stdout), npm's "added N packages in Xs" summary line — which npm v9/v10 write to stdout, not stderr, even in a non-TTY pipe — would appear before the hook's JSON and cause Claude Code to fail to parse the response. The failure occurs only on first run on a marketplace install (exactly when the fix is most needed), and silently corrupts the hook decision.

Redirect index 1 of stdio to 'pipe' or 'ignore' so npm output stays off the hook's stdout channel.

Prompt To Fix With AI
This is a comment left during a code review.
Path: plugin/scripts/bun-runner.js
Line: 126-131

Comment:
**npm install stdout contaminates hook's structured output**

`stdio: ['ignore', 'inherit', 'inherit']` passes npm's stdout straight to bun-runner.js's own stdout, which Claude Code reads as the hook's response. For `UserPromptSubmit` (and any hook that must return structured JSON on stdout), npm's "added N packages in Xs" summary line — which npm v9/v10 write to **stdout**, not stderr, even in a non-TTY pipe — would appear before the hook's JSON and cause Claude Code to fail to parse the response. The failure occurs only on first run on a marketplace install (exactly when the fix is most needed), and silently corrupts the hook decision.

Redirect index 1 of stdio to `'pipe'` or `'ignore'` so npm output stays off the hook's stdout channel.

How can I resolve this? If you propose a fix, please make it concise.

@YOMXXX

YOMXXX commented May 30, 2026

Copy link
Copy Markdown
Contributor

Thanks @praxstack — great catch on the grammar-abort gap. I've folded both of those into #2597 (12a4360):

  • grammars → optionalDependencies: a native grammar build failure now only degrades code-graph parsing instead of aborting the whole npm install and taking zod down with it.
  • resolve gate: a createRequire(...).resolve(...) check at the end of build-hooks.js fails the build loudly if the critical runtime deps didn't land in plugin/node_modules (the install step is fail-soft, so this is the real guard).

One deliberate difference: I gated on zod (+ shell-quote) rather than zod/v3. On the #2597 branch better-auth is still bundled, so the worker bundle's actual external is bare zod — that's the specifier the runtime require resolves.

The one piece I did not pull in is the files: ["plugin/node_modules"] tarball entry (the #2531 column in your table). That's the orthogonal "ship it into the npm tarball" half, so I kept it out of #2597 to keep this PR focused on the dev-workflow + install-robustness piece. Happy to defer that half to #2710 or a dedicated follow-up — whatever @thedotmack prefers.

Either way your writeup made the gap obvious, thanks 🙂

praxstack and others added 2 commits June 25, 2026 20:02
…thedotmack#2379)

The bundled worker externalizes zod, shell-quote and the tree-sitter
grammars, so they must exist in plugin/node_modules at runtime. On a
marketplace/npm install they don't, and every hook crashes with
`Cannot find module 'zod/v3'` (thedotmack#2407 / thedotmack#2453 / thedotmack#2640 / thedotmack#2637).

Three changes, combining and extending the approaches in thedotmack#2531 and thedotmack#2597:

1. Build installs plugin deps (npm install --omit=dev) via spawnSync with
   win32 shell handling and __dirname-anchored cwd; fail-soft.
2. Root package.json `files` adds "plugin/node_modules" so the npm tarball
   actually ships it (npm force-strips only a top-level node_modules, not a
   nested one named explicitly in files — verified via `npm pack`). thedotmack#2531
   adds this; thedotmack#2597 does not, so thedotmack#2597 alone leaves the tarball broken.
3. Tree-sitter grammars move to optionalDependencies, and a hard resolve
   gate (`createRequire(...).resolve('zod/v3')`) fails the build loudly if
   the install left zod missing.

Why (3) matters and both prior PRs miss it: grammars are native node-gyp
builds. On a Node version without a prebuilt binding (e.g. Node 26) the
grammar build fails, npm exits nonzero, and as hard dependencies that
aborts the whole install — zod never lands and node_modules ships empty.
As optionalDependencies the failure degrades only code-graph parsing; zod
and shell-quote still install and hooks still boot. The resolve gate then
guarantees a broken artifact can never ship silently.

Verified locally on macOS arm64 + Node 26: with grammars optional, npm
install succeeds despite the grammar gyp failure and zod/v3 resolves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…stalls

The build-time install + tarball `files` entry fix the npm channel, but the
MARKETPLACE channel is a `git clone` where plugin/node_modules is gitignored
and never committed — so a fresh marketplace install has no node_modules and
every hook crashes with `Cannot find module 'zod/v3'` (thedotmack#2407/thedotmack#2453/thedotmack#2640/thedotmack#2379).
The build output can't fix this (it's gitignored), so heal at runtime.

On first run, if zod/v3 doesn't resolve from the plugin root, bun-runner
installs zod + shell-quote with --ignore-scripts before spawning the worker.
--ignore-scripts is required: npm resolves the full dep tree from the existing
package.json and the tree-sitter grammars are native node-gyp builds that abort
the whole install on a Node version without a prebuilt binding (e.g. Node 26),
which would leave zod uninstalled. zod/shell-quote are pure JS and need no
build, so skipping scripts always recovers the hook. Idempotent: once zod/v3
resolves the heal is a no-op.

Verified on macOS arm64 + Node 26: fresh root with no node_modules recovers
on first run, zod/v3 + shell-quote resolve, second run does zero work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@praxstack praxstack force-pushed the fix/ship-and-harden-plugin-node-modules branch from df172d9 to 287cdd2 Compare June 25, 2026 14:32
@praxstack

Copy link
Copy Markdown
Author

Rebased onto the latest main — this is now mergeable again (the conflict was in scripts/build-hooks.js).

Conflict resolution notes, since both sides touched the same spots:

  • plugin/package.json deps block: kept your newer zod: ^4.4.3 (did not regress it) while preserving this PR's shell-quote dependency + the dependenciesoptionalDependencies split for the tree-sitter grammars.
  • build verification: kept both your new verifyShellTemplateCanonical() Rule-A check and this PR's "verify plugin runtime deps resolve (zod/v3, shell-quote)" hard gate — they're complementary.

Net diff is unchanged in intent (+113/-1 across the same 3 files). Happy to adjust if you'd prefer the runtime-resolve gate folded into the Rule-A verifier instead of standing alongside it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v13.0.0 marketplace install bundle ships without bundled node_modules (zod, shell-quote)

2 participants