While exploring the npm documentation, I discovered this underrated gem: npm query. npm query is essentially a thin front-end for arborist that lets you treat dependencies like CSS selectors and query them using a fairly modern selector engine with support for the :has
pseudo-class. You can reference a dependency as a CSS id (for example, #lodash
or #[email protected]
), use classes to select dev, prod, or peer dependencies (.dev
, .prod
, and .peer
respectively), or workspace packages (.workspace
). You can also select version ranges and use attributes to target manifest fields (for example, [license=MIT]
or [repository^=https://github.com]
). The possibilities are many; check out the example-driven documentation here.
Where npm query
really shines is with monorepos. NPM provides workspaces out of the box, which is a set of low-level primitives for managing multiple, interlinked packages in a single repository. Higher-level tools exist to manage workspaces in more complex ways: for example, verifying consistency between dependencies of individual packages, or running the minimum number of builds in dependency order to fully rebuild a given package. These tools are complex and highly optimized; their semantics differ substantially, and some are VC-backed with subscriptions and paid extensions. I always like to find zero-dependency workflows and start small. It would be great if Deno or Bun.sh supported the query
command too, but I suspect it's possible to use npm query
on any well-formed directory containing package.json
and node_modules
(I haven't tried this, so take it with a grain of salt).
This article is a collection of npm query
zsh snippets that I use to query the dependency graph in a simple monorepo.
Let's start with an example monorepo defined by this package.json workspaces configuration:
"workspaces": [
"components/button",
"components/card",
"utils/hooks",
"utils/logger",
"unrelated/dep-a",
"unrelated/dep-b"
]
on this directory tree:
.
├── components
│ ├── button
│ └── card
│
├── unrelated
│ ├── dep-a
│ └── dep-b
│
└── utils
├── hooks
└── logger
that corresponds to this dependency graph (directed arrow from A to B means "A depends on B"):
1) Find the root path of the current monorepo
This is the simplest example, quite useful when scripting: find the :root
workspace and extract its path
attribute with jq.
npm query ":root" | jq '.[].path'
Result
/path/to/workspace/root
2) Find all workspace packages in the monorepo
This gets all the packages declared by workspaces
and outputs an array of name@version
strings.
npm query ".workspace" | jq 'map("\(.name)@\(.version)")'
Result
[
"@ui/[email protected]",
"@ui/[email protected]",
"@unrelated/[email protected]",
"@unrelated/[email protected]",
"@utils/[email protected]",
"@utils/[email protected]"
]
3) Find all the workspace packages that depend on other workspace packages
This is a bit more complex: find all workspaces with at least one direct dependency that is itself a workspace package, using :has
and the child combinator >
. Useful for finding interconnected packages.
npm query ".workspace:has(> .workspace)" | jq 'map("\(.name)@\(.version)")'
Result
[
"@ui/[email protected]",
"@ui/[email protected]",
"@unrelated/[email protected]",
"@utils/[email protected]"
]
4) Find all workspace packages that don't depend on other workspace packages
This is the inverse of the previous snippet. Find all the workspaces that don't depend on any other workspace: the leaves of the dependency graph.
npm query ".workspace:not(:has(> .workspace))" | jq 'map("\(.name)@\(.version)")'
Result
["@unrelated/[email protected]", "@utils/[email protected]"]
5) Find all the direct workspace dependencies of a set of packages
Find all the immediate workspace dependencies: What other workspaces do these workspace(s) directly depend on?
npm query ":is(#@utils/hooks, #@ui/card) > .workspace" | jq 'map("\(.name)@\(.version)")'
Result
["@utils/[email protected]", "@utils/[email protected]"]
6) Find all the transitive workspace dependencies of a set of packages
Find all the transitive workspace dependencies: What other workspaces do these workspaces directly or indirectly depend on? This means the workspace's dependencies plus the dependencies' dependencies and so on, until the leaves.
npm query ":is(#@ui/card, #@unrelated/dep-a) .workspace" | jq 'map("\(.name)@\(.version)")'
Result
["@unrelated/[email protected]", "@utils/[email protected]", "@utils/[email protected]"]
7) Find all packages that directly depend on a set of packages
Finding dependencies of a set of packages is useful, but what about the ancestors - the packages that depend on a given set of packages? This command finds the immediate ancestors.
npm query ".workspace:has(:is(* > #@unrelated/dep-b, * > #@utils/logger))" | jq 'map("\(.name)@\(.version)")'
Result
["@unrelated/[email protected]", "@utils/[email protected]"]
8) Find all packages that transitively depend on a set of packages
Like the above, but wider: find the workspace packages that depend on this set of workspace packages, then find the packages that depend on them, and so on, until you hit the roots.
npm query ".workspace:has(:is(* #@utils/logger, * #@unrelated/dep-b))" | jq 'map("\(.name)@\(.version)")'
Result
[
"@ui/[email protected]",
"@ui/[email protected]",
"@unrelated/[email protected]",
"@utils/[email protected]"
]
9) Build all the dependencies of a workspace package, in the right order
Given all these snippets, here's a zsh script that uses some of them to build all the dependencies of a workspace package in dependency order.
This is actually a common problem when you're working on a workspace package in a reasonably sized monorepo and you pull a new version from git; you know some of the workspace packages have been updated, and you need to build them in the right order to rebuild the package you're working on. To do that, you want to build all the package's workspace dependencies, unless they have dependencies that might have changed in turn; in that case, you want to build those dependencies first, and so on, until you hit the leaves (which can always be built without preconditions). If you don't do this in the right order, you might build one of the packages with an older build version of its dependencies, leading to bugs that are quite annoying to discover.
This script recursively explores the graph of dependencies, using npm query
at each step, and builds the leaves as it discovers them, then proceeds to build their parents and so on, until the whole subgraph is completely (and correctly) built. If you're familiar with rushjs, this is similar to the --to
option (I'm sure there's a similar option in all the modern monorepo management tools). The same principle can be extended to build rules like --from
, or --to-except
, and so on.
#!/usr/bin/env zsh
set -euo pipefail
# Keep track of what’s already built:
typeset -A built
# Keep track of what’s in the current recursion stack (for cycle detection):
typeset -A visiting
# Recursively build $1’s workspace-dependencies first, then $1 itself.
build() {
local pkg="$1"
# If already built, nothing to do
if [[ -n "${built[$pkg]+x}" ]]; then
echo "Skipping already built dependency $pkg"
return
fi
# If we reach this pkg again in the same chain, we’ve got a cycle
if [[ -n "${visiting[$pkg]+x}" ]]; then
echo "Error: Circular dependency detected at '$pkg'" >&2
exit 1
fi
visiting[$pkg]=1
# Fetch immediate workspace dependencies of $pkg
local deps
deps="$(npm query ":is(#${pkg}) > .workspace" | jq -r '.[].name')"
echo "Found dependencies for $pkg: $deps"
# Recurse into each dependency
while IFS= read -r dep; do
[[ -z "$dep" ]] && continue
build "$dep"
done <<< "$deps"
# Now that all sub-deps are built, build this one:
echo "Building dependency $pkg"
# Replace the echo with your real build command, e.g.:
# npm run build --workspace="$pkg"
built[$pkg]=1
unset 'visiting[$pkg]'
}
# Entry-point: one argument, the workspace name
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <workspace-name>" >&2
exit 1
fi
build "$1"
> ./build-deps.sh @ui/card
Found dependencies for @ui/card: @utils/hooks
Found dependencies for @utils/hooks: @utils/logger
Found dependencies for @utils/logger:
Building dependency @utils/logger
Building dependency @utils/hooks
Building dependency @ui/card
10) Find all the dependencies that exist in more than one version
This is another common problem in monorepos: dependencies grow separately and, at some point, the same package gets installed in multiple versions. Here's a handy script to find all the dependencies with diverging versions that uses npm query
to print out all the dependencies of all the workspaces, then groups them by name with jq
and uses awk
to find which packages have multiple versions (disclaimer: I hacked together the awk
part, feel free to replace it with your favorite inline interpreter):
npm query ".workspace *" \
| jq -r '.[] | "\(.name) \(.version)"' \
| sort -u \
| awk '{
name = $1
ver = $2
count[name]++
versions[name] = versions[name] " " ver
}
END {
for (pkg in count)
if (count[pkg] > 1)
print pkg ":" versions[pkg]
}'
Result
(assuming that, for example, @ui/button
has a "react": "18.0.0"
dependency and @ui/card
has a "react": "16.8.0"
dependency):
react: 16.8.0 18.0.0