@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.
- package/LICENSE +21 -0
- package/README.md +421 -0
- package/dist/actions/action-error.js +11 -0
- package/dist/actions/action-jobs.js +199 -0
- package/dist/actions/action-service.js +268 -0
- package/dist/actions/apply.js +120 -0
- package/dist/actions/availability.js +79 -0
- package/dist/actions/change-sets.js +117 -0
- package/dist/actions/dry-run.js +287 -0
- package/dist/actions/edge-moves.js +108 -0
- package/dist/actions/edit-plan.js +179 -0
- package/dist/actions/file-scope.js +118 -0
- package/dist/actions/fix-options.js +141 -0
- package/dist/actions/goal-check.js +80 -0
- package/dist/actions/install.js +27 -0
- package/dist/actions/job-registry.js +104 -0
- package/dist/actions/json-edit.js +189 -0
- package/dist/actions/labels.js +35 -0
- package/dist/actions/node-actions.js +102 -0
- package/dist/actions/overrides-location.js +59 -0
- package/dist/actions/parent-chain.js +210 -0
- package/dist/actions/range-style.js +38 -0
- package/dist/actions/recipes.js +31 -0
- package/dist/actions/root-files.js +30 -0
- package/dist/actions/sandbox.js +175 -0
- package/dist/actions/set-range.js +59 -0
- package/dist/actions/shared-packages.js +50 -0
- package/dist/actions/text-diff.js +203 -0
- package/dist/actions/types.js +1 -0
- package/dist/actions/update-options.js +232 -0
- package/dist/actions/workspace-edit.js +33 -0
- package/dist/adapters/command-runner.js +127 -0
- package/dist/adapters/manifest-only.js +20 -0
- package/dist/adapters/node-modules.js +148 -0
- package/dist/adapters/npm-lock.js +149 -0
- package/dist/adapters/package-manifest.js +57 -0
- package/dist/adapters/pnpm-lock.js +177 -0
- package/dist/adapters/resolve-today.js +93 -0
- package/dist/cli/cli.js +227 -0
- package/dist/cli/open-browser.js +14 -0
- package/dist/cli/package-version.js +6 -0
- package/dist/cli/usage-text.js +27 -0
- package/dist/config/glob.js +119 -0
- package/dist/config/npmrc.js +116 -0
- package/dist/config/root-config.js +103 -0
- package/dist/config/secrets.js +18 -0
- package/dist/discovery/find-roots.js +107 -0
- package/dist/enrich/credentials.js +143 -0
- package/dist/enrich/enrichment-cache.js +42 -0
- package/dist/enrich/enrichment-keys.js +71 -0
- package/dist/enrich/enrichment-service.js +399 -0
- package/dist/enrich/enrichment-status.js +86 -0
- package/dist/enrich/enrichment-store.js +143 -0
- package/dist/enrich/http.js +201 -0
- package/dist/enrich/on-demand.js +220 -0
- package/dist/enrich/osv-client.js +260 -0
- package/dist/enrich/registry-client.js +123 -0
- package/dist/enrich/types.js +1 -0
- package/dist/git/blob-reader.js +180 -0
- package/dist/git/git-commands.js +32 -0
- package/dist/git/git-process.js +71 -0
- package/dist/git/git-repository.js +136 -0
- package/dist/git/ref-list.js +78 -0
- package/dist/git/tree-index.js +87 -0
- package/dist/graph/global-index.js +19 -0
- package/dist/graph/root-graph-builder.js +114 -0
- package/dist/graph/serialize.js +19 -0
- package/dist/model.js +2 -0
- package/dist/query/children.js +38 -0
- package/dist/query/compare-shape.js +13 -0
- package/dist/query/compare.js +245 -0
- package/dist/query/decorations.js +121 -0
- package/dist/query/dependents.js +135 -0
- package/dist/query/duplicates.js +23 -0
- package/dist/query/enrichment-lookup.js +78 -0
- package/dist/query/fixes.js +60 -0
- package/dist/query/graph-diff.js +42 -0
- package/dist/query/issues.js +235 -0
- package/dist/query/lookup.js +49 -0
- package/dist/query/node-fix.js +46 -0
- package/dist/query/package-insight.js +112 -0
- package/dist/query/search.js +61 -0
- package/dist/query/subgraph.js +107 -0
- package/dist/query/versions.js +103 -0
- package/dist/scan/build-root.js +137 -0
- package/dist/scan/cache-shape.js +52 -0
- package/dist/scan/scan-cache.js +32 -0
- package/dist/scan/scan-session.js +127 -0
- package/dist/scan/source-registry.js +55 -0
- package/dist/server/auth.js +45 -0
- package/dist/server/branches.js +105 -0
- package/dist/server/error-log.js +9 -0
- package/dist/server/root-segment.js +13 -0
- package/dist/server/router.js +474 -0
- package/dist/server/server.js +166 -0
- package/dist/server/source-key.js +1 -0
- package/dist/server/static.js +59 -0
- package/dist/sources/file-reader.js +55 -0
- package/dist/sources/git-reader.js +45 -0
- package/dist/ui/assets/index-C4uyB5v3.css +1 -0
- package/dist/ui/assets/index-DYAG2LM-.js +51 -0
- package/dist/ui/index.html +15 -0
- 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
|
+
}
|