One package name. Every project on your disk. Which copies are a problem.
dex reads every package.json and lockfile on your machine and answers the question no per-project tool can: where is this package, what version is each copy on, which ones have a CVE, and which ones aren't even used?
English | Türkçe
dex is a read-only inspector for the npm projects already sitting on your disk. It is not a package manager. It never installs anything, never resolves a tree, never edits a package.json and never writes a lockfile. It reads what pnpm, npm, yarn and bun have already written, and lets you ask one question across all of your projects at once.
That last part is the whole point:
- npm and pnpm answer about the project you are standing in.
npm audittells you about this one.dex audit -gtells you about all seventy-five. dexis organized around the package, not the project. "Where is express, on what versions, and what is wrong with them" is one command instead of seventy-five.
npm audit answers about one project. But express is not in one project - it is in twelve, on four different versions, and two of them are vulnerable. You find that out by cd-ing into each of them, one at a time, and you don't, so you never find out at all.
The three questions you actually have are the same three every time:
express
↓
which projects have it?
↓
which are on an old version · which have a CVE · which don't even use it
That's one command:
$ dex who express ~/Projects
express 12 projects · latest 5.2.1
5.2.1 ██████████████████████ 5
4.22.1 ██████████████████████ 5
4.19.2 █████░░░░░░░░░░░░░░░░░ 1 CVE-2024-43796 low
4.17.1 █████░░░░░░░░░░░░░░░░░ 1 CVE-2024-29041 +1 more
VULNERABLE 2 projects
acme/cms-develop 4.19.2 CVE-2024-43796 express vulnerable to XSS via response.re… → 4.20.0
globex/globex.com.tr 4.17.1 CVE-2024-29041 Express.js Open Redirect in malformed URLs → 4.19.2
0 declare it · 12 had it pulled in by something else
pulled in by: @nestjs/platform-express (6), @nestjs/core (4), @nestjs/platform-fastify (4)Reading it top to bottom:
- the bars are your version spread. Four versions of one package, which is the normal state of a real disk.
- VULNERABLE is the part you act on: the project, the version it's on, the advisory, and the version that fixes it from where that project is standing.
- the last two lines are the ones people miss. Nobody chose express - NestJS did. You cannot fix a package you never asked for by editing your own
package.json, so the answer has to name what pulled it in.
Built with Rust, so you need a Rust toolchain (rustup.rs). From the repo root:
cargo install --path . # installs `dex` into ~/.cargo/bin
# or, just build it:
cargo build --release # binary at ./target/release/dexNothing else to set up. There is no config file, no daemon, no index to build.
Everything defaults to where you are, like every other tool you own:
cd ~/Projects/api
dex unused # what can I delete from this project?
dex show . # every dependency here, and why each one counts as used
cd ~/Projects
dex ls # what do all of these projects depend on?
dex audit --direct # every CVE under here, in packages I chose myselfThen set up -g once, because the flagship question is global:
export DEX_ROOTS=~/Projects # colon-separated, like PATH. Put it in your shell rc.
dex who express -g # every project on the disk, asked from anywhere| You want to know | Run |
|---|---|
| "That CVE is in the news - am I exposed?" | dex who lodash -g |
| "What can I delete from this project?" | dex unused |
| "Why is this package even installed?" | dex show . |
| "What can I safely bump today?" | dex up |
| "Which of my projects are behind?" | dex outdated -g |
| "Give me every critical finding, for a report" | dex audit -g --json | jq '.[] | select(.severity=="critical")' |
| "Fail the build if anything is vulnerable" | dex audit (exits 1 on findings) |
dex who <package> which projects have it, what version, what's wrong with it
dex ls every package you declared, by how many projects have it
dex audit every known advisory (exits 1 on findings)
dex unused declared and never referenced
dex outdated direct dependencies behind their latest release
dex up move them to the latest release (prints; --yes runs)
dex show <project> one project: every dependency, and why it counts as used
Everything is read-only except dex up --yes, which is the only command in the tool that changes anything, and only when you say so.
| Flag | Where | What it does |
|---|---|---|
-g, --global |
all but show |
look at $DEX_ROOTS instead of here |
--depth N |
all but show |
how many levels down to look for projects (default 4) |
--json |
all but up |
machine-readable output |
--offline |
who |
answer from the disk alone, ask nobody anything |
--direct |
audit |
only packages you declared, skip the tree underneath |
--all |
ls |
count the whole tree, not just what you declared |
--limit N / --min N |
ls |
how many rows (default 40) / minimum projects (default 1) |
--patch / --minor / --breaking |
up |
how far to move. --minor is the default |
--breaking |
outdated |
only the bumps whose author reserved the right to break you |
--yes |
up |
actually run the commands |
explicit paths > -g > the current directory
dex unused- here, and everything below it.dex unused ~/work ~/side- those.dex unused -g- everywhere, meaning$DEX_ROOTS.
$DEX_ROOTS is something you ask for, never something that happens to you. It used to outrank the working directory, which meant that once you set it, dex unused inside one project silently scanned all seventy-five of them and there was no way to ask about the one you were standing in. A tool that ignores your location to go read the whole disk is one you stop trusting to be looking at what you think it is.
$ dex up # patches and minors: the set that cannot break you
acme/backend pnpm · prod
pnpm add axios@^1.18.1 fastify@^5.10.0 @nestjs/core@^11.1.28 …
acme/backend pnpm · dev
pnpm add -D prettier@^3.9.5 jest@^30.4.2 …
32 packages in 1 project · nothing was run
1 of these has uncommitted changes - if the upgrade goes wrong, `git
checkout` takes your own work with it:
acme/backend
to run it: dex up --minor --yesdex up |
patches and minors - the default, and the set that cannot break you |
dex up --patch |
patches only |
dex up --breaking |
everything, including the bumps whose author reserved the right to break you |
dex up --yes |
actually run it |
Two things to know before you use it.
It writes the commands, not the change. The output is pnpm add …, run in your project by the tool that owns your lockfile. Nothing here edits a package.json or touches a lockfile. (Why?)
Peer dependencies are never moved. Bumping one is an API change to your package - it belongs in a deliberate release, not a bulk upgrade.
class-validator 0.14.2 → 0.15.1 breaking
sharp 0.34.5 → 0.35.3 breaking
axios 1.13.2 → 1.18.1 minor
^0.34.5 means >=0.34.5 <0.35.0 to npm: the resolver refuses to cross that line itself, because the spec says anything may change at any time in 0.y.z. Reading 0.34.5 → 0.35.3 as "minor" is arithmetic, not meaning - and it is the reading that gets somebody hurt, because dex up promises the set that cannot break you. Both of those are sitting on a real disk at 0.x, both a "minor" away, and both would have been moved without asking.
dex audit exits 1 when it finds anything, which is what makes it usable in a pre-commit hook or a CI step:
dex audit # fails the step on any finding
dex audit ~/Projects --json | jq '.[] | select(.severity=="critical")'Every command except dex up takes --json.
A package is in use if any of these is true, and each answer says which one:
- something
imports orrequires it (including subpaths:tailwindcss/plugin) - a
package.jsonscript runs its binary ("build": "tsc -p ."→typescript) - anything else runs its binary: a
.huskyhook, a CI job, a Makefile - a config file names it - including eslint's shorthand, where
extends: ["airbnb"]meanseslint-config-airbnb - another package peer-requires it -
@nestjs/coredemandsreflect-metadata,@react-email/renderdemandsreact-dom - it is
@types/*, which tsc picks up off the filesystem and nobody ever imports - it is on the short list of packages a toolchain loads invisibly (
tslib,core-js,@babel/runtime…)
Only when all of that comes back empty is a package reported unused. On a real disk that leaves findings like a NestJS scaffold's nodemon, supertest, ts-loader and tsconfig-paths - declared years ago, referenced nowhere since.
The best finding this tool has is the intersection: a package you never imported, that has a CVE. audit calls those out on their own line, because the fix is deleting one line.
| Package manager | Lockfile | Status |
|---|---|---|
| pnpm | pnpm-lock.yaml |
v9, v6 and v5 - all three key spellings |
| npm | package-lock.json |
v2/v3 fully, v1 without the graph |
| yarn | yarn.lock |
berry and classic |
| bun | bun.lock |
text format, untested against a real file |
| bun | bun.lockb |
cannot - binary, no parser outside bun |
Only npm packages, for now.
Two of the questions have answers that are not on your disk, so exactly two things reach the network:
| What | Where from | Which commands |
|---|---|---|
| Known vulnerabilities | OSV.dev | audit, who |
| Latest version | registry.npmjs.org | outdated, who |
ls, unused and show never touch it, and dex who --offline answers from the disk alone. Nothing is uploaded: OSV is asked about (name, version) pairs, which is the same thing npm audit sends. Your code never leaves the machine, because dex never sends it anywhere.
1. A range is not a version. "express": "^4.18.0" is a wish. The lockfile is the only file that records what a resolver actually chose, and asking OSV about a range is not a weaker answer - it is a wrong one. A project with no lockfile reports ?, never a guess, and it is counted as unexamined rather than clean.
2. "No advisories" from a tool that couldn't read your lockfile is not a clean bill of health. Every gap - a missing lockfile, a bun.lockb, a parse failure - is printed under every audit, including the clean ones. Especially the clean ones.
3. Nothing is called unused until every way it could be in use has been checked. Calling a used package unused costs you a broken build; calling an unused package used costs 40 KB. So the checks run in one direction, and anything unreadable is Unknown - never Unused.
Only dex up --yes changes anything, and what it changes is whatever pnpm add would change. Every other command opens files for reading and prints.
Use both. npm audit is about the project you are in and dex is about all of them. The things dex can answer that a per-project tool structurally cannot: which of your projects share a vulnerable version, which package pulled it in, and which vulnerable packages you never even imported.
75 projects and 40,000 packages take about 0.8 seconds. There is no cached index and no indexing step: every answer is computed from what is on the disk at the moment you ask.
That is a deliberate trade. A cache would make who instant and would also, one day, report a CVE you fixed last week or miss one you installed this morning. A security tool that is sometimes lying is worse than a slow one, and 0.8 seconds is not slow.
Because that isn't the hard part, and doing it halfway is worse than not doing it. A lockfile is a resolved graph, not a list of versions: bumping axios means re-resolving its whole subtree, re-checking every peer constraint, and rewriting integrity hashes. pnpm does that correctly and this tool never could. Hand-editing a lockfile produces a file that looks right and installs a tree nobody has ever tested. So dex writes the command and your package manager writes the change.
Because a tool that says "delete this" without saying how it knows is a tool you have to double-check by hand, which is the work you were trying to avoid. dex show . names the signal for every package: this import, that script, this peer requirement.
Most CVEs are not in the fifteen packages you chose. They are in the nine hundred those fifteen dragged in - and you cannot bump a transitive package, so naming it alone is useless:
cookie 0.4.1 → CVE-2024-47764 moderate · fixed in 0.7.0
acme/backend via express, @nestjs/platform-express
"cookie is vulnerable" is a fact you can do nothing with. "cookie is vulnerable, and express pulls it in" tells you what to bump.
Because it changed its mind twice, and all three are still on real disks: express@4.18.2 (v9), /express@4.18.2 (v6), /express/4.18.2 (v5). The last is the awkward one - it separates name from version with the same / a scope uses, and appends peers with _, so /express/4.18.2_react@18.0.0 read with v9's rules gives you a package called express/4.18.2_react. Both readings are tried and the one whose name could actually be a package name wins. You will never see any of this; it is here so you know it was handled.
The test suite (cargo test) is entirely accuracy cases, each one locking in a mistake this tool must never make again. Five of them, found on real projects on a real disk:
- pnpm writes the peer context into the version.
11.1.24(rxjs@7.8.2)is not a version - npm never coined it and OSV has never heard of it. Left on, every pnpm project reports "no CVEs" forever. reflect-metadataandreact-domare not unused. No source imports either.@nestjs/coreand@react-email/renderpeer-require them. Deleting either breaks the app on boot, and only the lockfile knows.@commitlint/cliis not unused. Nothing imports it, no script runs it, and its own config names a different package. What runs it is a one-line git hook.- "Fixed in" is a question about where you are standing. An advisory spanning three major lines patches each one separately. Taking the lowest fix told ten projects on Next.js 16 that the fix was
15.5.16- not a fix, a downgrade into a different set of advisories. - The npm registry 406s scoped packages when you ask for the abbreviated document on
/latest. Unscoped names are served normally, so it looks like it works - while every@scope/*package silently has no latest version. outdatedprinted "everything is up to date" when npm rate-limited it. Failed lookups were swallowed, a failed lookup produced no row, and no rows read as good news. Rule 2 above, broken by the tool that states it. A registry that cannot be reached now says so, and is kept apart from a package that is merely private - one is news, the other 404s forever and is not.
Two more the tests hold that were never shipped: pnpm v5 keys (/express/4.18.2_react@18.0.0) read with v9's rules invent a package called express/4.18.2_react, and stripping that _ from the whole key instead of just the version turns the real, installed string_decoder@1.3.0 into string.
- npm ecosystem only. No cargo, pip or go yet.
bun.lockbcannot be read at all. It is binary and there is no parser outside bun itself. It is reported as a gap, never as clean.bun.lockhas never been tested against a real file. The parser is written; nobody has handed it one.package-lock.jsonv1 has no graph, so "who pulled this in" is unanswerable for those projects.- A project with no lockfile is
?, not a guess. See rule 1.
- cargo, pip and go: the same three questions, other ecosystems
dex why <package> --in <project>- the full path from your manifest to a transitive package- version drift against your own newest, so it works with no network at all
- a real
bun.lockto test the bun parser against - it is written and has never seen one
MIT