@loomhelix/deptrail 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +421 -0
  3. package/dist/actions/action-error.js +11 -0
  4. package/dist/actions/action-jobs.js +199 -0
  5. package/dist/actions/action-service.js +268 -0
  6. package/dist/actions/apply.js +120 -0
  7. package/dist/actions/availability.js +79 -0
  8. package/dist/actions/change-sets.js +117 -0
  9. package/dist/actions/dry-run.js +287 -0
  10. package/dist/actions/edge-moves.js +108 -0
  11. package/dist/actions/edit-plan.js +179 -0
  12. package/dist/actions/file-scope.js +118 -0
  13. package/dist/actions/fix-options.js +141 -0
  14. package/dist/actions/goal-check.js +80 -0
  15. package/dist/actions/install.js +27 -0
  16. package/dist/actions/job-registry.js +104 -0
  17. package/dist/actions/json-edit.js +189 -0
  18. package/dist/actions/labels.js +35 -0
  19. package/dist/actions/node-actions.js +102 -0
  20. package/dist/actions/overrides-location.js +59 -0
  21. package/dist/actions/parent-chain.js +210 -0
  22. package/dist/actions/range-style.js +38 -0
  23. package/dist/actions/recipes.js +31 -0
  24. package/dist/actions/root-files.js +30 -0
  25. package/dist/actions/sandbox.js +175 -0
  26. package/dist/actions/set-range.js +59 -0
  27. package/dist/actions/shared-packages.js +50 -0
  28. package/dist/actions/text-diff.js +203 -0
  29. package/dist/actions/types.js +1 -0
  30. package/dist/actions/update-options.js +232 -0
  31. package/dist/actions/workspace-edit.js +33 -0
  32. package/dist/adapters/command-runner.js +127 -0
  33. package/dist/adapters/manifest-only.js +20 -0
  34. package/dist/adapters/node-modules.js +148 -0
  35. package/dist/adapters/npm-lock.js +149 -0
  36. package/dist/adapters/package-manifest.js +57 -0
  37. package/dist/adapters/pnpm-lock.js +177 -0
  38. package/dist/adapters/resolve-today.js +93 -0
  39. package/dist/cli/cli.js +227 -0
  40. package/dist/cli/open-browser.js +14 -0
  41. package/dist/cli/package-version.js +6 -0
  42. package/dist/cli/usage-text.js +27 -0
  43. package/dist/config/glob.js +119 -0
  44. package/dist/config/npmrc.js +116 -0
  45. package/dist/config/root-config.js +103 -0
  46. package/dist/config/secrets.js +18 -0
  47. package/dist/discovery/find-roots.js +107 -0
  48. package/dist/enrich/credentials.js +143 -0
  49. package/dist/enrich/enrichment-cache.js +42 -0
  50. package/dist/enrich/enrichment-keys.js +71 -0
  51. package/dist/enrich/enrichment-service.js +399 -0
  52. package/dist/enrich/enrichment-status.js +86 -0
  53. package/dist/enrich/enrichment-store.js +143 -0
  54. package/dist/enrich/http.js +201 -0
  55. package/dist/enrich/on-demand.js +220 -0
  56. package/dist/enrich/osv-client.js +260 -0
  57. package/dist/enrich/registry-client.js +123 -0
  58. package/dist/enrich/types.js +1 -0
  59. package/dist/git/blob-reader.js +180 -0
  60. package/dist/git/git-commands.js +32 -0
  61. package/dist/git/git-process.js +71 -0
  62. package/dist/git/git-repository.js +136 -0
  63. package/dist/git/ref-list.js +78 -0
  64. package/dist/git/tree-index.js +87 -0
  65. package/dist/graph/global-index.js +19 -0
  66. package/dist/graph/root-graph-builder.js +114 -0
  67. package/dist/graph/serialize.js +19 -0
  68. package/dist/model.js +2 -0
  69. package/dist/query/children.js +38 -0
  70. package/dist/query/compare-shape.js +13 -0
  71. package/dist/query/compare.js +245 -0
  72. package/dist/query/decorations.js +121 -0
  73. package/dist/query/dependents.js +135 -0
  74. package/dist/query/duplicates.js +23 -0
  75. package/dist/query/enrichment-lookup.js +78 -0
  76. package/dist/query/fixes.js +60 -0
  77. package/dist/query/graph-diff.js +42 -0
  78. package/dist/query/issues.js +235 -0
  79. package/dist/query/lookup.js +49 -0
  80. package/dist/query/node-fix.js +46 -0
  81. package/dist/query/package-insight.js +112 -0
  82. package/dist/query/search.js +61 -0
  83. package/dist/query/subgraph.js +107 -0
  84. package/dist/query/versions.js +103 -0
  85. package/dist/scan/build-root.js +137 -0
  86. package/dist/scan/cache-shape.js +52 -0
  87. package/dist/scan/scan-cache.js +32 -0
  88. package/dist/scan/scan-session.js +127 -0
  89. package/dist/scan/source-registry.js +55 -0
  90. package/dist/server/auth.js +45 -0
  91. package/dist/server/branches.js +105 -0
  92. package/dist/server/error-log.js +9 -0
  93. package/dist/server/root-segment.js +13 -0
  94. package/dist/server/router.js +474 -0
  95. package/dist/server/server.js +166 -0
  96. package/dist/server/source-key.js +1 -0
  97. package/dist/server/static.js +59 -0
  98. package/dist/sources/file-reader.js +55 -0
  99. package/dist/sources/git-reader.js +45 -0
  100. package/dist/ui/assets/index-C4uyB5v3.css +1 -0
  101. package/dist/ui/assets/index-DYAG2LM-.js +51 -0
  102. package/dist/ui/index.html +15 -0
  103. package/package.json +72 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 loomhelix
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,421 @@
1
+ # deptrail
2
+
3
+ deptrail explores the dependency trees of a repository that holds several independent
4
+ JavaScript roots, such as a monorepo with a few pnpm and npm projects side by side. It finds
5
+ every root, parses each one into a normalized dependency graph and serves a local web viewer
6
+ that answers these questions across all roots at once:
7
+
8
+ - Which packages have issues, and what fixes them?
9
+ - Who pulls this package in, and through which direct dependency?
10
+ - What does this package pull in?
11
+
12
+ After the scan, deptrail looks up every package in its registry and in the OSV.dev
13
+ vulnerability database in the background, and the viewer opens on **Issues** for all roots.
14
+ Issues lists the vulnerable and deprecated packages at any depth and the direct dependencies
15
+ with an update, grouped by package or by direct dependency. Under each package a Via line names
16
+ the packages that pull it in, with workspace packages shown as violet pills, here and in
17
+ Explore. Each row acts on exactly one root:
18
+ **Add fix** adds the recommended fix of a package to the changes of that root, and **Update…**
19
+ opens the version choice of a direct dependency. **Add recommended fixes…** adds the
20
+ recommended fix of every vulnerable package to the roots you check.
21
+
22
+ **Changes** lists the changes of each root. deptrail previews them in a temporary copy of the
23
+ root and, only after you confirm, writes them to the repository. It never commits.
24
+
25
+ **Explore** shows the structure of one root in three modes: **Dependents**, a column trail
26
+ from the workspace packages to the selected package; **Dependencies**, a tree; and
27
+ **Graph**, around the package or, with no package selected, of the whole root. Badges mark a vulnerable or deprecated
28
+ package, an update with its size (`↑ 1.1.0 minor`) and, on every ancestor, how many
29
+ vulnerable packages lie below it (`1 vulnerable below · high`). A `dup` badge marks a package
30
+ with several versions in the repository, and **Options** can show only those.
31
+
32
+ In a git repository, the source switcher in the header shows any branch, tag or the last commit
33
+ instead of the working tree, read-only and without checking it out, and **Compare** shows what
34
+ changed in dependencies and vulnerabilities between two of them.
35
+
36
+ The root switcher in the header shows which root every view works on, either one root or All
37
+ roots, and only controls that name a root change it. Global search finds a package by name or
38
+ by `name@range` in every root. The roots panel on the left lists the roots with their health
39
+ and workspace packages, and the details panel on the right says why the selected package is
40
+ there, which advisories affect it and what to do about it: re-lock within the range it asks
41
+ for, update a range in a workspace `package.json`, update parent packages, or override the
42
+ version. Both side panels collapse to give the center more room.
43
+
44
+ ## Usage
45
+
46
+ ```
47
+ npx @loomhelix/deptrail [path] [--port <n>] [--no-open] [--json] [--offline]
48
+ ```
49
+
50
+ After `npm install --global @loomhelix/deptrail`, the same command is `deptrail [path] [options]`.
51
+
52
+ | Argument | Meaning |
53
+ |---|---|
54
+ | `path` | Repository to scan. Defaults to the current directory. |
55
+ | `--port <n>` | Port for the local server. Defaults to a free port. |
56
+ | `--no-open` | Print the viewer URL without opening a browser. |
57
+ | `--json` | Print the normalized scan as JSON to stdout and exit, without starting the server. The output has no registry or vulnerability data, and nothing is sent over the network. |
58
+ | `--offline` | Send no requests to registries or OSV.dev. The viewer shows the dependency trees without registry or vulnerability data, and actions are unavailable. **Resolve versions** still runs the package manager, which uses the network as it is configured to. |
59
+ | `-h`, `--help` | Print the options and environment variables and exit. |
60
+ | `-v`, `--version` | Print the version and exit. |
61
+
62
+ | Variable | Use |
63
+ |---|---|
64
+ | `DEPTRAIL_CACHE_DIR` | Cache directory instead of `~/.cache/deptrail`. |
65
+ | `DEPTRAIL_OSV_URL` | Base URL of an OSV-compatible API, such as a mirror, instead of `https://api.osv.dev`. |
66
+ | `npm_config_registry` | Default registry, as with npm. The name is matched case-insensitively, so `NPM_CONFIG_REGISTRY` works too. |
67
+ | `npm_config_userconfig` | User config file to read instead of `~/.npmrc`. |
68
+
69
+ Without `--json`, deptrail prints `deptrail: <url>`, opens that URL in the default browser and
70
+ keeps serving until it receives Ctrl+C. Errors that happen while serving are printed to stderr
71
+ as one line each, with credentials redacted.
72
+
73
+ ## Keyboard
74
+
75
+ The global keys do nothing while focus is in a text field:
76
+
77
+ | Key | Action |
78
+ |---|---|
79
+ | `⌘K`, `Ctrl+K`, `/` | Focus search |
80
+ | `[` | Collapse or expand the roots panel |
81
+ | `]` | Collapse or expand the details panel |
82
+ | `1` `2` `3` `4` | Go to Issues, Changes, Explore, or Compare |
83
+ | `?` | Show keyboard shortcuts |
84
+ | `Esc` | Close the open menu, popover, or dialog |
85
+
86
+ Issues, Changes, the roots panel, the tree and the trail are one Tab stop each. Inside
87
+ them, ↑ and ↓ move, Home and End jump to the first and last row, ← and → collapse and expand
88
+ (and move between trail columns), Enter selects, and Tab moves into the row's buttons. Add,
89
+ Apply, Revert and Clear write files or change the list, so they run only from their buttons
90
+ and no single key triggers them.
91
+
92
+ ## Roots and sources
93
+
94
+ A root is a directory with its own `pnpm-lock.yaml`, `package-lock.json` or
95
+ `npm-shrinkwrap.json`, a `pnpm-workspace.yaml`, a `package.json` with `workspaces`, or a
96
+ standalone `package.json`. Directories that belong to a workspace of an enclosing root are
97
+ workspace packages of that root, not roots of their own. `node_modules`, `.git` and
98
+ directories ignored by a `.gitignore` are skipped: the one at the top and the ones in
99
+ subdirectories, each applied to its own subtree. deptrail reads plain names (`vendor`),
100
+ directory names (`cache/`) and paths (`/build`, `dev/copy`) and skips lines with wildcards or
101
+ `!`, so a pattern it cannot read shows a directory rather than hides one.
102
+
103
+ Each root is built from the best source available. The roots panel shows a badge on a root
104
+ that was not built from its lockfile:
105
+
106
+ | Badge | Source | Accuracy |
107
+ |---|---|---|
108
+ | none | The root's lockfile | Exact. |
109
+ | `installed` | The root's `node_modules`, when there is no usable lockfile | Exact for the last install in this checkout. |
110
+ | `resolved today` | A lockfile generated in a temporary directory with `pnpm install --lockfile-only` or `npm install --package-lock-only` | Approximate: versions are resolved as of today, not as they were last installed. |
111
+ | `manifest only` | The `package.json` files of the root | Direct dependencies only. |
112
+
113
+ Roots with `resolved today` or `manifest only` show a **Resolve versions** button. It
114
+ copies the root's manifests and configuration (`package.json` files, `.npmrc`,
115
+ `pnpm-workspace.yaml`) into a temporary directory, runs the package manager there with
116
+ `--ignore-scripts` to generate a lockfile without installing packages, rebuilds the root from
117
+ that lockfile and deletes the temporary directory. When the package manager is missing, fails
118
+ or runs longer than five minutes, the root falls back to `manifest only` and its warnings
119
+ explain why. Stopping deptrail with Ctrl+C also stops a running package manager and deletes its
120
+ temporary directory.
121
+
122
+ ## Branches and comparison
123
+
124
+ When the scanned directory is inside a git repository, the header has a source switcher to the
125
+ left of the root switcher. It shows the working tree and the checked-out branch, and its menu
126
+ lists the last commit (`HEAD`), local branches, remote-tracking branches and tags, newest first,
127
+ with a filter field. Choosing one shows Issues, Explore, search and details for that commit
128
+ without checking it out: deptrail lists the commit's files with `git ls-tree` and reads them
129
+ with `git cat-file`, finds the roots with the same rules and the same `.gitignore` files, and
130
+ builds each root from its lockfile, or from its `package.json` files when the lockfile is
131
+ missing or does not parse. A commit never reads `node_modules`, files inside a submodule are
132
+ not read (the notice names the submodules it skips), and symlinks in a commit are not followed,
133
+ so a `.npmrc` or `package.json` that is a symlink counts as missing there. **Resolve
134
+ versions** is not offered on a commit: it would run the package manager with that commit's
135
+ `.npmrc` and `pnpm-workspace.yaml`, which a read-only view does not trust. A remote-tracking
136
+ branch shows its state as of the last fetch; deptrail never fetches.
137
+
138
+ The source is part of the page address (`ref`), so a reload or a link opens the same branch,
139
+ and a branch that moved since shows its new commit. While a commit is shown, the switcher is
140
+ highlighted and a notice says which commit it is and that it is read-only. Changes and actions
141
+ apply to the working tree only: **Changes** says so, the action buttons give that reason, and
142
+ **Refresh** offers **Reload branch** instead of **Rescan files**. **Back to working tree** returns.
143
+ Changes added in the working tree stay while you look at a branch. deptrail keeps up to four
144
+ commits open and drops the least recently used one; a page whose commit was dropped reloads
145
+ and opens it again.
146
+
147
+ **Compare** compares the source in the header with a base you pick: by default the remote's
148
+ default branch (`origin/HEAD`), or the working tree when the header already shows that branch.
149
+ **Since merge base** compares with the last common commit of the two instead, so only what the
150
+ branch itself changed shows up. For each root that changed, Compare lists the vulnerable and
151
+ deprecated versions that the target introduces or resolves, the direct dependencies whose
152
+ range changed or that were added or removed in each workspace package, and the package versions
153
+ added, removed, upgraded, downgraded or changed. Package versions and risk are compared only
154
+ when both sides are built from a lockfile or installed `node_modules`; otherwise a note says
155
+ which side is approximate, and only direct dependencies are compared. A root inside a git
156
+ submodule that only one side can read is listed with a note instead of being compared.
157
+ **Swap** exchanges base and target. A package that exists in the target opens in the details panel.
158
+
159
+ ## Actions
160
+
161
+ Every change belongs to one root. The details panel has an **Actions** block for a vulnerable
162
+ package and for a direct dependency of a workspace package. **Add to changes** puts the chosen
163
+ fix or version into the changes of the selected package's root, which the **Changes** view
164
+ lists. Once the change is there, the button reads **✓ In changes · Review** and opens it in
165
+ **Changes**. When the changes hold the same goal with another choice, the panel names that
166
+ choice ("In changes: Override to 1.0.1"), and **Replace in changes** swaps it for the
167
+ one you picked, in place and in one step. When the new choice conflicts with another change,
168
+ the old one stays.
169
+
170
+ In **Issues**, each root of a package has its own row with its own button:
171
+
172
+ - **Add fix** adds the recommended fix to the changes of that row's root only. The
173
+ recommended fix is the same one the details panel recommends. The button then reads
174
+ **✓ In changes** and opens the change in **Changes**.
175
+ - **Update…** selects a direct dependency in that root, opens the details panel and moves
176
+ focus to its version choice.
177
+ - **Add recommended fixes…** asks which roots to cover, adds the recommended fix of every
178
+ vulnerable package to each checked root and reports what it added and skipped per root.
179
+
180
+ **Changes** has a section for each root with changes. **Edit** on a change selects its
181
+ package in that root and opens **Actions** with the change's choice. **Save change** then
182
+ replaces the change in place. **Clear** removes every change of a root at once and offers
183
+ **Undo** for 10 seconds to bring them back. The roots panel shows how many changes each root
184
+ has. Changes live in memory until deptrail stops.
185
+
186
+ Actions are available for a root that scanned from its lockfile, with pnpm or npm on the
187
+ `PATH`, and not with `--offline`. A root may have workspace packages outside its directory
188
+ only when it is a pnpm root, its `pnpm-workspace.yaml` declares them and they lie inside the
189
+ scanned repository. Otherwise actions stay unavailable and the panel gives the reason: an npm
190
+ root with such packages, a package outside the repository, or a lockfile importer that
191
+ `pnpm-workspace.yaml` does not declare. deptrail runs `pnpm --version` or `npm --version` in the root
192
+ directory, so `packageManager` and Corepack apply; otherwise the panel says why actions are
193
+ unavailable. In an npm root, **Override** and a **Re-lock** of a package that another package
194
+ requests need npm 11.2.0 or later, because older npm does not apply `overrides` to an existing
195
+ lockfile. Node.js 22 ships with npm 10, and with it the panel gives this as the reason those
196
+ fixes are unavailable. A safe version is a published version that is not a prerelease and is not
197
+ affected by any advisory of the package.
198
+
199
+ | Action | Offered for | What it writes |
200
+ |---|---|---|
201
+ | **Re-lock** | A vulnerable package when one safe version satisfies every range that requests it | The lockfile only: every locked copy of the affected version moves to the highest such safe version. |
202
+ | **Update parent packages** | A vulnerable package that a parent's range keeps on an affected version | The lockfile, where parents move to versions that accept a safe version, plus the raised range of a direct dependency in each workspace `package.json` that needs it, or its catalog entry in `pnpm-workspace.yaml`. The parent versions that accept a safe version are found in the published manifests, up to five levels above the package. When some ranges already admit a safe version, the chain also re-locks those, and its title starts with **Re-lock and update**. |
203
+ | **Override** | A vulnerable package, as the alternative to the chain | One override `"name@<X": "X"` and the lockfile. `X` is the highest safe version in the current major; when there is none, the lowest safe version above the current one, with the warning "major jump". The override goes where the root already declares overrides, otherwise to `pnpm-workspace.yaml` for pnpm 10 or later, to `pnpm.overrides` in the root `package.json` for older pnpm, and to `overrides` in the root `package.json` for npm. npm does not allow an override of a direct dependency of the root package, so for such a package the panel suggests the update instead. |
204
+ | **Update** | A direct dependency | The version marked **in range**, the highest within the current ranges, is a re-lock and writes only the lockfile. **latest** or any other published version rewrites the range in each selected workspace `package.json`, or the catalog entry, and the lockfile. The range keeps its form (`^2.0.0` becomes `^3.0.0`, `~2.0.0` becomes `~3.0.0`, an exact version stays exact), and any other range becomes `^<version>`. `workspace:`, git, `file:` and `link:` specifiers, URLs, dist-tags and `peerDependencies` are never edited. |
205
+
206
+ An item with the same goal as another item, a fix of the same package version or an update of
207
+ the same package, is refused, and so is an item that sets a range, an override or a re-lock to
208
+ a different value than another item; the message names the other item.
209
+
210
+ **Preview** copies the root's manifests, `.npmrc`, `pnpm-workspace.yaml` and lockfile into a
211
+ temporary directory, and for pnpm also the pnpmfile and the files named in
212
+ `patchedDependencies`. It applies the changes to the copy and runs
213
+ `pnpm install --lockfile-only --ignore-scripts` or
214
+ `npm install --package-lock-only --ignore-scripts --no-audit --no-fund` there. When the root's
215
+ `package.json` names no `packageManager`, the temporary directory pins the version found in the
216
+ root for Corepack, and the preview stops when the copy reports another version. A re-lock adds a
217
+ temporary override for the package manager run and removes it again, so only the lockfile keeps
218
+ the new version. The preview then shows:
219
+
220
+ - whether each goal is met, and for a fix that is not, which package still requires the
221
+ affected version;
222
+ - packages that are new in the root and vulnerable or deprecated;
223
+ - warnings, including `WARN` lines of the package manager, a change of the lockfile format,
224
+ each other parent whose version of a re-locked or overridden package moves (marked
225
+ "downgrade" when it moves down), and the package manager version used;
226
+ - every file that would change with its diff (the lockfile diff loads on demand and stops at
227
+ 2 MB), and the package versions removed, added and changed.
228
+
229
+ Nothing in the repository changes during a preview. Editing the changes or rescanning the
230
+ root makes the preview stale, and only a current preview can be applied.
231
+
232
+ **Apply…** opens a confirmation that lists unmet goals first, then the files to write, and a
233
+ checkbox to run install afterwards: `pnpm install --frozen-lockfile` or `npm ci` in the root
234
+ directory, with the project's own script settings. The checkbox is checked when the root has a
235
+ `node_modules` directory. Apply refuses to write when any file it copied for the preview has
236
+ changed since. It writes exactly the previewed files, each to a temporary file that is renamed
237
+ into place, the lockfile last, and restores the files already written when a write fails. A
238
+ file it cannot restore is named in the error, and **Revert files** restores it later.
239
+ Install runs for at most 15 minutes and its output is shown live. A failed install leaves the
240
+ files written. deptrail then rescans the root, and the applied changes leave the list;
241
+ changes added after the preview stay. The root's section shows the outcome first: the goals
242
+ met and the files written. When install did not run, it says that `node_modules` was not
243
+ updated and which command to run when ready. When the selected package moved to another
244
+ version, the details panel follows it there and says so.
245
+
246
+ A workspace package can belong to more than one root: a package outside this root that
247
+ another root also lists, or a package inside this root that another root lists, for example
248
+ a root elsewhere whose `pnpm-workspace.yaml` points into this one. Only roots built from their
249
+ lockfile count. When a change writes such a package's `package.json`, the other root's
250
+ lockfile does not change. The preview then warns once per such root, names up to three of the
251
+ shared `package.json` files and says to run the package manager's install in that root. The
252
+ Apply dialog marks those files, and the outcome names the roots to update. The install you
253
+ confirm also writes `node_modules` in the packages outside the root, and the dialog says so
254
+ under the checkbox. An apply or revert is refused with a 409 while another root that shares a
255
+ workspace package applies or reverts.
256
+
257
+ **Revert files** restores the files the last apply wrote and removes the ones it created, as
258
+ long as each of them still has the content deptrail wrote. It is available until the next
259
+ apply in that root or until deptrail stops, also when other actions are unavailable, except
260
+ while the root scans. It does not touch `node_modules`. After the revert, the applied changes
261
+ are back in the list in their order, except those that duplicate or conflict with a change
262
+ added since; the outcome names the skipped ones. When install ran during the apply, the
263
+ outcome says that `node_modules` still has the applied versions and which command to run.
264
+
265
+ One preview, apply or revert runs per root at a time. While one runs, **Resolve versions**
266
+ for that root and **Rescan files** are refused. **Cancel** stops a preview at any point and an apply
267
+ while install runs; the files already written stay. Ctrl+C stops a running package manager and
268
+ deletes the temporary directories, and an install that has not started yet does not start. deptrail never commits, never creates branches and never
269
+ runs a package manager in the repository except for the install you confirm.
270
+
271
+ ## Network
272
+
273
+ Unless `--offline` is given, deptrail sends these requests after the scan:
274
+
275
+ | Destination | What is sent |
276
+ |---|---|
277
+ | The package's registry in that root: the scope's registry when `.npmrc` declares one, otherwise the default registry | `GET <registry>/<name>` (`@scope%2fname` for scoped names) for the abbreviated packument, with `If-None-Match` when a cached ETag exists and `Authorization` when credentials for that registry are configured. |
278
+ | OSV.dev (`https://api.osv.dev`, or `DEPTRAIL_OSV_URL`) | `POST /v1/querybatch` with the name and version of every checked package, then `POST /v1/query` with the name of each package that has known vulnerabilities, to load all of its advisories. |
279
+
280
+ - Every request carries `User-Agent: deptrail/<version> node/<Node.js version>` and nothing
281
+ else that identifies you.
282
+ - A scope is private when the user config or the `.npmrc` of any root declares a registry for
283
+ it (`@acme:registry=...`). Packages in private scopes are never sent to OSV.dev, from any
284
+ root, and neither are packages without an exact version (`manifest only`). A root that
285
+ fails to scan contributes no `.npmrc` settings, so no credentials and no private scopes,
286
+ until it scans successfully; a scope that it declared in an earlier successful scan stays
287
+ private while deptrail runs.
288
+ - Dependencies declared with `workspace:`, `file:` or `link:` specs or from git are sent
289
+ nowhere, and the details panel says that they are not checked. Workspace packages and
290
+ placeholders are sent nowhere either. Known limitation: a dependency declared as a direct
291
+ `https://…/x.tgz` URL in an npm lockfile is still treated as a registry package.
292
+ - At most 16 requests run at a time per origin (scheme, host and port). OSV.dev receives up
293
+ to 1000 versions per batch request. A request that fails with a network error, HTTP 429 or
294
+ a 5xx status is retried twice, after 1 s and 4 s, honoring `Retry-After` up to 30 s.
295
+ - HTTP proxies are not supported, nor are custom CA files (`cafile`, `ca`), client
296
+ certificates (`cert`, `key`) and `strict-ssl=false`. TLS uses the Node.js default
297
+ certificate store plus `NODE_EXTRA_CA_CERTS`.
298
+ - When a registry or OSV.dev cannot be reached, the roots panel reports the problem per
299
+ origin, expired cached data is shown with its date, and the rest of the viewer keeps
300
+ working.
301
+ - When a registry answers HTTP 401, its packages that have not been requested yet with the
302
+ same credentials fail without a request, and the roots panel suggests checking the token in
303
+ `.npmrc`. A 403 fails only that package. **Recheck registry and OSV** tries again.
304
+ - **Recheck registry and OSV**, in the **Refresh ▾** menu of the header, treats every cached entry as expired, retries failed lookups
305
+ and reads each root's `.npmrc` again, so a token corrected there takes effect without a
306
+ restart. The user config is read once at startup.
307
+ - With `--offline`, **Resolve versions** still runs the package manager, which uses the
308
+ network as it is configured to.
309
+ - For the parent chain of a fix, actions request the packument of a parent again with the
310
+ declared dependencies of every published version, not only of the versions in use. A
311
+ preview checks the packages it adds with the registry and OSV.dev under the same rules.
312
+ **Preview** and install run the package manager, which uses the network as it is configured
313
+ to.
314
+
315
+ ## Security model
316
+
317
+ - The server binds to `127.0.0.1` only.
318
+ - Every run generates a random token. The URL that deptrail prints and opens carries it once;
319
+ the server then replaces it with an `HttpOnly`, `SameSite=Strict` cookie named
320
+ `deptrail_token_<port>` and removes it from the address bar. Because the cookie name carries
321
+ the port, several deptrail instances can run side by side without signing each other out.
322
+ Requests without the token are rejected.
323
+ - The `Host` header must be `127.0.0.1:<port>` or `localhost:<port>`, which blocks DNS
324
+ rebinding. Every request with a method other than `GET` and `HEAD` must also carry a
325
+ matching `Origin`, and a request body must be JSON (`Content-Type: application/json`) of at
326
+ most 64 KB. `GET` requests never change anything.
327
+ - Every response carries `X-Frame-Options: DENY` and
328
+ `Content-Security-Policy: frame-ancestors 'none'`, so no other page can frame the viewer.
329
+ - Registry settings are read from the root's `.npmrc`, the user config (`~/.npmrc` or
330
+ `npm_config_userconfig`) and the `npm_config_registry` variable. Credentials
331
+ (`//host/:_authToken`, `:_auth`, `:username` with `:_password`, and user info in a registry
332
+ URL) are held in the server's memory and sent only with requests to the registry they
333
+ belong to, matched by host, port and the longest path prefix. On a redirect they are looked
334
+ up again for the new URL, so they never reach another host. Unscoped `_authToken` and
335
+ `_auth` are ignored with a warning, as npm 9 and later do.
336
+ - Credentials never reach the browser, the API, the `--json` output, the cache or the error
337
+ output; known secret values are replaced in error messages, enrichment problems and
338
+ package-manager output, including job logs and previews. The API and the `--json` output
339
+ only report whether a root has registry authentication configured (`hasAuth`). Enrichment problems in the API name only
340
+ the registry origin (`scheme://host[:port]`), never a path or credentials, and the `--json`
341
+ output never contains registry URLs.
342
+ - deptrail writes inside the repository only when you apply previewed changes or revert
343
+ them, and only to manifests, `pnpm-workspace.yaml` and the lockfile: the root's manifests,
344
+ `pnpm-workspace.yaml` and lockfile inside the root directory, and outside it only the
345
+ `package.json` of a pnpm workspace package that the root's `pnpm-workspace.yaml` declares,
346
+ resolved to its real path and required to stay inside that package's directory and the
347
+ scanned repository. It never creates a new file outside the root directory: an outside
348
+ `package.json` is replaced through a temporary file in the same directory that is renamed
349
+ over it. The install you confirm also writes `node_modules` in those packages. The browser sends
350
+ only choices (the root, the package, the option, a version and workspace packages), never
351
+ paths or file contents, and the server derives every edit from its own data. Temporary work
352
+ happens under the system temporary directory, and everything else goes to the cache
353
+ directory described below.
354
+ - A preview copies the root's `.npmrc`, credentials included, into its temporary directory,
355
+ which only your user can read, and deletes the directory when the preview ends or deptrail
356
+ stops. This is the only place where deptrail itself writes credentials to disk.
357
+ - In a preview the package manager always runs with `--ignore-scripts`. For pnpm, the copy
358
+ includes `.pnpmfile.cjs` or the configured pnpmfile: it is project code, and pnpm runs it
359
+ during the preview as it would during install, because without it the lockfile would not
360
+ match a real install. In the repository the package manager runs only for the install you
361
+ confirm, with the project's own script settings.
362
+ - Branches only read git. deptrail runs `git` directly, never through a shell, and only these
363
+ commands: `git --version`, `git rev-parse` (the top of the repository, the scanned
364
+ directory's prefix and `HEAD`), `git symbolic-ref -q HEAD`, `git for-each-ref`, `git ls-tree`,
365
+ `git cat-file --batch` and `git merge-base`. It never fetches, pushes, changes git
366
+ configuration, stashes, checks out, creates worktrees or moves refs. Every environment variable
367
+ whose name starts with `GIT_` is removed, so a variable such as `GIT_DIR` cannot point git at
368
+ another repository, and `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, `GIT_NO_LAZY_FETCH=1`
369
+ and `LC_ALL=C` are set.
370
+ - The browser sends a ref name, which must not start with `-` and must equal a name that
371
+ `git for-each-ref` lists (or `HEAD`). deptrail resolves it to a commit id; only commit ids
372
+ reach git's command line, after `--end-of-options`, and only blob ids reach `cat-file`.
373
+ - A partial clone downloads missing objects when they are read. With git 2.44 or later,
374
+ `GIT_NO_LAZY_FETCH=1` prevents that; with an older git, branches are unavailable in a partial
375
+ clone.
376
+ - On another branch, registry settings come from that branch's `.npmrc` and
377
+ `pnpm-workspace.yaml`, but registry credentials come only from the working tree's `.npmrc`,
378
+ the user configuration and the environment, matched to their registry as described above, so
379
+ a registry that only a branch configures receives no credentials. Lines of a branch's
380
+ `.npmrc` that reference an environment variable (`${...}`) are ignored, so a branch cannot
381
+ put an environment value into a registry address, and the package manager never runs with a
382
+ branch's configuration.
383
+
384
+ ## Cache
385
+
386
+ deptrail caches in `~/.cache/deptrail`, or in `DEPTRAIL_CACHE_DIR` when it is set:
387
+
388
+ | Path | Contents |
389
+ |---|---|
390
+ | `<key>.json` | Parsed roots, keyed by the repository path, the root path and the lockfile contents. |
391
+ | `registry/` | Registry metadata per registry and package name: dist-tags, the version list, deprecation messages and the declared dependency ranges of the versions in use. |
392
+ | `osv/matches/` | The vulnerability ids that OSV.dev reports for each `name@version`. |
393
+ | `osv/packages/` | Every advisory of each package that has known vulnerabilities. |
394
+ | `registry-versions/` | Per registry and package name, the declared dependency ranges of every published version, loaded only for the parent chain of a fix. |
395
+
396
+ Registry and OSV.dev entries expire after 12 hours, so a second run within that time only
397
+ requests what is missing from the cache. An expired registry entry is revalidated with its
398
+ ETag. Files are named by a SHA-256 hash and never contain registry URLs or credentials.
399
+ Metadata of private packages (version lists and deprecation messages) is stored, as npm's own
400
+ cache does. Writes are atomic, and a corrupt entry is ignored and fetched again.
401
+
402
+ ## Requirements
403
+
404
+ - Node.js 22 or later.
405
+ - macOS or Linux. On Windows, deptrail cannot start pnpm or npm yet, so actions and
406
+ **Resolve versions** are unavailable there.
407
+ - pnpm or npm on the `PATH` for actions and for **Resolve versions**; scanning
408
+ lockfiles, `node_modules` and manifests needs neither. Actions use the package manager of
409
+ the root's lockfile. In npm roots, **Override** and most **Re-lock** fixes need npm 11.2.0 or
410
+ later.
411
+ - Network access to the registries and to OSV.dev only for registry and vulnerability data;
412
+ without it, or with `--offline`, the viewer shows the dependency trees without registry or
413
+ vulnerability data. With `--offline`, actions are unavailable.
414
+ - git 2.24 or later on the `PATH` for branches and Compare (2.44 or later in a partial clone).
415
+ Without it, everything else works and the header says "Branches unavailable" with the reason.
416
+
417
+ ## Development
418
+
419
+ `pnpm playground` builds deptrail and starts it on a temporary monorepo with two roots, `shop` (pnpm) and `shop-npm` (npm), backed by a fake npm registry and a fake OSV.dev, so nothing reaches the network. It prints the viewer URL and the path of the temporary monorepo. The monorepo is a git repository: `main` is checked out, the branch `fix/child-lib` fixes the vulnerable `child-lib`, `main` moved on after that branch started, and there are a tag `v1.0.0` and a remote-tracking `origin/main`, so the source switcher and Compare can be tried. It needs pnpm, npm and git on the `PATH`. Press Ctrl+C to stop it and remove everything it created.
420
+
421
+ `pnpm build && DEPTRAIL_WALKTHROUGH=1 pnpm e2e walkthrough.spec.ts` goes through the same monorepo at 1440 × 900 and saves numbered screenshots of each step to `test-results/walkthrough/`. Any other `pnpm e2e` run clears `test-results/`, so run the walkthrough last.
@@ -0,0 +1,11 @@
1
+ export class ActionError extends Error {
2
+ status;
3
+ conflictsWith;
4
+ constructor(status, message, conflictsWith) {
5
+ super(message);
6
+ this.name = 'ActionError';
7
+ this.status = status;
8
+ if (conflictsWith !== undefined)
9
+ this.conflictsWith = conflictsWith;
10
+ }
11
+ }
@@ -0,0 +1,199 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { SEVERITY_ORDER } from "../enrich/types.js";
3
+ import { PLACEHOLDER_PREFIX } from "../model.js";
4
+ import { dependentsSummary } from "../query/dependents.js";
5
+ import { nodeAdvisories, worstSeverity } from "../query/enrichment-lookup.js";
6
+ import { ActionError } from "./action-error.js";
7
+ import { changedSinceWrite, changedSources, PartialWriteError, revertWrittenFiles, RevertConflictError, writePreviewFiles } from "./apply.js";
8
+ import { fileScopeOf } from "./file-scope.js";
9
+ import { INSTALL_FAILURE_PREFIX } from "./labels.js";
10
+ import { runDryRun } from "./dry-run.js";
11
+ import { runFrozenInstall } from "./install.js";
12
+ import { actionsOfNode, nodeLabel, requestedItem } from "./node-actions.js";
13
+ import { sharingRoots } from "./shared-packages.js";
14
+ import { lockfileNameOf, overridesLocationOf } from "./root-files.js";
15
+ const OUT_OF_DATE = 'The preview is out of date; preview again';
16
+ const NOTHING_TO_REVERT = 'Nothing to revert';
17
+ function filesChangedSinceApply(paths) {
18
+ return `Files changed since they were applied: ${paths.join(', ')}`;
19
+ }
20
+ export function currentPreview(changes, rootId, previewId) {
21
+ const record = changes.of(rootId).preview;
22
+ if (record === undefined || record.preview.id !== previewId || changes.isStale(rootId, record))
23
+ throw new ActionError(409, OUT_OF_DATE);
24
+ return record;
25
+ }
26
+ export async function checkPreviewSources(record) {
27
+ const changed = await changedSources(record.scope, record.sources);
28
+ if (changed.length > 0)
29
+ throw new ActionError(409, `Files changed since the preview: ${changed.join(', ')}`);
30
+ }
31
+ export function currentRevert(changes, rootId) {
32
+ const record = changes.of(rootId).revert;
33
+ if (record === undefined)
34
+ throw new ActionError(409, NOTHING_TO_REVERT);
35
+ return record;
36
+ }
37
+ export async function checkWrittenFiles(record) {
38
+ const changed = await changedSinceWrite(record.scope, record.files);
39
+ if (changed.length > 0)
40
+ throw new ActionError(409, filesChangedSinceApply(changed));
41
+ }
42
+ export function sharedRootsOf(files, shared) {
43
+ const unique = new Map();
44
+ for (const file of files)
45
+ for (const sharing of shared.get(file.path) ?? [])
46
+ unique.set(sharing.id, sharing);
47
+ return [...unique.values()].sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
48
+ }
49
+ export function previewJob({ options, changes }, snapshot) {
50
+ return async (context) => {
51
+ context.phase('staging');
52
+ const { root, manager, items } = snapshot;
53
+ const scope = fileScopeOf(options.repoPath, root);
54
+ const [lockfile, overrides] = await Promise.all([lockfileNameOf(scope.rootDir, manager.name), overridesLocationOf(scope.rootDir, manager)]);
55
+ const sharedManifests = sharingRoots(root, options.session.scan.roots);
56
+ const result = await runDryRun({
57
+ previewId: randomUUID(),
58
+ root,
59
+ scope,
60
+ sharedManifests,
61
+ lockfile,
62
+ manager,
63
+ overridesFile: overrides.file,
64
+ items,
65
+ view: options.enrichment,
66
+ assess: (packages) => options.enrichment.assess(root, packages),
67
+ runner: options.runner,
68
+ env: options.env,
69
+ secrets: options.secrets,
70
+ signal: context.signal,
71
+ onPhase: context.phase,
72
+ onLine: context.log,
73
+ now: () => new Date(),
74
+ });
75
+ changes.storePreview(root.id, {
76
+ preview: result.preview,
77
+ graph: root,
78
+ changeRevision: snapshot.changeRevision,
79
+ itemIds: items.map((item) => item.id),
80
+ written: result.written,
81
+ originals: result.originals,
82
+ sources: result.sources,
83
+ lockfileDiff: result.lockfileDiff,
84
+ scope,
85
+ lockfile,
86
+ sharedRoots: sharedRootsOf(result.preview.files, sharedManifests),
87
+ });
88
+ return { previewId: result.preview.id };
89
+ };
90
+ }
91
+ async function writeKeepingUnrestored({ changes }, record) {
92
+ try {
93
+ return await writePreviewFiles(record.scope, record.written, record.lockfile, record.originals);
94
+ }
95
+ catch (failure) {
96
+ if (failure instanceof PartialWriteError)
97
+ changes.storeRevert(record.preview.rootId, { scope: record.scope, files: failure.unrestored, items: [], sharedRoots: record.sharedRoots });
98
+ throw failure;
99
+ }
100
+ }
101
+ function installResult(outcome) {
102
+ if (outcome === undefined)
103
+ return 'not-run';
104
+ return outcome.ok ? 'done' : 'failed';
105
+ }
106
+ async function frozenInstall({ options }, request, context) {
107
+ context.phase('installing');
108
+ return runFrozenInstall({
109
+ manager: request.manager.name,
110
+ rootDir: request.record.scope.rootDir,
111
+ runner: options.runner,
112
+ env: options.env,
113
+ secrets: options.secrets,
114
+ signal: context.signal,
115
+ onLine: context.log,
116
+ });
117
+ }
118
+ function writtenPaths(record, files) {
119
+ const previewed = record.preview.files.map((file) => file.path);
120
+ const written = [...files.hashes.keys()];
121
+ return [...previewed.filter((relativePath) => files.hashes.has(relativePath)), ...written.filter((relativePath) => !previewed.includes(relativePath))];
122
+ }
123
+ export function applyJob(deps, request) {
124
+ return async (context) => {
125
+ const { record } = request;
126
+ const rootId = record.preview.rootId;
127
+ context.phase('writing');
128
+ currentPreview(deps.changes, rootId, record.preview.id);
129
+ await checkPreviewSources(record);
130
+ const files = await writeKeepingUnrestored(deps, record);
131
+ const revert = deps.changes.applied(rootId, record, { scope: record.scope, files });
132
+ const install = request.install && !context.signal.aborted ? await frozenInstall(deps, request, context) : undefined;
133
+ revert.install = installResult(install);
134
+ context.phase('rescanning');
135
+ await deps.options.session.refreshRoot(rootId);
136
+ if (install !== undefined && !install.ok && !install.cancelled)
137
+ throw new Error(`${INSTALL_FAILURE_PREFIX} ${install.reason}`);
138
+ return { goalsMet: record.preview.goals.filter((goal) => goal.met).length, install: revert.install, files: writtenPaths(record, files), ...(record.sharedRoots.length > 0 ? { sharedRoots: record.sharedRoots } : {}) };
139
+ };
140
+ }
141
+ export function revertJob({ options, changes }, rootId, record) {
142
+ return async (context) => {
143
+ context.phase('writing');
144
+ try {
145
+ await revertWrittenFiles(record.scope, record.files);
146
+ }
147
+ catch (failure) {
148
+ if (failure instanceof RevertConflictError)
149
+ throw new ActionError(409, filesChangedSinceApply(failure.paths));
150
+ throw failure;
151
+ }
152
+ changes.reverted(rootId, record);
153
+ const { restored, skipped } = changes.restore(rootId, record.items);
154
+ context.phase('rescanning');
155
+ await options.session.refreshRoot(rootId);
156
+ return { restored, skipped, appliedInstall: record.install ?? 'not-run' };
157
+ };
158
+ }
159
+ function severityRank(view, root, node) {
160
+ const advisories = nodeAdvisories(view, root, node);
161
+ const worst = worstSeverity(advisories.map((advisory) => advisory.severity));
162
+ return worst === undefined ? undefined : SEVERITY_ORDER.indexOf(worst);
163
+ }
164
+ function vulnerableNodes(view, root, kinds) {
165
+ const ranked = [];
166
+ for (const node of root.packages.values()) {
167
+ if (node.id.startsWith(PLACEHOLDER_PREFIX))
168
+ continue;
169
+ const rank = severityRank(view, root, node);
170
+ if (rank === undefined || dependentsSummary(root, node.id, kinds).importers.length === 0)
171
+ continue;
172
+ ranked.push({ node, rank });
173
+ }
174
+ return ranked.sort((left, right) => left.rank - right.rank).map((entry) => entry.node);
175
+ }
176
+ export function collectJob({ options, changes }, request) {
177
+ return async () => {
178
+ const { root, manager, kinds } = request;
179
+ const skipped = [];
180
+ let added = 0;
181
+ for (const node of vulnerableNodes(options.enrichment, root, kinds)) {
182
+ const actions = await actionsOfNode({ repoPath: options.repoPath, enrichment: options.enrichment, root, manager, kinds }, node);
183
+ if (actions.fix?.reason !== undefined) {
184
+ skipped.push({ name: nodeLabel(node), reason: actions.fix.reason });
185
+ continue;
186
+ }
187
+ try {
188
+ changes.add(root.id, requestedItem(root, node, actions, { nodeId: node.id, choice: 'recommended' }));
189
+ added++;
190
+ }
191
+ catch (failure) {
192
+ if (!(failure instanceof ActionError))
193
+ throw failure;
194
+ skipped.push({ name: nodeLabel(node), reason: failure.message });
195
+ }
196
+ }
197
+ return { added, skipped };
198
+ };
199
+ }