Zero-dependency monorepo tools with npm and shell

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"):

Example dependency chart

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