When astro check says a dependency is missing but the real bug is a pnpm override
The error looked like a missing dependency. Astro asked to install @astrojs/check and TypeScript, both already declared in the site package. The fix was not another install. It was removing a global pnpm override that made the Astro language server load an incompatible vscode-languageserver-protocol version.
The misleading part of this bug was the prompt.
Running pnpm --filter @sites/sunstoneapps.com typecheck should have been routine. The script only runs astro check for the Astro site. Instead, Astro stopped and asked to install dependencies:
To continue, Astro requires the following dependency to be installed: @astrojs/check.
Astro will run the following command:
pnpm add @astrojs/check typescript
That message made the first few minutes feel obvious. Maybe the site package declared the dependencies but node_modules was incomplete. Maybe pnpm install had run in production mode. Maybe the hoisted linker had skipped local links. Each of those explanations was plausible enough to waste time.
They were also wrong.
sites/sunstoneapps.com/package.json already had both packages in devDependencies:
{
"devDependencies": {
"@astrojs/check": "^0.9.9",
"typescript": "~6.0.3"
}
}
The lockfile also knew about them. pnpm --filter @sites/sunstoneapps.com list @astrojs/check typescript --depth 0 showed both dependencies. Re-running pnpm install did not change the prompt. Adding the same dependencies again did not change it either.
At that point, the prompt stopped being evidence. It was only the wrapper around the real failure.
Run the underlying binary
The useful move was to call the checker directly:
pnpm --filter @sites/sunstoneapps.com exec astro-check --version
That bypassed Astro’s friendly installer prompt and showed the actual crash:
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './' is not defined by "exports" in .../node_modules/vscode-languageserver-protocol/package.json
Now the problem had a shape. This was not Astro failing to find @astrojs/check. It was @astrojs/check starting, loading its language-server stack, and crashing inside the vscode-languageserver family.
That distinction matters. Installing @astrojs/check again cannot fix a package export mismatch inside a transitive dependency graph.
The stack pointed at vscode-languageserver-protocol, so the next question was simple: which version is installed, and who asked for it?
The installed package was [email protected]. Its package.json had an exports map like this:
{
"exports": {
".": {
"types": "./lib/common/api.d.ts",
"default": "./lib/common/api.js"
},
"./node": {
"types": "./lib/node/main.d.ts",
"node": "./lib/node/main.js"
},
"./browser": {
"types": "./lib/browser/main.d.ts",
"browser": "./lib/browser/main.js"
}
}
}
The failing import was not vscode-languageserver-protocol. It was vscode-languageserver-protocol/ with a trailing slash. That subpath is not exported by 3.18.1, so Node rejects it.
The override was the bug
The next file to inspect was vscode-languageserver itself. @astrojs/check pulls in @astrojs/language-server, which uses [email protected]. That package declares:
{
"dependencies": {
"vscode-languageserver-protocol": "3.17.5"
}
}
But our workspace had this override in pnpm-workspace.yaml:
overrides:
vscode-languageserver-protocol: 3.18.1
That forced [email protected] to use a protocol package version it did not declare. Most of the time, overrides feel harmless when they only bump a transitive package. This one changed an export boundary. The language server still contained code that did:
require("vscode-languageserver-protocol/");
and the forced 3.18.1 package refused that subpath.
The right fix was not to patch Astro, not to pin Node, and not to add another dependency. The right fix was to remove the override and let [email protected] use the version it asked for.
After removing the override and running pnpm install, the lockfile added [email protected] back into the graph for the language server. [email protected] can still exist elsewhere if another package legitimately needs it. The important part is that the Astro checker no longer forces the wrong version into the language-server dependency.
The false Node.js lead
One tempting wrong theory was Node.js version.
The local runtime was Node v25.6.1, and the stack trace came from Node’s package exports resolver. Astro itself supports >=22.12.0, and Node 25 is newer than the supported floor. That made it easy to suspect an unsupported runtime.
The direct error proved otherwise. Node was doing the correct thing: reading the package’s exports map and rejecting an unexported trailing-slash subpath. The package graph was wrong. Node only made the wrong graph visible.
This is the practical lesson: when an error mentions package exports, inspect dependency versions before blaming the runtime. Newer Node versions can expose these problems more clearly, but the fix is often in the package graph.
Why Astro showed the wrong prompt
Astro’s prompt was not lying on purpose. From the outer CLI’s point of view, the check dependency path did not complete successfully. The tool fell back to the common explanation: the check package is missing.
That prompt is useful for a new Astro project. It is less useful in a monorepo where the dependency exists but crashes during load.
The debugging pattern we kept is:
- Confirm the package is declared in the site
package.json. - Confirm pnpm sees it with
pnpm --filter <site> list <pkg> --depth 0. - Run the underlying binary directly with
pnpm --filter <site> exec astro-check --version. - If the binary crashes, debug that stack trace, not the installer prompt.
- Check
pnpm-workspace.yamloverrides before reinstalling packages repeatedly.
That last step is the one we were missing. Overrides are global. A line added for one transitive dependency can quietly affect tooling in another part of the monorepo.
The validation that mattered
After removing the override, the checks became boring again:
pnpm --filter @sites/sunstoneapps.com typecheck
pnpm sites:typecheck
pnpm sites:build
pnpm run format:check
astro check still prints TypeScript deprecation hints for z in content.config.ts. Those are not build failures. The important result was:
0 errors
0 warnings
The three Astro sites typechecked. The three sites built. Formatting still passed.
That final scope matters because the override was global. Fixing sunstoneapps.com alone would not be enough. If the dependency graph changed for all sites, all sites needed validation.
How to avoid chasing the wrong fix
The dangerous part of this failure is that several repairs look responsible on the surface.
Running pnpm install is reasonable once. Running it three times is usually a sign that the hypothesis has stopped paying rent. Adding @astrojs/check again is reasonable if the dependency is absent from package.json. It is noise if the package is already declared, present in the lockfile, and visible to pnpm list.
The same applies to deleting generated folders. We had already seen Astro content-layer cache issues when renaming legal content from .mdx to .md, and astro sync --force was the right answer there. That history made cache a tempting suspect here too. But this failure happened before content validation mattered. The checker crashed while loading its own language-server dependency graph. Clearing .astro would not change a bad vscode-languageserver-protocol export map.
The split we use now is:
- If generated content points at removed files, run
astro sync --forceorastro build --force. - If
astro checksays a dependency is missing, verify declaration and pnpm visibility. - If the direct
astro-checkbinary crashes, debug the stack trace as a package-graph problem. - If the stack mentions
exports, inspect package versions and overrides before changing source code.
That order keeps each fix attached to the failure it can actually solve.
There is also a maintenance angle. Overrides should have owners and expiration dates in practice, even if the file format does not support that metadata. When an override exists only because it once solved a transient issue, it becomes invisible infrastructure. Months later, another tool can inherit it and fail in a place that does not mention the override at all.
In this case, the override lived in the root workspace, while the symptom appeared inside a site package. That distance is what made the prompt believable. Nothing in sites/sunstoneapps.com/package.json looked wrong. The wrong line was one directory tree higher, in a file that affects every project.
The cheap prevention is review discipline: every new override should answer three questions in the same change that introduces it.
- Which package needs this override?
- Which command proves the override is safe for the rest of the workspace?
- What command should fail if the override is removed too early?
If those questions cannot be answered, the override is probably too broad.
The rule we added
The durable rule is narrow:
Do not force vscode-languageserver-protocol through pnpm-workspace.yaml overrides while the repo uses @astrojs/check with vscode-languageserver@9.
If a future package needs 3.18.x, it should be introduced in a way that does not replace vscode-languageserver@9’s declared 3.17.5 dependency. Otherwise astro check can break with:
ERR_PACKAGE_PATH_NOT_EXPORTED
require("vscode-languageserver-protocol/")
That is not a theoretical guardrail. It came from a real failure: the typecheck command looked broken, the build still passed, and the fix was one deleted override plus a regenerated lockfile.
The uncomfortable part is that a global override can feel like maintenance while it is actually changing someone else’s public contract. Package managers make that easy. Language servers are especially sensitive because they sit behind friendly CLI prompts and large dependency trees.
When a typecheck tool says a dependency is missing but the dependency is plainly installed, stop reinstalling. Run the binary, read the stack, and inspect overrides. The shortest fix may be deleting the clever line.