devvami 1.4.0 → 1.4.1

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.
@@ -0,0 +1,496 @@
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 cveId = state.rows[state.selectedIndex]?.id
364
+ if (!cveId) {
365
+ state = { ...state, currentView: 'table' }
366
+ process.stdout.write(buildTableScreen(state))
367
+ return
368
+ }
369
+
370
+ const myRequestId = ++requestId
371
+ process.stdout.write(buildLoadingScreen(cveId, state.termRows, state.termCols))
372
+
373
+ try {
374
+ const detail = await onOpenDetail(cveId)
375
+ if (myRequestId !== requestId) return // user dismissed while loading
376
+
377
+ const lines = formatCveDetailPlain(detail)
378
+ const firstRef = detail.references?.[0]?.url ?? null
379
+ state = {
380
+ ...state,
381
+ currentView: 'modal',
382
+ modalContent: lines,
383
+ modalError: null,
384
+ modalScrollOffset: 0,
385
+ firstRefUrl: firstRef,
386
+ }
387
+ process.stdout.write(buildModalScreen(state))
388
+ } catch (err) {
389
+ if (myRequestId !== requestId) return // user dismissed while loading
390
+ state = {
391
+ ...state,
392
+ currentView: 'modal',
393
+ modalContent: null,
394
+ modalError: /** @type {Error} */ (err).message ?? 'Unknown error',
395
+ }
396
+ process.stdout.write(buildErrorScreen(cveId, state.modalError ?? 'Unknown error', state.termRows, state.termCols))
397
+ }
398
+ }
399
+
400
+ // T014: terminal resize handler
401
+ /** @returns {void} */
402
+ function onResize() {
403
+ const newRows = process.stdout.rows || 24
404
+ const newCols = process.stdout.columns || 80
405
+ state = {
406
+ ...state,
407
+ termRows: newRows,
408
+ termCols: newCols,
409
+ viewportHeight: Math.max(1, newRows - HEADER_LINES - FOOTER_LINES),
410
+ }
411
+ if (state.currentView === 'table') {
412
+ process.stdout.write(buildTableScreen(state))
413
+ } else if (state.modalContent) {
414
+ process.stdout.write(buildModalScreen(state))
415
+ } else if (state.modalError) {
416
+ const cveId = state.rows[state.selectedIndex]?.id ?? ''
417
+ process.stdout.write(buildErrorScreen(cveId, state.modalError, state.termRows, state.termCols))
418
+ }
419
+ }
420
+ process.stdout.on('resize', onResize)
421
+
422
+ return new Promise((resolve) => {
423
+ /**
424
+ * @param {string} _str @param {{ name: string, ctrl?: boolean }} key
425
+ * @param {{ name: string, ctrl?: boolean }} key
426
+ */
427
+ const listener = async (_str, key) => {
428
+ if (!key) return
429
+
430
+ if (state.currentView === 'table') {
431
+ const result = handleTableKeypress(state, key)
432
+
433
+ if ('exit' in result) {
434
+ process.stdout.removeListener('resize', onResize)
435
+ process.removeListener('SIGINT', sigHandler)
436
+ process.removeListener('SIGTERM', sigHandler)
437
+ process.removeListener('exit', exitHandler)
438
+ cleanupTerminal()
439
+ resolve()
440
+ return
441
+ }
442
+
443
+ state = /** @type {InteractiveTableState} */ (result)
444
+
445
+ if (state.currentView === 'modal') {
446
+ // Enter was pressed — fetch and display detail
447
+ await openDetail()
448
+ } else {
449
+ process.stdout.write(buildTableScreen(state))
450
+ }
451
+ } else {
452
+ // Modal view
453
+ const result = handleModalKeypress(state, key)
454
+
455
+ if ('backToTable' in result) {
456
+ // Invalidate any in-flight fetch
457
+ requestId++
458
+ state = {
459
+ ...state,
460
+ currentView: 'table',
461
+ modalContent: null,
462
+ modalError: null,
463
+ modalScrollOffset: 0,
464
+ firstRefUrl: null,
465
+ }
466
+ process.stdout.write(buildTableScreen(state))
467
+ } else if ('exit' in result) {
468
+ process.stdout.removeListener('resize', onResize)
469
+ process.removeListener('SIGINT', sigHandler)
470
+ process.removeListener('SIGTERM', sigHandler)
471
+ process.removeListener('exit', exitHandler)
472
+ cleanupTerminal()
473
+ resolve()
474
+ } else if ('openUrl' in result) {
475
+ await openBrowser(result.openUrl)
476
+ // Redraw modal — stays visible
477
+ if (state.modalContent) {
478
+ process.stdout.write(buildModalScreen(state))
479
+ }
480
+ } else {
481
+ state = /** @type {InteractiveTableState} */ (result)
482
+ if (state.modalContent) {
483
+ process.stdout.write(buildModalScreen(state))
484
+ } else if (state.modalError) {
485
+ const cveId = state.rows[state.selectedIndex]?.id ?? ''
486
+ process.stdout.write(buildErrorScreen(cveId, state.modalError, state.termRows, state.termCols))
487
+ }
488
+ }
489
+ }
490
+ }
491
+
492
+ _keypressListener = listener
493
+ process.stdin.on('keypress', listener)
494
+ process.stdin.resume()
495
+ })
496
+ }