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.
@@ -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 findNavigable(entries, from, direction) {
17
- let i = from;
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 firstNavigable(entries) {
27
- for (let i = 0; i < entries.length; i++) {
28
- if (entries[i] && typeof entries[i] === 'object') return i;
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({ descriptors, onSubmit, onAbort }) {
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 [focusedIndex, setFocusedIndex] = useState(-1);
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
- // Keep focus pinned to a navigable (loaded, upgradeable) row as things load in.
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 (focusedIndex !== -1 && entries[focusedIndex] && typeof entries[focusedIndex] === 'object') return;
79
- const next = firstNavigable(entries);
80
- if (next !== focusedIndex) setFocusedIndex(next);
81
- }, [entries, focusedIndex]);
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 (focusedIndex === -1) return;
86
- const entry = entries[focusedIndex];
87
- if (!entry) return;
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 < entry.suggestions.length; step++) {
92
- next = clamp(next + direction, 0, entry.suggestions.length - 1);
93
- if (entry.suggestions[next].spans.length > 0 || next === 0) break;
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
- [focusedIndex, entries, selectedColumns]
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
- next[name] = 0;
110
- } else if (which === 'r') {
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
- useInput((input, key) => {
123
- if (key.ctrl && input === 'c') {
124
- onAbort();
125
- exit();
126
- return;
127
- }
128
- if (key.escape) {
129
- onAbort();
130
- exit();
131
- return;
132
- }
133
- if (key.upArrow) {
134
- setFocusedIndex((idx) => findNavigable(entries, idx, -1));
135
- return;
136
- }
137
- if (key.downArrow) {
138
- setFocusedIndex((idx) => findNavigable(entries, idx, 1));
139
- return;
140
- }
141
- if (key.leftArrow) {
142
- cycleColumn(-1);
143
- return;
144
- }
145
- if (key.rightArrow) {
146
- cycleColumn(1);
147
- return;
148
- }
149
- if (input === 'c' || input === 'r' || input === 'l') {
150
- bulkSelect(input);
151
- return;
152
- }
153
- if (key.return) {
154
- const selections = new Map();
155
- for (const entry of entries) {
156
- if (!entry) continue;
157
- const col = selectedColumns[entry.descriptor.name] ?? 0;
158
- const value = entry.suggestions[col]?.value ?? null;
159
- if (value) selections.set(entry.descriptor.name, value);
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
- onSubmit(selections);
162
- exit();
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 displayIndices = allLoaded
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 && displayIndices.length === 0) {
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 - 11);
182
- const posInDisplay = Math.max(0, displayIndices.indexOf(focusedIndex));
183
- let windowStart = clamp(posInDisplay - Math.floor(maxRows / 2), 0, Math.max(0, displayIndices.length - maxRows));
184
- const windowEnd = Math.min(displayIndices.length, windowStart + maxRows);
185
- const visible = displayIndices.slice(windowStart, windowEnd);
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, null),
295
+ e(Prompt, { audit }),
191
296
  e(Header, null),
192
- windowStart > 0 ? e(Text, { dimColor: true }, ` \u2191 ${windowStart} more above`) : null,
193
- ...visible.map((i) => {
194
- const entry = entries[i];
195
- if (!entry) return e(LoadingRow, { key: i });
196
- const col = selectedColumns[entry.descriptor.name] ?? 0;
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: i,
199
- name: entry.descriptor.name,
200
- active: i === focusedIndex,
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 < displayIndices.length ? e(Text, { dimColor: true }, ` \u2193 ${displayIndices.length - windowEnd} more below`) : null
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
+ }
@@ -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
  }
@@ -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 ? '\u25CF ' : '\u25CB ', e(Spans, { spans: suggestion.spans, inverse: selected }))
22
- : e(Text, { dimColor: true }, selected ? '\u25CF' : '')
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
- export function Row({ name, active, suggestions, selectedColumn }) {
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 ? '\u276F ' : ' ')),
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
+ }
@@ -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
+ }