Fixing 'command not found' for Local Binaries in npm Scripts
A script such as "build": "tsc -p ." works on one machine and fails on another with sh: tsc: command not found — or works through npm run build but not when you type tsc in the terminal. Both behaviours come from the same mechanism: package managers put node_modules/.bin on PATH only while they run a script, and only for the package that owns the binary. This guide explains how that lookup works in single packages and workspaces, why binaries go missing, and how to fix the error without installing tools globally.
Exact symptoms and error messages
The shell reports the missing command, and npm adds its own summary:
> @acme/ui@1.4.0 build
> tsc -p tsconfig.build.json
sh: 1: tsc: not found
npm error Lifecycle script `build` failed with error:
npm error code 127
npm error path /repo/packages/ui
npm error workspace @acme/ui@1.4.0
npm error location /repo/packages/ui
npm error command failed
npm error command sh -c tsc -p tsconfig.build.json
Exit code 127 is the shell's "command not found". On Windows the message reads 'tsc' is not recognized as an internal or external command, operable program or batch file. pnpm prints:
packages/ui build$ tsc -p tsconfig.build.json
packages/ui build: sh: tsc: command not found
ELIFECYCLE Command failed.
A second family of symptoms is the reverse case: a developer types vitest in the terminal and gets command not found, even though npm test runs Vitest fine.
Root cause analysis
When you run npm run <script>, npm builds an environment for the child shell and prepends every node_modules/.bin directory from the package's folder up to the filesystem root onto PATH. pnpm and Yarn do the same. That is why scripts can call tsc, eslint or vitest without a path: the binary is found in .bin. Your interactive shell does not get that PATH change, so the same command is unknown there. How scripts are scoped between root and packages is covered in Root-Level vs Package-Level Scripts.
A binary appears in a .bin directory only if the package that provides it is installed where that directory can see it. So tsc: not found means one of:
- The package is not declared where the script runs. Under pnpm's strict layout,
packages/uionly gets.binlinks for its own dependencies, plus those at the root. If TypeScript is declared only inapps/web,packages/ui's scripts cannot seetsc. - The package is not installed yet. A CI step runs the script before install, or after a production-only install (
npm ci --omit=dev) that skipped the dev dependency providing the binary. - The binary name differs from the package name. TypeScript provides
tsc, nottypescript;@biomejs/biomeprovidesbiome;npm-run-all2providesrun-pandrun-s. - Bin links were not created — installs with
--ignore-scriptsstill create links, but--no-bin-linksor certain filesystem mounts (such as some Docker volume setups on Windows) do not.
Resolution and configuration patch
Declare the tool in the package that runs it — the correct fix in almost every case:
pnpm add -D typescript --filter @acme/ui
# npm workspaces
npm install -D typescript --workspace @acme/ui
Declaring build tools per package keeps each package's build self-describing and lets task runners hash the right inputs. Tools that genuinely run only from the root — a formatter across the whole repository, a release tool — belong in the root devDependencies, and root scripts can call them directly because the root .bin is always on the path.
Find the real binary name when unsure:
node -p "require('typescript/package.json').bin"
# { tsc: './bin/tsc', tsserver: './bin/tsserver' }
Keep dev dependencies for build steps. In a multi-stage Docker build, install everything in the build stage and prune only in the final stage:
FROM node:22-slim AS build
WORKDIR /app
COPY . .
RUN npm ci && npm run build
FROM node:22-slim
WORKDIR /app
COPY /app/dist ./dist
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
CMD ["node", "dist/server.js"]
Run local binaries from your terminal with the package manager's exec command instead of installing globally:
npx tsc --version # npm: uses the local copy if present
pnpm exec tsc --version # pnpm: never downloads, local only
yarn tsc --version # Yarn Berry: runs a workspace binary
pnpm exec is the safest because it fails rather than downloading a random version when the tool is missing. npx falls back to downloading from the registry, which can hide a missing declaration.
Worked example: a library build that only failed under pnpm
A repository migrates from npm workspaces to pnpm. Every library build fails with tsc: not found, while the applications build fine. Under npm, TypeScript was declared only in the root package.json, and npm's hoisting put tsc in the root .bin — visible from every package. Under pnpm the root .bin is still on the path for workspace scripts, so this should have worked; the real difference is that the libraries' build scripts ran through pnpm --filter ./packages/* build from a CI step that had installed only the apps/* subset with --filter. The root dev dependencies were never linked in that job. Declaring TypeScript in each library's devDependencies (with a catalog entry keeping the version aligned) made each build independent of how the job was filtered, and made the task runner's hash for each library include its compiler version.
Scripts that call other scripts
Calling one script from another is another source of confusing path errors. "prebuild": "npm run clean" works, but "build": "clean && tsc" does not, because clean is a script name, not a binary. Use npm run clean && tsc, or a runner such as run-s clean compile from npm-run-all2. In workspaces, calling a script in another package with npm run build --workspace @acme/utils from inside a package script works but bypasses the dependency ordering a task runner would provide; for anything beyond a one-off, move the orchestration to the root as described in Running Workspace Scripts in Topological Order.
Environment variables are the last trap. Scripts run in sh on macOS and Linux and in cmd.exe on Windows by default, so NODE_ENV=production tsc fails on Windows with 'NODE_ENV' is not recognized. Use cross-env, or set npm's script-shell configuration to a POSIX shell for the project.
Install-time scripts see a different world
Lifecycle scripts that run during installation — preinstall, install, postinstall and prepare — run while the tree is still being built. A postinstall in packages/ui that calls tsc can fail with command not found on a fresh clone even though the same command works later, because the package providing tsc may not be linked yet when that particular script runs. pnpm runs dependency lifecycle scripts after linking, but ordering between workspace packages is not guaranteed to match your expectations. The robust pattern is to keep install-time scripts free of build steps: build in an explicit build script that CI and developers run after install, and reserve prepare for lightweight tasks such as installing git hooks. The trade-offs between these hooks are covered in Choosing Between prepare, prepack and prepublishOnly.
CLI validation and debug commands
# Which bin links exist for this package?
ls packages/ui/node_modules/.bin/ | head
ls node_modules/.bin/ | grep -E "^(tsc|eslint|vitest)$"
# Print the PATH npm gives scripts
npm run env --workspace @acme/ui | grep ^PATH=
# Which package provides a binary?
pnpm why typescript --filter @acme/ui
# Run the script exactly as CI does
pnpm --filter @acme/ui run build
Prevention and CI/CD guardrails
- Declare every tool a package's scripts call in that package's
devDependencies, or deliberately at the root for repository-wide tools. - Use
pnpm execin documentation and CI rather thannpx, so missing declarations fail instead of silently downloading. - Separate build and runtime installs in Docker; only the final stage omits dev dependencies.
- Avoid shell-specific syntax in scripts, or pin
script-shellfor the project.
Frequently Asked Questions
Why does npm run work but typing the command does not?
Because npm adds node_modules/.bin to PATH only for the child process running the script. Your terminal's PATH is unchanged. Use npx, pnpm exec or yarn to run the local binary.
Should I install TypeScript globally to fix this? No. A global install gives every project the same compiler version regardless of what it pins, and CI will not have it. Declare it locally and call it through scripts or exec commands.
Why does the root .bin work for some packages but not others?
The root .bin is on the path for scripts in every workspace package, but only if root dependencies were installed. Filtered installs and sparse CI checkouts can skip them, which is why per-package declarations are more robust.
What does exit code 127 mean?
It is the POSIX shell's code for "command not found". Any script failing with 127 has a PATH or install problem rather than a problem inside the tool.
Related
- Root-Level vs Package-Level Scripts explains how scripts are scoped in workspaces.
- Fixing 'npm run: script not found' in a Workspace covers the neighbouring error for missing script names.
- Fixing Phantom Dependencies After Switching to pnpm is the import-level version of the same strictness.
- Adding a bin Field for CLI Packages shows how binaries get into
.binin the first place.