upgrade-interactive 1.0.0 → 1.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/README.md +82 -15
- package/package.json +5 -3
- package/src/cli.js +71 -7
- package/src/components/App.js +238 -98
- package/src/components/OverridePicker.js +44 -0
- package/src/components/Prompt.js +8 -2
- package/src/components/Row.js +80 -11
- package/src/links.js +17 -0
- package/src/lockfile.js +58 -0
- package/src/package-file.js +35 -5
- package/src/registry.js +49 -0
- package/src/vulnerabilities.js +260 -0
package/src/components/App.js
CHANGED
|
@@ -2,40 +2,52 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
|
2
2
|
import { Box, Text, useInput, useApp } from 'ink';
|
|
3
3
|
import { Prompt } from './Prompt.js';
|
|
4
4
|
import { Header } from './Header.js';
|
|
5
|
-
import { Row, LoadingRow } from './Row.js';
|
|
5
|
+
import { Row, VulnRow, OverrideRow, LoadingRow, SectionHeader } from './Row.js';
|
|
6
|
+
import { OverridePicker } from './OverridePicker.js';
|
|
6
7
|
import { fetchSuggestions } from '../semver-suggest.js';
|
|
7
8
|
import { mapWithConcurrency } from '../registry.js';
|
|
9
|
+
import { loadInstalledVersions } from '../lockfile.js';
|
|
10
|
+
import { computeVulnerabilities } from '../vulnerabilities.js';
|
|
8
11
|
|
|
9
12
|
const e = React.createElement;
|
|
10
13
|
const CONCURRENCY = 8;
|
|
14
|
+
// Stable reference so `overrides` defaulting doesn't allocate a fresh object
|
|
15
|
+
// each render — otherwise the audit effect's deps change every commit and it
|
|
16
|
+
// re-runs in an unbounded loop.
|
|
17
|
+
const EMPTY_OVERRIDES = Object.freeze({});
|
|
11
18
|
|
|
12
19
|
function clamp(n, min, max) {
|
|
13
20
|
return Math.max(min, Math.min(max, n));
|
|
14
21
|
}
|
|
15
22
|
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
for (let step = 0; step < entries.length; step++) {
|
|
19
|
-
i += direction;
|
|
20
|
-
if (i < 0 || i >= entries.length) return from;
|
|
21
|
-
if (entries[i] && typeof entries[i] === 'object') return i;
|
|
22
|
-
}
|
|
23
|
-
return from;
|
|
23
|
+
function isNavigable(row) {
|
|
24
|
+
return row.kind === 'dep' || row.kind === 'vuln' || row.kind === 'override';
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
return -1;
|
|
27
|
+
async function defaultRunAudit({ cwd, descriptors, overrides }) {
|
|
28
|
+
const installed = await loadInstalledVersions(cwd);
|
|
29
|
+
return computeVulnerabilities({ descriptors, installed, overrides });
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
export function App({
|
|
32
|
+
export function App({
|
|
33
|
+
descriptors,
|
|
34
|
+
onSubmit,
|
|
35
|
+
onAbort,
|
|
36
|
+
audit = false,
|
|
37
|
+
section = false,
|
|
38
|
+
cwd = process.cwd(),
|
|
39
|
+
overrides = EMPTY_OVERRIDES,
|
|
40
|
+
runAudit = defaultRunAudit,
|
|
41
|
+
}) {
|
|
34
42
|
const { exit } = useApp();
|
|
35
43
|
const [entries, setEntries] = useState(() => descriptors.map(() => null));
|
|
36
44
|
const [allLoaded, setAllLoaded] = useState(descriptors.length === 0);
|
|
37
|
-
const [
|
|
45
|
+
const [focusedKey, setFocusedKey] = useState(null);
|
|
38
46
|
const [selectedColumns, setSelectedColumns] = useState({});
|
|
47
|
+
const [stagedOverrides, setStagedOverrides] = useState({});
|
|
48
|
+
const [stagedRemovals, setStagedRemovals] = useState({}); // { name: true }
|
|
49
|
+
const [auditState, setAuditState] = useState(null); // { offline, vulns, removableOverrides } | null
|
|
50
|
+
const [override, setOverride] = useState(null); // { name, versions } | null
|
|
39
51
|
const mountedRef = useRef(true);
|
|
40
52
|
|
|
41
53
|
useEffect(() => {
|
|
@@ -44,6 +56,7 @@ export function App({ descriptors, onSubmit, onAbort }) {
|
|
|
44
56
|
};
|
|
45
57
|
}, []);
|
|
46
58
|
|
|
59
|
+
// Load upgrade suggestions for each descriptor.
|
|
47
60
|
useEffect(() => {
|
|
48
61
|
if (descriptors.length === 0) return;
|
|
49
62
|
let cancelled = false;
|
|
@@ -73,29 +86,108 @@ export function App({ descriptors, onSubmit, onAbort }) {
|
|
|
73
86
|
};
|
|
74
87
|
}, [descriptors]);
|
|
75
88
|
|
|
76
|
-
//
|
|
89
|
+
// Check installed + range-resolved versions against npm's advisory database.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!audit) return;
|
|
92
|
+
let cancelled = false;
|
|
93
|
+
|
|
94
|
+
Promise.resolve(runAudit({ cwd, descriptors, overrides }))
|
|
95
|
+
.then((res) => {
|
|
96
|
+
if (cancelled || !mountedRef.current) return;
|
|
97
|
+
setAuditState(res || { offline: false, vulns: new Map() });
|
|
98
|
+
})
|
|
99
|
+
.catch(() => {
|
|
100
|
+
if (cancelled || !mountedRef.current) return;
|
|
101
|
+
setAuditState({ offline: true, vulns: new Map() });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return () => {
|
|
105
|
+
cancelled = true;
|
|
106
|
+
};
|
|
107
|
+
}, [audit, cwd, descriptors, overrides, runAudit]);
|
|
108
|
+
|
|
109
|
+
// ---- Build the ordered display list (headers + rows) ----------------------
|
|
110
|
+
const vulns = auditState ? auditState.vulns : null;
|
|
111
|
+
|
|
112
|
+
const depItems = descriptors.map((descriptor, i) => ({ descriptor, entry: entries[i], i }));
|
|
113
|
+
const visibleDeps = allLoaded ? depItems.filter((x) => x.entry !== null) : depItems;
|
|
114
|
+
|
|
115
|
+
const depRow = (x) =>
|
|
116
|
+
x.entry === null
|
|
117
|
+
? { kind: 'loading', key: `loading:${x.i}` }
|
|
118
|
+
: {
|
|
119
|
+
kind: 'dep',
|
|
120
|
+
key: `dep:${x.descriptor.name}`,
|
|
121
|
+
descriptor: x.descriptor,
|
|
122
|
+
entry: x.entry,
|
|
123
|
+
vuln: vulns ? vulns.get(x.descriptor.name) || null : null,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// A vuln shows inline on its dep row when that package has an upgrade row;
|
|
127
|
+
// everything else (transitive deps, or direct deps with no upgrade available)
|
|
128
|
+
// falls through to the Overrides section so it's never silently dropped.
|
|
129
|
+
const shownDepNames = new Set(visibleDeps.filter((x) => x.entry !== null).map((x) => x.descriptor.name));
|
|
130
|
+
const overrideVulns = vulns
|
|
131
|
+
? [...vulns.entries()].filter(([name]) => !shownDepNames.has(name))
|
|
132
|
+
: [];
|
|
133
|
+
const removable = auditState && auditState.removableOverrides ? auditState.removableOverrides : null;
|
|
134
|
+
const removableList = removable ? [...removable.entries()] : [];
|
|
135
|
+
|
|
136
|
+
const rows = [];
|
|
137
|
+
const pushOverrides = () => {
|
|
138
|
+
if (overrideVulns.length === 0 && removableList.length === 0) return;
|
|
139
|
+
rows.push({ kind: 'header', key: 'h:overrides', title: 'Overrides' });
|
|
140
|
+
for (const [name, vuln] of overrideVulns) {
|
|
141
|
+
rows.push({ kind: 'vuln', key: `vuln:${name}`, name, vuln });
|
|
142
|
+
}
|
|
143
|
+
for (const [name, info] of removableList) {
|
|
144
|
+
rows.push({ kind: 'override', key: `ovr:${name}`, name, pin: info.pin, reason: info.reason });
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (section) {
|
|
149
|
+
const deps = visibleDeps.filter((x) => x.descriptor.field === 'dependencies');
|
|
150
|
+
const dev = visibleDeps.filter((x) => x.descriptor.field === 'devDependencies');
|
|
151
|
+
if (deps.length > 0) {
|
|
152
|
+
rows.push({ kind: 'header', key: 'h:deps', title: 'Dependencies' });
|
|
153
|
+
for (const x of deps) rows.push(depRow(x));
|
|
154
|
+
}
|
|
155
|
+
if (dev.length > 0) {
|
|
156
|
+
rows.push({ kind: 'header', key: 'h:dev', title: 'Dev dependencies' });
|
|
157
|
+
for (const x of dev) rows.push(depRow(x));
|
|
158
|
+
}
|
|
159
|
+
pushOverrides();
|
|
160
|
+
} else {
|
|
161
|
+
for (const x of visibleDeps) rows.push(depRow(x));
|
|
162
|
+
pushOverrides();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const navKeys = rows.filter(isNavigable).map((r) => r.key);
|
|
166
|
+
const navKeyStr = navKeys.join('|');
|
|
167
|
+
const focusedRow = rows.find((r) => r.key === focusedKey) || null;
|
|
168
|
+
|
|
169
|
+
// Keep focus on a navigable row as things load in / vulns arrive.
|
|
77
170
|
useEffect(() => {
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}, [
|
|
171
|
+
if (focusedKey && navKeys.includes(focusedKey)) return;
|
|
172
|
+
setFocusedKey(navKeys[0] ?? null);
|
|
173
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
174
|
+
}, [navKeyStr, focusedKey]);
|
|
82
175
|
|
|
83
176
|
const cycleColumn = useCallback(
|
|
84
177
|
(direction) => {
|
|
85
|
-
if (
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
const name = entry.descriptor.name;
|
|
178
|
+
if (!focusedRow || focusedRow.kind !== 'dep') return;
|
|
179
|
+
const { suggestions } = focusedRow.entry;
|
|
180
|
+
const name = focusedRow.descriptor.name;
|
|
89
181
|
const current = selectedColumns[name] ?? 0;
|
|
90
182
|
let next = current;
|
|
91
|
-
for (let step = 0; step <
|
|
92
|
-
next = clamp(next + direction, 0,
|
|
93
|
-
if (
|
|
183
|
+
for (let step = 0; step < suggestions.length; step++) {
|
|
184
|
+
next = clamp(next + direction, 0, suggestions.length - 1);
|
|
185
|
+
if (suggestions[next].spans.length > 0 || next === 0) break;
|
|
94
186
|
if (next === current) break;
|
|
95
187
|
}
|
|
96
188
|
setSelectedColumns((prev) => ({ ...prev, [name]: next }));
|
|
97
189
|
},
|
|
98
|
-
[
|
|
190
|
+
[focusedRow, selectedColumns]
|
|
99
191
|
);
|
|
100
192
|
|
|
101
193
|
const bulkSelect = useCallback(
|
|
@@ -105,13 +197,9 @@ export function App({ descriptors, onSubmit, onAbort }) {
|
|
|
105
197
|
for (const entry of entries) {
|
|
106
198
|
if (!entry) continue;
|
|
107
199
|
const { name } = entry.descriptor;
|
|
108
|
-
if (which === 'c')
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
next[name] = 1;
|
|
112
|
-
} else if (which === 'l') {
|
|
113
|
-
next[name] = entry.suggestions[2].value != null ? 2 : 1;
|
|
114
|
-
}
|
|
200
|
+
if (which === 'c') next[name] = 0;
|
|
201
|
+
else if (which === 'r') next[name] = 1;
|
|
202
|
+
else if (which === 'l') next[name] = entry.suggestions[2].value != null ? 2 : 1;
|
|
115
203
|
}
|
|
116
204
|
return next;
|
|
117
205
|
});
|
|
@@ -119,55 +207,72 @@ export function App({ descriptors, onSubmit, onAbort }) {
|
|
|
119
207
|
[entries]
|
|
120
208
|
);
|
|
121
209
|
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
210
|
+
const openOverride = useCallback(() => {
|
|
211
|
+
if (!audit || !focusedRow) return;
|
|
212
|
+
if (focusedRow.kind !== 'dep' && focusedRow.kind !== 'vuln') return;
|
|
213
|
+
const vuln = focusedRow.vuln;
|
|
214
|
+
if (!vuln || !vuln.safeVersions || vuln.safeVersions.length === 0) return;
|
|
215
|
+
const name = focusedRow.kind === 'dep' ? focusedRow.descriptor.name : focusedRow.name;
|
|
216
|
+
setOverride({ name, versions: vuln.safeVersions });
|
|
217
|
+
}, [audit, focusedRow]);
|
|
218
|
+
|
|
219
|
+
const toggleRemoval = useCallback(() => {
|
|
220
|
+
if (!audit || !focusedRow || focusedRow.kind !== 'override') return;
|
|
221
|
+
const { name } = focusedRow;
|
|
222
|
+
setStagedRemovals((prev) => {
|
|
223
|
+
const next = { ...prev };
|
|
224
|
+
if (next[name]) delete next[name];
|
|
225
|
+
else next[name] = true;
|
|
226
|
+
return next;
|
|
227
|
+
});
|
|
228
|
+
}, [audit, focusedRow]);
|
|
229
|
+
|
|
230
|
+
const moveFocus = useCallback(
|
|
231
|
+
(direction) => {
|
|
232
|
+
setFocusedKey((cur) => {
|
|
233
|
+
const idx = navKeys.indexOf(cur);
|
|
234
|
+
if (idx === -1) return navKeys[0] ?? null;
|
|
235
|
+
const nextIdx = idx + direction;
|
|
236
|
+
if (nextIdx < 0 || nextIdx >= navKeys.length) return cur;
|
|
237
|
+
return navKeys[nextIdx];
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
[navKeyStr] // eslint-disable-line react-hooks/exhaustive-deps
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
useInput(
|
|
244
|
+
(input, key) => {
|
|
245
|
+
if ((key.ctrl && input === 'c') || key.escape) {
|
|
246
|
+
onAbort();
|
|
247
|
+
exit();
|
|
248
|
+
return;
|
|
160
249
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
250
|
+
if (key.upArrow) return moveFocus(-1);
|
|
251
|
+
if (key.downArrow) return moveFocus(1);
|
|
252
|
+
if (key.leftArrow) return cycleColumn(-1);
|
|
253
|
+
if (key.rightArrow) return cycleColumn(1);
|
|
254
|
+
if (input === 'o') return openOverride();
|
|
255
|
+
if (input === 'x') return toggleRemoval();
|
|
256
|
+
if (input === 'c' || input === 'r' || input === 'l') return bulkSelect(input);
|
|
257
|
+
if (key.return) {
|
|
258
|
+
const selections = new Map();
|
|
259
|
+
for (const entry of entries) {
|
|
260
|
+
if (!entry) continue;
|
|
261
|
+
const col = selectedColumns[entry.descriptor.name] ?? 0;
|
|
262
|
+
const value = entry.suggestions[col]?.value ?? null;
|
|
263
|
+
if (value) selections.set(entry.descriptor.name, value);
|
|
264
|
+
}
|
|
265
|
+
const removals = Object.keys(stagedRemovals).filter((name) => stagedRemovals[name]);
|
|
266
|
+
onSubmit(selections, { ...stagedOverrides }, removals);
|
|
267
|
+
exit();
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
{ isActive: override == null }
|
|
271
|
+
);
|
|
165
272
|
|
|
166
|
-
const
|
|
167
|
-
? entries.map((_, i) => i).filter((i) => entries[i] !== null)
|
|
168
|
-
: entries.map((_, i) => i);
|
|
273
|
+
const auditDone = !audit || auditState !== null;
|
|
169
274
|
|
|
170
|
-
if (allLoaded &&
|
|
275
|
+
if (allLoaded && auditDone && rows.length === 0) {
|
|
171
276
|
return e(
|
|
172
277
|
Box,
|
|
173
278
|
{ flexDirection: 'column' },
|
|
@@ -178,30 +283,65 @@ export function App({ descriptors, onSubmit, onAbort }) {
|
|
|
178
283
|
}
|
|
179
284
|
|
|
180
285
|
const termRows = (process.stdout && process.stdout.rows) || 24;
|
|
181
|
-
const maxRows = Math.max(5, termRows -
|
|
182
|
-
const
|
|
183
|
-
let windowStart = clamp(
|
|
184
|
-
const windowEnd = Math.min(
|
|
185
|
-
const visible =
|
|
286
|
+
const maxRows = Math.max(5, termRows - 12);
|
|
287
|
+
const focusedIndex = Math.max(0, rows.findIndex((r) => r.key === focusedKey));
|
|
288
|
+
let windowStart = clamp(focusedIndex - Math.floor(maxRows / 2), 0, Math.max(0, rows.length - maxRows));
|
|
289
|
+
const windowEnd = Math.min(rows.length, windowStart + maxRows);
|
|
290
|
+
const visible = rows.slice(windowStart, windowEnd);
|
|
186
291
|
|
|
187
292
|
return e(
|
|
188
293
|
Box,
|
|
189
294
|
{ flexDirection: 'column' },
|
|
190
|
-
e(Prompt,
|
|
295
|
+
e(Prompt, { audit }),
|
|
191
296
|
e(Header, null),
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
297
|
+
audit && auditState && auditState.offline
|
|
298
|
+
? e(Text, { color: 'yellow' }, " ℹ no network — couldn't check for vulnerable packages")
|
|
299
|
+
: null,
|
|
300
|
+
windowStart > 0 ? e(Text, { dimColor: true }, ` ↑ ${windowStart} more above`) : null,
|
|
301
|
+
...visible.map((row) => {
|
|
302
|
+
if (row.kind === 'header') return e(SectionHeader, { key: row.key, title: row.title });
|
|
303
|
+
if (row.kind === 'loading') return e(LoadingRow, { key: row.key });
|
|
304
|
+
if (row.kind === 'vuln') {
|
|
305
|
+
return e(VulnRow, {
|
|
306
|
+
key: row.key,
|
|
307
|
+
name: row.name,
|
|
308
|
+
active: row.key === focusedKey,
|
|
309
|
+
vuln: row.vuln,
|
|
310
|
+
override: stagedOverrides[row.name],
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
if (row.kind === 'override') {
|
|
314
|
+
return e(OverrideRow, {
|
|
315
|
+
key: row.key,
|
|
316
|
+
name: row.name,
|
|
317
|
+
active: row.key === focusedKey,
|
|
318
|
+
pin: row.pin,
|
|
319
|
+
reason: row.reason,
|
|
320
|
+
staged: !!stagedRemovals[row.name],
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
const col = selectedColumns[row.descriptor.name] ?? 0;
|
|
197
324
|
return e(Row, {
|
|
198
|
-
key:
|
|
199
|
-
name:
|
|
200
|
-
active:
|
|
201
|
-
suggestions: entry.suggestions,
|
|
325
|
+
key: row.key,
|
|
326
|
+
name: row.descriptor.name,
|
|
327
|
+
active: row.key === focusedKey,
|
|
328
|
+
suggestions: row.entry.suggestions,
|
|
202
329
|
selectedColumn: col,
|
|
330
|
+
vuln: row.vuln,
|
|
331
|
+
override: stagedOverrides[row.descriptor.name],
|
|
203
332
|
});
|
|
204
333
|
}),
|
|
205
|
-
windowEnd <
|
|
334
|
+
windowEnd < rows.length ? e(Text, { dimColor: true }, ` ↓ ${rows.length - windowEnd} more below`) : null,
|
|
335
|
+
override
|
|
336
|
+
? e(OverridePicker, {
|
|
337
|
+
name: override.name,
|
|
338
|
+
versions: override.versions,
|
|
339
|
+
onSelect: (version) => {
|
|
340
|
+
setStagedOverrides((prev) => ({ ...prev, [override.name]: version }));
|
|
341
|
+
setOverride(null);
|
|
342
|
+
},
|
|
343
|
+
onCancel: () => setOverride(null),
|
|
344
|
+
})
|
|
345
|
+
: null
|
|
206
346
|
);
|
|
207
347
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
|
|
4
|
+
const e = React.createElement;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A small overlay for choosing which safe version to pin a vulnerable package
|
|
8
|
+
* to via npm `overrides`. ↑/↓ move, Enter selects, Esc cancels.
|
|
9
|
+
*/
|
|
10
|
+
export function OverridePicker({ name, versions, onSelect, onCancel }) {
|
|
11
|
+
const [index, setIndex] = useState(0);
|
|
12
|
+
|
|
13
|
+
useInput((input, key) => {
|
|
14
|
+
if (key.escape) {
|
|
15
|
+
onCancel();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (key.upArrow) {
|
|
19
|
+
setIndex((i) => Math.max(0, i - 1));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (key.downArrow) {
|
|
23
|
+
setIndex((i) => Math.min(versions.length - 1, i + 1));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (key.return) {
|
|
27
|
+
onSelect(versions[index]);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return e(
|
|
32
|
+
Box,
|
|
33
|
+
{ flexDirection: 'column', marginTop: 1, borderStyle: 'round', paddingX: 1 },
|
|
34
|
+
e(Text, { bold: true }, 'Override ', e(Text, { color: 'cyanBright' }, name), ' to a safe version:'),
|
|
35
|
+
...versions.map((v, i) =>
|
|
36
|
+
e(
|
|
37
|
+
Box,
|
|
38
|
+
{ key: v },
|
|
39
|
+
e(Text, { color: i === index ? 'greenBright' : undefined }, i === index ? '❯ ' : ' ', v)
|
|
40
|
+
)
|
|
41
|
+
),
|
|
42
|
+
e(Box, { marginTop: 1 }, e(Text, { dimColor: true }, '↑/↓ choose · <enter> apply · <esc> cancel'))
|
|
43
|
+
);
|
|
44
|
+
}
|
package/src/components/Prompt.js
CHANGED
|
@@ -7,7 +7,7 @@ function Key({ children }) {
|
|
|
7
7
|
return e(Text, { bold: true, color: 'cyanBright' }, children);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export function Prompt() {
|
|
10
|
+
export function Prompt({ audit = false } = {}) {
|
|
11
11
|
return e(
|
|
12
12
|
Box,
|
|
13
13
|
{ flexDirection: 'row' },
|
|
@@ -50,7 +50,13 @@ export function Prompt() {
|
|
|
50
50
|
Box,
|
|
51
51
|
{ flexDirection: 'column' },
|
|
52
52
|
e(Box, { marginLeft: 1 }, e(Text, null, 'Press ', e(Key, null, '<enter>'), ' to install.')),
|
|
53
|
-
e(Box, { marginLeft: 1 }, e(Text, null, 'Press ', e(Key, null, '<ctrl+c>'), ' to abort.'))
|
|
53
|
+
e(Box, { marginLeft: 1 }, e(Text, null, 'Press ', e(Key, null, '<ctrl+c>'), ' to abort.')),
|
|
54
|
+
audit
|
|
55
|
+
? e(Box, { marginLeft: 1 }, e(Text, null, 'Press ', e(Key, null, 'o'), ' to override a vulnerable package.'))
|
|
56
|
+
: null,
|
|
57
|
+
audit
|
|
58
|
+
? e(Box, { marginLeft: 1 }, e(Text, null, 'Press ', e(Key, null, 'x'), ' to remove an unneeded override.'))
|
|
59
|
+
: null
|
|
54
60
|
)
|
|
55
61
|
);
|
|
56
62
|
}
|
package/src/components/Row.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { hyperlink } from '../links.js';
|
|
4
|
+
import { SEVERITY } from '../vulnerabilities.js';
|
|
3
5
|
|
|
4
6
|
const e = React.createElement;
|
|
5
7
|
|
|
@@ -16,29 +18,96 @@ function Column({ suggestion, selected }) {
|
|
|
16
18
|
const hasContent = suggestion && suggestion.spans.length > 0;
|
|
17
19
|
return e(
|
|
18
20
|
Box,
|
|
19
|
-
{ width: 17 },
|
|
21
|
+
{ width: 17, flexShrink: 0 },
|
|
20
22
|
hasContent
|
|
21
|
-
? e(Text, null, selected ? '
|
|
22
|
-
: e(Text, { dimColor: true }, selected ? '
|
|
23
|
+
? e(Text, null, selected ? '● ' : '○ ', e(Spans, { spans: suggestion.spans, inverse: selected }))
|
|
24
|
+
: e(Text, { dimColor: true }, selected ? '●' : '')
|
|
23
25
|
);
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
// The ⚠ + severity + CVE link + affected/fixed-in summary shown on a flagged row.
|
|
29
|
+
function VulnInfo({ vuln, override }) {
|
|
30
|
+
const sev = SEVERITY[vuln.severity] || SEVERITY.low;
|
|
31
|
+
let text = `⚠ ${sev.label} ${hyperlink(vuln.cve, vuln.url)} — affects ${vuln.affectedRange}`;
|
|
32
|
+
if (vuln.firstPatched) text += ` · fixed in ${vuln.firstPatched}`;
|
|
33
|
+
return e(
|
|
34
|
+
Box,
|
|
35
|
+
{ marginLeft: 1 },
|
|
36
|
+
e(Text, { color: sev.color }, text),
|
|
37
|
+
override ? e(Text, { color: 'greenBright', bold: true }, ` → override ${override}`) : null
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function NameCell({ name }) {
|
|
27
42
|
const padLength = Math.max(1, 45 - name.length);
|
|
28
43
|
return e(
|
|
44
|
+
Box,
|
|
45
|
+
{ width: 45, flexShrink: 0 },
|
|
46
|
+
e(Text, { bold: true }, name),
|
|
47
|
+
e(Text, null, ' '.repeat(padLength))
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function SectionHeader({ title }) {
|
|
52
|
+
return e(Box, { marginTop: 1 }, e(Text, { bold: true, underline: true, color: 'gray' }, title));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function Row({ name, active, suggestions, selectedColumn, vuln, override }) {
|
|
56
|
+
const main = e(
|
|
29
57
|
Box,
|
|
30
58
|
{ flexDirection: 'row' },
|
|
31
|
-
e(Box, { width: 2 }, e(Text, { color: 'cyanBright', bold: true }, active ? '
|
|
32
|
-
e(
|
|
33
|
-
Box,
|
|
34
|
-
{ width: 45 },
|
|
35
|
-
e(Text, { bold: true }, name),
|
|
36
|
-
e(Text, null, ' '.repeat(padLength))
|
|
37
|
-
),
|
|
59
|
+
e(Box, { width: 2, flexShrink: 0 }, e(Text, { color: 'cyanBright', bold: true }, active ? '❯ ' : ' ')),
|
|
60
|
+
e(NameCell, { name }),
|
|
38
61
|
e(Column, { suggestion: suggestions[0], selected: selectedColumn === 0 }),
|
|
39
62
|
e(Column, { suggestion: suggestions[1], selected: selectedColumn === 1 }),
|
|
40
63
|
e(Column, { suggestion: suggestions[2], selected: selectedColumn === 2 })
|
|
41
64
|
);
|
|
65
|
+
if (!vuln) return main;
|
|
66
|
+
// Put the (potentially long) advisory detail on its own indented line so it
|
|
67
|
+
// stays readable instead of wrapping past the version columns.
|
|
68
|
+
return e(
|
|
69
|
+
Box,
|
|
70
|
+
{ flexDirection: 'column' },
|
|
71
|
+
main,
|
|
72
|
+
e(Box, { marginLeft: 4 }, e(VulnInfo, { vuln, override }))
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A transitive (indirect) vulnerable package: no upgrade columns, just the
|
|
77
|
+
// warning and an override affordance.
|
|
78
|
+
export function VulnRow({ name, active, vuln, override }) {
|
|
79
|
+
return e(
|
|
80
|
+
Box,
|
|
81
|
+
{ flexDirection: 'row' },
|
|
82
|
+
e(Box, { width: 2, flexShrink: 0 }, e(Text, { color: 'cyanBright', bold: true }, active ? '❯ ' : ' ')),
|
|
83
|
+
e(NameCell, { name }),
|
|
84
|
+
e(VulnInfo, { vuln, override }),
|
|
85
|
+
override ? null : e(Text, { dimColor: true }, ' press o to override')
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// An existing `overrides` entry that no longer appears to be needed.
|
|
90
|
+
export function OverrideRow({ name, active, pin, reason, staged }) {
|
|
91
|
+
const why =
|
|
92
|
+
reason === 'dead' ? 'nothing depends on it anymore' : 'no longer prevents a known vulnerability';
|
|
93
|
+
return e(
|
|
94
|
+
Box,
|
|
95
|
+
{ flexDirection: 'row' },
|
|
96
|
+
e(Box, { width: 2, flexShrink: 0 }, e(Text, { color: 'cyanBright', bold: true }, active ? '❯ ' : ' ')),
|
|
97
|
+
e(NameCell, { name }),
|
|
98
|
+
e(
|
|
99
|
+
Box,
|
|
100
|
+
{ marginLeft: 1 },
|
|
101
|
+
staged
|
|
102
|
+
? e(Text, { color: 'greenBright', bold: true }, `✔ removing override ${pin}`)
|
|
103
|
+
: e(
|
|
104
|
+
Text,
|
|
105
|
+
{ color: 'gray' },
|
|
106
|
+
`ⓘ override ${pin} not needed (${why}) `,
|
|
107
|
+
e(Text, { dimColor: true }, '— press x to remove')
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
);
|
|
42
111
|
}
|
|
43
112
|
|
|
44
113
|
export function LoadingRow() {
|
package/src/links.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// OSC 8 terminal hyperlinks, with a plain-text fallback.
|
|
2
|
+
|
|
3
|
+
const OSC = ']8;;';
|
|
4
|
+
const BEL = '';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Render `text` as a clickable link to `url` using the OSC 8 escape sequence
|
|
8
|
+
* when writing to a TTY that can render it. When it can't (piped output, dumb
|
|
9
|
+
* terminal), fall back to `text (url)` so the URL stays visible and copyable.
|
|
10
|
+
*/
|
|
11
|
+
export function hyperlink(text, url) {
|
|
12
|
+
if (!url) return text;
|
|
13
|
+
if (process.stdout && process.stdout.isTTY) {
|
|
14
|
+
return `${OSC}${url}${BEL}${text}${OSC}${BEL}`;
|
|
15
|
+
}
|
|
16
|
+
return `${text} (${url})`;
|
|
17
|
+
}
|
package/src/lockfile.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Reads installed versions (direct + transitive) from package-lock.json.
|
|
2
|
+
// Uses the npm v7+ "packages" map, which lists every installed path/version.
|
|
3
|
+
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
/** Derive a package name from a lockfile path like "node_modules/@scope/name". */
|
|
8
|
+
function nameFromPath(pkgPath) {
|
|
9
|
+
const marker = 'node_modules/';
|
|
10
|
+
const idx = pkgPath.lastIndexOf(marker);
|
|
11
|
+
if (idx === -1) return null;
|
|
12
|
+
const name = pkgPath.slice(idx + marker.length);
|
|
13
|
+
return name || null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Return { versions: Map<name, Set<version>>, direct: Set<name>, packages }
|
|
18
|
+
* for the whole installed tree, or null if there's no usable lockfile (feature
|
|
19
|
+
* then degrades to range-resolved-only checks for direct deps). `packages` is
|
|
20
|
+
* the raw npm lockfile `packages` map, used to see which ranges dependents
|
|
21
|
+
* declare for a package (for spotting no-longer-needed overrides).
|
|
22
|
+
*/
|
|
23
|
+
export async function loadInstalledVersions(cwd) {
|
|
24
|
+
const filePath = path.join(cwd, 'package-lock.json');
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = await readFile(filePath, 'utf8');
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let json;
|
|
33
|
+
try {
|
|
34
|
+
json = JSON.parse(raw);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const packages = json.packages;
|
|
40
|
+
if (!packages || typeof packages !== 'object') return null;
|
|
41
|
+
|
|
42
|
+
const versions = new Map();
|
|
43
|
+
for (const [pkgPath, info] of Object.entries(packages)) {
|
|
44
|
+
if (!pkgPath || !info || !info.version) continue; // skip the "" root entry
|
|
45
|
+
const name = nameFromPath(pkgPath);
|
|
46
|
+
if (!name) continue;
|
|
47
|
+
if (!versions.has(name)) versions.set(name, new Set());
|
|
48
|
+
versions.get(name).add(info.version);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const root = packages[''] || {};
|
|
52
|
+
const direct = new Set([
|
|
53
|
+
...Object.keys(root.dependencies || {}),
|
|
54
|
+
...Object.keys(root.devDependencies || {}),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
return { versions, direct, packages };
|
|
58
|
+
}
|