devvami 1.4.0 → 1.4.2
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/oclif.manifest.json +275 -89
- package/package.json +1 -1
- package/src/commands/vuln/detail.js +65 -0
- package/src/commands/vuln/scan.js +210 -0
- package/src/commands/vuln/search.js +128 -0
- package/src/formatters/vuln.js +317 -0
- package/src/help.js +15 -0
- package/src/services/audit-detector.js +120 -0
- package/src/services/audit-runner.js +365 -0
- package/src/services/nvd.js +245 -0
- package/src/types.js +9 -5
- package/src/utils/errors.js +2 -0
- package/src/utils/tui/modal.js +224 -0
- package/src/utils/tui/navigable-table.js +504 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import readline from 'node:readline'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { NVD_ATTRIBUTION } from '../../services/nvd.js'
|
|
4
|
+
import { buildModalScreen, buildLoadingScreen, buildErrorScreen, handleModalKeypress } from './modal.js'
|
|
5
|
+
import { formatCveDetailPlain } from '../../formatters/vuln.js'
|
|
6
|
+
import { openBrowser } from '../open-browser.js'
|
|
7
|
+
|
|
8
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
// ANSI escape sequences
|
|
10
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const ANSI_CLEAR = '\x1b[2J'
|
|
13
|
+
const ANSI_HOME = '\x1b[H'
|
|
14
|
+
const ANSI_ALT_SCREEN_ON = '\x1b[?1049h'
|
|
15
|
+
const ANSI_ALT_SCREEN_OFF = '\x1b[?1049l'
|
|
16
|
+
const ANSI_CURSOR_HIDE = '\x1b[?25l'
|
|
17
|
+
const ANSI_CURSOR_SHOW = '\x1b[?25h'
|
|
18
|
+
const ANSI_INVERSE_ON = '\x1b[7m'
|
|
19
|
+
const ANSI_INVERSE_OFF = '\x1b[27m'
|
|
20
|
+
|
|
21
|
+
// Screen layout constants
|
|
22
|
+
const HEADER_LINES = 4 // heading, empty, column headers, divider
|
|
23
|
+
const FOOTER_LINES = 3 // empty, NVD attribution, keyboard hints
|
|
24
|
+
|
|
25
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
// Module-level terminal session state (reset on each startInteractiveTable call)
|
|
27
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
let _cleanupCalled = false
|
|
30
|
+
let _altScreenActive = false
|
|
31
|
+
let _rawModeActive = false
|
|
32
|
+
/** @type {((...args: unknown[]) => void) | null} */
|
|
33
|
+
let _keypressListener = null
|
|
34
|
+
|
|
35
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Typedefs
|
|
37
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {Object} TableColumnDef
|
|
41
|
+
* @property {string} header - Column header text
|
|
42
|
+
* @property {string} key - Key to read from the row object
|
|
43
|
+
* @property {number} [width] - Maximum display width in characters
|
|
44
|
+
* @property {(v: string) => string} [colorize] - Chalk color function applied after padding
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {Object} InteractiveTableState
|
|
49
|
+
* @property {Array<Record<string, string>>} rows - Pre-formatted row objects
|
|
50
|
+
* @property {TableColumnDef[]} columns - Column definitions
|
|
51
|
+
* @property {string} heading - Display heading (e.g. 'CVE Search: "openssl" (last 14 days)')
|
|
52
|
+
* @property {number} totalResults - Total results from the API (may differ from rows.length)
|
|
53
|
+
* @property {number} selectedIndex - 0-based index of the highlighted row
|
|
54
|
+
* @property {number} scrollOffset - 0-based index of the first visible row (unused; derived from selectedIndex)
|
|
55
|
+
* @property {number} viewportHeight - Number of data rows visible at once
|
|
56
|
+
* @property {number} termRows - Current terminal height
|
|
57
|
+
* @property {number} termCols - Current terminal width
|
|
58
|
+
* @property {'table' | 'modal'} currentView - Active view
|
|
59
|
+
* @property {number} modalScrollOffset - Scroll offset within modal content
|
|
60
|
+
* @property {string[] | null} modalContent - Pre-rendered plain-text modal lines
|
|
61
|
+
* @property {string | null} modalError - Error message if CVE detail fetch failed
|
|
62
|
+
* @property {string | null} firstRefUrl - First reference URL for the currently open CVE
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
// Internal helpers
|
|
67
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Return the visible (display) length of a string, stripping ANSI escape codes.
|
|
71
|
+
* @param {string} str
|
|
72
|
+
* @returns {number}
|
|
73
|
+
*/
|
|
74
|
+
function visibleLength(str) {
|
|
75
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '').length
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pad a plain-text string to a fixed width, truncating with '…' if needed.
|
|
80
|
+
* @param {string} str
|
|
81
|
+
* @param {number} width
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
function padCell(str, width) {
|
|
85
|
+
if (!str) str = ''
|
|
86
|
+
if (str.length > width) return str.slice(0, width - 1) + '…'
|
|
87
|
+
return str.padEnd(width)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
// T003: computeViewport
|
|
92
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compute the viewport slice using a center-biased algorithm with edge clamping.
|
|
96
|
+
* The selected row is kept roughly in the center of the visible area.
|
|
97
|
+
* @param {number} selectedIndex - 0-based index of the highlighted row
|
|
98
|
+
* @param {number} totalRows - Total number of rows in the data set
|
|
99
|
+
* @param {number} viewportHeight - Number of rows visible at one time
|
|
100
|
+
* @returns {{ startIndex: number, endIndex: number }}
|
|
101
|
+
*/
|
|
102
|
+
export function computeViewport(selectedIndex, totalRows, viewportHeight) {
|
|
103
|
+
let startIndex = selectedIndex - Math.floor(viewportHeight / 2)
|
|
104
|
+
startIndex = Math.max(0, startIndex)
|
|
105
|
+
startIndex = Math.min(Math.max(0, totalRows - viewportHeight), startIndex)
|
|
106
|
+
const endIndex = Math.min(startIndex + viewportHeight, totalRows)
|
|
107
|
+
return { startIndex, endIndex }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
// T004: formatRow
|
|
112
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Render a single table row as a terminal string.
|
|
116
|
+
* Pads each cell to its column width, applies the column's colorize function,
|
|
117
|
+
* then wraps the whole line in ANSI inverse video if the row is selected.
|
|
118
|
+
* @param {Record<string, string>} row - Pre-formatted row data
|
|
119
|
+
* @param {TableColumnDef[]} columns - Column definitions
|
|
120
|
+
* @param {number} termCols - Terminal width (unused; for future clamp support)
|
|
121
|
+
* @param {boolean} isSelected - Whether to apply inverse-video highlight
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function formatRow(row, columns, termCols, isSelected) {
|
|
125
|
+
const parts = []
|
|
126
|
+
for (const col of columns) {
|
|
127
|
+
const raw = String(row[col.key] ?? '')
|
|
128
|
+
const width = col.width ?? 15
|
|
129
|
+
const padded = padCell(raw, width)
|
|
130
|
+
const colored = col.colorize ? col.colorize(padded) : padded
|
|
131
|
+
parts.push(colored)
|
|
132
|
+
}
|
|
133
|
+
const line = parts.join(' ')
|
|
134
|
+
if (isSelected) {
|
|
135
|
+
return `${ANSI_INVERSE_ON}${line}${ANSI_INVERSE_OFF}`
|
|
136
|
+
}
|
|
137
|
+
return line
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
// T005: buildTableScreen
|
|
142
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build the full table screen string from the current state.
|
|
146
|
+
* Includes: heading + count, column headers, divider, visible rows, footer.
|
|
147
|
+
* Prepends ANSI clear + home to replace the previous frame.
|
|
148
|
+
* @param {InteractiveTableState} state
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
export function buildTableScreen(state) {
|
|
152
|
+
const { rows, columns, heading, totalResults, selectedIndex, viewportHeight, termCols } = state
|
|
153
|
+
const lines = []
|
|
154
|
+
|
|
155
|
+
// ── Header ────────────────────────────────────────────────────────────────
|
|
156
|
+
const headingStyled = chalk.bold(heading)
|
|
157
|
+
const countStr = `Showing ${rows.length} of ${totalResults ?? rows.length} results`
|
|
158
|
+
const countStyled = chalk.dim(countStr)
|
|
159
|
+
const gap = Math.max(2, termCols - visibleLength(headingStyled) - visibleLength(countStyled))
|
|
160
|
+
lines.push(headingStyled + ' '.repeat(gap) + countStyled)
|
|
161
|
+
lines.push('')
|
|
162
|
+
|
|
163
|
+
// ── Column headers ────────────────────────────────────────────────────────
|
|
164
|
+
const headerParts = columns.map((col) => chalk.bold.white(padCell(col.header, col.width ?? 15)))
|
|
165
|
+
lines.push(headerParts.join(' '))
|
|
166
|
+
const dividerWidth = columns.reduce((sum, col) => sum + (col.width ?? 15), 0) + (columns.length - 1) * 2
|
|
167
|
+
lines.push(chalk.dim('─'.repeat(Math.min(termCols, dividerWidth))))
|
|
168
|
+
|
|
169
|
+
// ── Data rows ─────────────────────────────────────────────────────────────
|
|
170
|
+
const { startIndex, endIndex } = computeViewport(selectedIndex, rows.length, viewportHeight)
|
|
171
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
172
|
+
lines.push(formatRow(rows[i], columns, termCols, i === selectedIndex))
|
|
173
|
+
}
|
|
174
|
+
// Pad remaining viewport if fewer rows than viewportHeight
|
|
175
|
+
for (let i = endIndex - startIndex; i < viewportHeight; i++) {
|
|
176
|
+
lines.push('')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Footer ────────────────────────────────────────────────────────────────
|
|
180
|
+
lines.push('')
|
|
181
|
+
lines.push(chalk.dim(NVD_ATTRIBUTION))
|
|
182
|
+
lines.push(chalk.dim(' ↑↓ navigate Enter view detail Esc/q exit'))
|
|
183
|
+
|
|
184
|
+
return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
188
|
+
// T010: createInteractiveTableState
|
|
189
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Create the initial InteractiveTableState for a new interactive session.
|
|
193
|
+
* @param {Array<Record<string, string>>} rows - Pre-formatted row objects
|
|
194
|
+
* @param {TableColumnDef[]} columns - Column definitions
|
|
195
|
+
* @param {string} heading - Display heading
|
|
196
|
+
* @param {number} totalResults - Total result count from the API
|
|
197
|
+
* @param {number} termRows - Terminal height
|
|
198
|
+
* @param {number} termCols - Terminal width
|
|
199
|
+
* @returns {InteractiveTableState}
|
|
200
|
+
*/
|
|
201
|
+
export function createInteractiveTableState(rows, columns, heading, totalResults, termRows, termCols) {
|
|
202
|
+
return {
|
|
203
|
+
rows,
|
|
204
|
+
columns,
|
|
205
|
+
heading,
|
|
206
|
+
totalResults,
|
|
207
|
+
selectedIndex: 0,
|
|
208
|
+
scrollOffset: 0,
|
|
209
|
+
viewportHeight: Math.max(1, termRows - HEADER_LINES - FOOTER_LINES),
|
|
210
|
+
termRows,
|
|
211
|
+
termCols,
|
|
212
|
+
currentView: 'table',
|
|
213
|
+
modalScrollOffset: 0,
|
|
214
|
+
modalContent: null,
|
|
215
|
+
modalError: null,
|
|
216
|
+
firstRefUrl: null,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
221
|
+
// T011: handleTableKeypress
|
|
222
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Pure state reducer for keypresses in the table view.
|
|
226
|
+
* Returns a new state object on navigation, or a control object on exit/enter.
|
|
227
|
+
* @param {InteractiveTableState} state
|
|
228
|
+
* @param {{ name: string, ctrl?: boolean }} key - readline keypress event
|
|
229
|
+
* @returns {InteractiveTableState | { exit: true }}
|
|
230
|
+
*/
|
|
231
|
+
export function handleTableKeypress(state, key) {
|
|
232
|
+
const { selectedIndex, rows, viewportHeight } = state
|
|
233
|
+
|
|
234
|
+
if (key.name === 'escape' || key.name === 'q') return { exit: true }
|
|
235
|
+
if (key.ctrl && key.name === 'c') return { exit: true }
|
|
236
|
+
|
|
237
|
+
if (key.name === 'return') {
|
|
238
|
+
return { ...state, currentView: 'modal' }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (key.name === 'up') {
|
|
242
|
+
return { ...state, selectedIndex: Math.max(0, selectedIndex - 1) }
|
|
243
|
+
}
|
|
244
|
+
if (key.name === 'down') {
|
|
245
|
+
return { ...state, selectedIndex: Math.min(rows.length - 1, selectedIndex + 1) }
|
|
246
|
+
}
|
|
247
|
+
if (key.name === 'pageup') {
|
|
248
|
+
return { ...state, selectedIndex: Math.max(0, selectedIndex - viewportHeight) }
|
|
249
|
+
}
|
|
250
|
+
if (key.name === 'pagedown') {
|
|
251
|
+
return { ...state, selectedIndex: Math.min(rows.length - 1, selectedIndex + viewportHeight) }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return state // unrecognized key — no state change
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
258
|
+
// T012: setupTerminal / cleanupTerminal
|
|
259
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Enter the alternate screen buffer, hide the cursor, and enable raw stdin keypresses.
|
|
263
|
+
* @returns {void}
|
|
264
|
+
*/
|
|
265
|
+
export function setupTerminal() {
|
|
266
|
+
_altScreenActive = true
|
|
267
|
+
_rawModeActive = true
|
|
268
|
+
process.stdout.write(ANSI_ALT_SCREEN_ON)
|
|
269
|
+
process.stdout.write(ANSI_CURSOR_HIDE)
|
|
270
|
+
readline.emitKeypressEvents(process.stdin)
|
|
271
|
+
if (process.stdin.isTTY) {
|
|
272
|
+
process.stdin.setRawMode(true)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Restore the terminal to its original state: leave alt screen, show cursor, disable raw mode.
|
|
278
|
+
* Idempotent — safe to call multiple times.
|
|
279
|
+
* @returns {void}
|
|
280
|
+
*/
|
|
281
|
+
export function cleanupTerminal() {
|
|
282
|
+
if (_cleanupCalled) return
|
|
283
|
+
_cleanupCalled = true
|
|
284
|
+
|
|
285
|
+
if (_keypressListener) {
|
|
286
|
+
process.stdin.removeListener('keypress', _keypressListener)
|
|
287
|
+
_keypressListener = null
|
|
288
|
+
}
|
|
289
|
+
if (_rawModeActive && process.stdin.isTTY) {
|
|
290
|
+
try {
|
|
291
|
+
process.stdin.setRawMode(false)
|
|
292
|
+
} catch {
|
|
293
|
+
// ignore — stdin may already be closed
|
|
294
|
+
}
|
|
295
|
+
_rawModeActive = false
|
|
296
|
+
}
|
|
297
|
+
if (_altScreenActive) {
|
|
298
|
+
process.stdout.write(ANSI_CURSOR_SHOW)
|
|
299
|
+
process.stdout.write(ANSI_ALT_SCREEN_OFF)
|
|
300
|
+
_altScreenActive = false
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
process.stdin.pause()
|
|
304
|
+
} catch {
|
|
305
|
+
// ignore
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
310
|
+
// T013 + T014 + T016-T018: startInteractiveTable (orchestrator)
|
|
311
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Start the interactive navigable table session.
|
|
315
|
+
* Blocks until the user exits (Esc / q / Ctrl+C).
|
|
316
|
+
* Manages the full lifecycle: terminal setup, keypress loop, modal overlay, cleanup.
|
|
317
|
+
* @param {Array<Record<string, string>>} rows - Pre-formatted row objects
|
|
318
|
+
* @param {TableColumnDef[]} columns - Column definitions
|
|
319
|
+
* @param {string} heading - Display heading (search query + time window)
|
|
320
|
+
* @param {number} totalResults - Total result count from the API
|
|
321
|
+
* @param {(cveId: string) => Promise<import('../../types.js').CveDetail>} onOpenDetail - Async callback to fetch CVE detail
|
|
322
|
+
* @returns {Promise<void>}
|
|
323
|
+
*/
|
|
324
|
+
export async function startInteractiveTable(rows, columns, heading, totalResults, onOpenDetail) {
|
|
325
|
+
// Reset cleanup guard for a fresh session
|
|
326
|
+
_cleanupCalled = false
|
|
327
|
+
|
|
328
|
+
// Register process-level cleanup handlers
|
|
329
|
+
const sigHandler = () => {
|
|
330
|
+
cleanupTerminal()
|
|
331
|
+
process.exit(0)
|
|
332
|
+
}
|
|
333
|
+
const exitHandler = () => {
|
|
334
|
+
if (!_cleanupCalled) cleanupTerminal()
|
|
335
|
+
}
|
|
336
|
+
process.once('SIGINT', sigHandler)
|
|
337
|
+
process.once('SIGTERM', sigHandler)
|
|
338
|
+
process.once('exit', exitHandler)
|
|
339
|
+
|
|
340
|
+
setupTerminal()
|
|
341
|
+
|
|
342
|
+
/** @type {InteractiveTableState} */
|
|
343
|
+
let state = createInteractiveTableState(
|
|
344
|
+
rows,
|
|
345
|
+
columns,
|
|
346
|
+
heading,
|
|
347
|
+
totalResults,
|
|
348
|
+
process.stdout.rows || 24,
|
|
349
|
+
process.stdout.columns || 80,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
// Render initial table
|
|
353
|
+
process.stdout.write(buildTableScreen(state))
|
|
354
|
+
|
|
355
|
+
// Request ID to prevent stale fetch results from overwriting state
|
|
356
|
+
let requestId = 0
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Fetch CVE detail for the currently selected row, show loading, then modal or error.
|
|
360
|
+
* @returns {Promise<void>}
|
|
361
|
+
*/
|
|
362
|
+
async function openDetail() {
|
|
363
|
+
const row = state.rows[state.selectedIndex]
|
|
364
|
+
const cveId = row?.id
|
|
365
|
+
|
|
366
|
+
if (!cveId) {
|
|
367
|
+
// No CVE ID — check for an advisory URL (e.g. npm/pnpm advisory findings).
|
|
368
|
+
// Open it in the browser and stay in table view rather than showing a modal.
|
|
369
|
+
const advisoryUrl = row?.advisoryUrl
|
|
370
|
+
if (advisoryUrl) {
|
|
371
|
+
await openBrowser(String(advisoryUrl))
|
|
372
|
+
}
|
|
373
|
+
state = { ...state, currentView: 'table' }
|
|
374
|
+
process.stdout.write(buildTableScreen(state))
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const myRequestId = ++requestId
|
|
379
|
+
process.stdout.write(buildLoadingScreen(cveId, state.termRows, state.termCols))
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const detail = await onOpenDetail(cveId)
|
|
383
|
+
if (myRequestId !== requestId) return // user dismissed while loading
|
|
384
|
+
|
|
385
|
+
const lines = formatCveDetailPlain(detail)
|
|
386
|
+
const firstRef = detail.references?.[0]?.url ?? null
|
|
387
|
+
state = {
|
|
388
|
+
...state,
|
|
389
|
+
currentView: 'modal',
|
|
390
|
+
modalContent: lines,
|
|
391
|
+
modalError: null,
|
|
392
|
+
modalScrollOffset: 0,
|
|
393
|
+
firstRefUrl: firstRef,
|
|
394
|
+
}
|
|
395
|
+
process.stdout.write(buildModalScreen(state))
|
|
396
|
+
} catch (err) {
|
|
397
|
+
if (myRequestId !== requestId) return // user dismissed while loading
|
|
398
|
+
state = {
|
|
399
|
+
...state,
|
|
400
|
+
currentView: 'modal',
|
|
401
|
+
modalContent: null,
|
|
402
|
+
modalError: /** @type {Error} */ (err).message ?? 'Unknown error',
|
|
403
|
+
}
|
|
404
|
+
process.stdout.write(buildErrorScreen(cveId, state.modalError ?? 'Unknown error', state.termRows, state.termCols))
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// T014: terminal resize handler
|
|
409
|
+
/** @returns {void} */
|
|
410
|
+
function onResize() {
|
|
411
|
+
const newRows = process.stdout.rows || 24
|
|
412
|
+
const newCols = process.stdout.columns || 80
|
|
413
|
+
state = {
|
|
414
|
+
...state,
|
|
415
|
+
termRows: newRows,
|
|
416
|
+
termCols: newCols,
|
|
417
|
+
viewportHeight: Math.max(1, newRows - HEADER_LINES - FOOTER_LINES),
|
|
418
|
+
}
|
|
419
|
+
if (state.currentView === 'table') {
|
|
420
|
+
process.stdout.write(buildTableScreen(state))
|
|
421
|
+
} else if (state.modalContent) {
|
|
422
|
+
process.stdout.write(buildModalScreen(state))
|
|
423
|
+
} else if (state.modalError) {
|
|
424
|
+
const cveId = state.rows[state.selectedIndex]?.id ?? ''
|
|
425
|
+
process.stdout.write(buildErrorScreen(cveId, state.modalError, state.termRows, state.termCols))
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
process.stdout.on('resize', onResize)
|
|
429
|
+
|
|
430
|
+
return new Promise((resolve) => {
|
|
431
|
+
/**
|
|
432
|
+
* @param {string} _str @param {{ name: string, ctrl?: boolean }} key
|
|
433
|
+
* @param {{ name: string, ctrl?: boolean }} key
|
|
434
|
+
*/
|
|
435
|
+
const listener = async (_str, key) => {
|
|
436
|
+
if (!key) return
|
|
437
|
+
|
|
438
|
+
if (state.currentView === 'table') {
|
|
439
|
+
const result = handleTableKeypress(state, key)
|
|
440
|
+
|
|
441
|
+
if ('exit' in result) {
|
|
442
|
+
process.stdout.removeListener('resize', onResize)
|
|
443
|
+
process.removeListener('SIGINT', sigHandler)
|
|
444
|
+
process.removeListener('SIGTERM', sigHandler)
|
|
445
|
+
process.removeListener('exit', exitHandler)
|
|
446
|
+
cleanupTerminal()
|
|
447
|
+
resolve()
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
state = /** @type {InteractiveTableState} */ (result)
|
|
452
|
+
|
|
453
|
+
if (state.currentView === 'modal') {
|
|
454
|
+
// Enter was pressed — fetch and display detail
|
|
455
|
+
await openDetail()
|
|
456
|
+
} else {
|
|
457
|
+
process.stdout.write(buildTableScreen(state))
|
|
458
|
+
}
|
|
459
|
+
} else {
|
|
460
|
+
// Modal view
|
|
461
|
+
const result = handleModalKeypress(state, key)
|
|
462
|
+
|
|
463
|
+
if ('backToTable' in result) {
|
|
464
|
+
// Invalidate any in-flight fetch
|
|
465
|
+
requestId++
|
|
466
|
+
state = {
|
|
467
|
+
...state,
|
|
468
|
+
currentView: 'table',
|
|
469
|
+
modalContent: null,
|
|
470
|
+
modalError: null,
|
|
471
|
+
modalScrollOffset: 0,
|
|
472
|
+
firstRefUrl: null,
|
|
473
|
+
}
|
|
474
|
+
process.stdout.write(buildTableScreen(state))
|
|
475
|
+
} else if ('exit' in result) {
|
|
476
|
+
process.stdout.removeListener('resize', onResize)
|
|
477
|
+
process.removeListener('SIGINT', sigHandler)
|
|
478
|
+
process.removeListener('SIGTERM', sigHandler)
|
|
479
|
+
process.removeListener('exit', exitHandler)
|
|
480
|
+
cleanupTerminal()
|
|
481
|
+
resolve()
|
|
482
|
+
} else if ('openUrl' in result) {
|
|
483
|
+
await openBrowser(result.openUrl)
|
|
484
|
+
// Redraw modal — stays visible
|
|
485
|
+
if (state.modalContent) {
|
|
486
|
+
process.stdout.write(buildModalScreen(state))
|
|
487
|
+
}
|
|
488
|
+
} else {
|
|
489
|
+
state = /** @type {InteractiveTableState} */ (result)
|
|
490
|
+
if (state.modalContent) {
|
|
491
|
+
process.stdout.write(buildModalScreen(state))
|
|
492
|
+
} else if (state.modalError) {
|
|
493
|
+
const cveId = state.rows[state.selectedIndex]?.id ?? ''
|
|
494
|
+
process.stdout.write(buildErrorScreen(cveId, state.modalError, state.termRows, state.termCols))
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
_keypressListener = listener
|
|
501
|
+
process.stdin.on('keypress', listener)
|
|
502
|
+
process.stdin.resume()
|
|
503
|
+
})
|
|
504
|
+
}
|