devvami 1.3.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,245 @@
1
+ import { loadConfig } from './config.js'
2
+ import { DvmiError } from '../utils/errors.js'
3
+
4
+ /** @import { CveSearchResult, CveDetail } from '../types.js' */
5
+
6
+ const NVD_BASE_URL = 'https://services.nvd.nist.gov/rest/json/cves/2.0'
7
+
8
+ /** NVD attribution required in all interactive output. */
9
+ export const NVD_ATTRIBUTION =
10
+ 'This product uses data from the NVD API but is not endorsed or certified by the NVD.'
11
+
12
+ /**
13
+ * Normalize a raw NVD severity string to the 4-tier canonical form.
14
+ * @param {string|undefined} raw
15
+ * @returns {'Critical'|'High'|'Medium'|'Low'|'Unknown'}
16
+ */
17
+ export function normalizeSeverity(raw) {
18
+ if (!raw) return 'Unknown'
19
+ const s = raw.toUpperCase()
20
+ if (s === 'CRITICAL') return 'Critical'
21
+ if (s === 'HIGH') return 'High'
22
+ if (s === 'MEDIUM') return 'Medium'
23
+ if (s === 'LOW') return 'Low'
24
+ return 'Unknown'
25
+ }
26
+
27
+ /**
28
+ * Extract the best available CVSS metrics from a CVE record.
29
+ * Priority: cvssMetricV31 > cvssMetricV40 > cvssMetricV2
30
+ * @param {Record<string, unknown>} metrics
31
+ * @returns {{ score: number|null, severity: string, vector: string|null }}
32
+ */
33
+ function extractCvss(metrics) {
34
+ const sources = [
35
+ (metrics?.cvssMetricV31 ?? []),
36
+ (metrics?.cvssMetricV40 ?? []),
37
+ (metrics?.cvssMetricV2 ?? []),
38
+ ]
39
+
40
+ for (const list of sources) {
41
+ if (Array.isArray(list) && list.length > 0) {
42
+ const data = /** @type {any} */ (list[0]).cvssData
43
+ if (data) {
44
+ return {
45
+ score: data.baseScore ?? null,
46
+ severity: normalizeSeverity(data.baseSeverity),
47
+ vector: data.vectorString ?? null,
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ return { score: null, severity: 'Unknown', vector: null }
54
+ }
55
+
56
+ /**
57
+ * Get the English description from the NVD descriptions array.
58
+ * @param {Array<{lang: string, value: string}>} descriptions
59
+ * @returns {string}
60
+ */
61
+ function getEnDescription(descriptions) {
62
+ if (!Array.isArray(descriptions)) return ''
63
+ const en = descriptions.find((d) => d.lang === 'en')
64
+ return en?.value ?? ''
65
+ }
66
+
67
+ /**
68
+ * Build query parameters for NVD API request.
69
+ * @param {Record<string, string|number|undefined>} params
70
+ * @returns {URLSearchParams}
71
+ */
72
+ function buildParams(params) {
73
+ const sp = new URLSearchParams()
74
+ for (const [key, val] of Object.entries(params)) {
75
+ if (val !== undefined && val !== null && val !== '') {
76
+ sp.set(key, String(val))
77
+ }
78
+ }
79
+ return sp
80
+ }
81
+
82
+ /**
83
+ * Make an authenticated fetch to the NVD API.
84
+ * @param {URLSearchParams} params
85
+ * @param {string|undefined} apiKey
86
+ * @returns {Promise<unknown>}
87
+ */
88
+ async function nvdFetch(params, apiKey) {
89
+ const url = `${NVD_BASE_URL}?${params.toString()}`
90
+ /** @type {Record<string, string>} */
91
+ const headers = { Accept: 'application/json' }
92
+ if (apiKey) headers['apiKey'] = apiKey
93
+
94
+ const res = await fetch(url, { headers })
95
+ if (!res.ok) {
96
+ throw new DvmiError(
97
+ `NVD API returned HTTP ${res.status}`,
98
+ 'Check your network connection or try again later.',
99
+ )
100
+ }
101
+ return res.json()
102
+ }
103
+
104
+ /**
105
+ * Parse a raw NVD vulnerability object into a CveSearchResult.
106
+ * @param {any} raw
107
+ * @returns {CveSearchResult}
108
+ */
109
+ function parseCveSearchResult(raw) {
110
+ const cve = raw.cve
111
+ const { score, severity } = extractCvss(cve.metrics ?? {})
112
+ return {
113
+ id: cve.id,
114
+ description: getEnDescription(cve.descriptions),
115
+ severity,
116
+ score,
117
+ publishedDate: cve.published,
118
+ lastModified: cve.lastModified,
119
+ firstReference: (cve.references ?? [])[0]?.url ?? null,
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Parse a raw NVD vulnerability object into a CveDetail.
125
+ * @param {any} raw
126
+ * @returns {CveDetail}
127
+ */
128
+ function parseCveDetail(raw) {
129
+ const cve = raw.cve
130
+ const { score, severity, vector } = extractCvss(cve.metrics ?? {})
131
+
132
+ // Weaknesses: flatten all CWE descriptions
133
+ const weaknesses = (cve.weaknesses ?? []).flatMap((w) =>
134
+ (w.description ?? []).map((d) => ({
135
+ id: d.value,
136
+ description: d.value,
137
+ })),
138
+ )
139
+
140
+ // Affected products: parse CPE data from configurations
141
+ const affectedProducts = (cve.configurations ?? []).flatMap((cfg) =>
142
+ (cfg.nodes ?? []).flatMap((node) =>
143
+ (node.cpeMatch ?? [])
144
+ .filter((m) => m.vulnerable)
145
+ .map((m) => {
146
+ // cpe:2.3:a:vendor:product:version:...
147
+ const parts = (m.criteria ?? '').split(':')
148
+ const vendor = parts[3] ?? 'unknown'
149
+ const product = parts[4] ?? 'unknown'
150
+ const versionStart = m.versionStartIncluding ?? m.versionStartExcluding ?? ''
151
+ const versionEnd = m.versionEndExcluding ?? m.versionEndIncluding ?? ''
152
+ const versions = versionStart && versionEnd
153
+ ? `${versionStart} to ${versionEnd}`
154
+ : versionStart || versionEnd || (parts[5] ?? '*')
155
+ return { vendor, product, versions }
156
+ }),
157
+ ),
158
+ )
159
+
160
+ // References
161
+ const references = (cve.references ?? []).map((r) => ({
162
+ url: r.url ?? '',
163
+ source: r.source ?? '',
164
+ tags: Array.isArray(r.tags) ? r.tags : [],
165
+ }))
166
+
167
+ return {
168
+ id: cve.id,
169
+ description: getEnDescription(cve.descriptions),
170
+ severity,
171
+ score,
172
+ cvssVector: vector,
173
+ publishedDate: cve.published,
174
+ lastModified: cve.lastModified,
175
+ status: cve.vulnStatus ?? '',
176
+ weaknesses,
177
+ affectedProducts,
178
+ references,
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Search CVEs by keyword within a date window.
184
+ * @param {Object} options
185
+ * @param {string} [options.keyword] - Search keyword (optional — omit to return all recent CVEs)
186
+ * @param {number} [options.days=14] - Look-back window in days
187
+ * @param {string} [options.severity] - Optional minimum severity filter (low|medium|high|critical)
188
+ * @param {number} [options.limit=20] - Maximum results to return
189
+ * @returns {Promise<{ results: CveSearchResult[], totalResults: number }>}
190
+ */
191
+ export async function searchCves({ keyword, days = 14, severity, limit = 20 }) {
192
+ const config = await loadConfig()
193
+ const apiKey = config.nvd?.apiKey
194
+
195
+ const now = new Date()
196
+ const past = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
197
+
198
+ // NVD requires ISO-8601 with time component, no trailing Z
199
+ const pubStartDate = past.toISOString().replace('Z', '')
200
+ const pubEndDate = now.toISOString().replace('Z', '')
201
+
202
+ const trimmedKeyword = keyword?.trim()
203
+
204
+ const params = buildParams({
205
+ ...(trimmedKeyword ? { keywordSearch: trimmedKeyword } : {}),
206
+ pubStartDate,
207
+ pubEndDate,
208
+ resultsPerPage: limit,
209
+ ...(severity ? { cvssV3Severity: severity.toUpperCase() } : {}),
210
+ })
211
+
212
+ const data = /** @type {any} */ (await nvdFetch(params, apiKey))
213
+
214
+ const results = (data.vulnerabilities ?? []).map(parseCveSearchResult)
215
+ return { results, totalResults: data.totalResults ?? results.length }
216
+ }
217
+
218
+ /**
219
+ * Fetch full details for a single CVE by ID.
220
+ * @param {string} cveId - CVE identifier (e.g. "CVE-2021-44228")
221
+ * @returns {Promise<CveDetail>}
222
+ */
223
+ export async function getCveDetail(cveId) {
224
+ if (!cveId || !/^CVE-\d{4}-\d{4,}$/i.test(cveId)) {
225
+ throw new DvmiError(
226
+ `Invalid CVE ID: ${cveId}`,
227
+ 'CVE IDs must match the format CVE-YYYY-NNNNN (e.g. CVE-2021-44228)',
228
+ )
229
+ }
230
+
231
+ const config = await loadConfig()
232
+ const apiKey = config.nvd?.apiKey
233
+
234
+ const params = buildParams({ cveId: cveId.toUpperCase() })
235
+ const data = /** @type {any} */ (await nvdFetch(params, apiKey))
236
+
237
+ if (!data.vulnerabilities || data.vulnerabilities.length === 0) {
238
+ throw new DvmiError(
239
+ `CVE not found: ${cveId}`,
240
+ 'Verify the CVE ID is correct and exists in the NVD database.',
241
+ )
242
+ }
243
+
244
+ return parseCveDetail(data.vulnerabilities[0])
245
+ }
package/src/types.js CHANGED
@@ -14,6 +14,70 @@
14
14
  * @property {string} [latestVersion] - Latest known CLI version
15
15
  * @property {'opencode'|'copilot'} [aiTool] - Preferred AI tool for running prompts
16
16
  * @property {string} [promptsDir] - Local directory for downloaded prompts (default: .prompts)
17
+ * @property {DotfilesConfig} [dotfiles] - Chezmoi dotfiles configuration
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} DotfilesConfig
22
+ * @property {boolean} enabled - Whether chezmoi dotfiles management is active
23
+ * @property {string} [repo] - Remote dotfiles repository URL
24
+ * @property {string[]} [customSensitivePatterns] - User-added sensitive path patterns
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} DotfileEntry
29
+ * @property {string} path - Target file path (e.g. "/home/user/.zshrc")
30
+ * @property {string} sourcePath - Source path in chezmoi state
31
+ * @property {boolean} encrypted - Whether the file is stored with encryption
32
+ * @property {'file'|'dir'|'symlink'} type - Entry type
33
+ */
34
+
35
+ /**
36
+ * @typedef {Object} DotfileRecommendation
37
+ * @property {string} path - File path to recommend (e.g. "~/.zshrc")
38
+ * @property {'shell'|'git'|'editor'|'package'|'security'} category - Display grouping
39
+ * @property {Platform[]} platforms - Platforms this file applies to
40
+ * @property {boolean} autoEncrypt - Whether to encrypt by default
41
+ * @property {string} description - Human-readable description
42
+ */
43
+
44
+ /**
45
+ * @typedef {Object} DotfilesSetupResult
46
+ * @property {Platform} platform - Detected platform
47
+ * @property {boolean} chezmoiInstalled - Whether chezmoi was found
48
+ * @property {boolean} encryptionConfigured - Whether age encryption is set up
49
+ * @property {string|null} sourceDir - Chezmoi source directory path
50
+ * @property {string|null} publicKey - Age public key
51
+ * @property {'success'|'skipped'|'failed'} status - Overall setup outcome
52
+ * @property {string} [message] - Human-readable outcome message
53
+ */
54
+
55
+ /**
56
+ * @typedef {Object} DotfilesStatusResult
57
+ * @property {Platform} platform - Detected platform
58
+ * @property {boolean} enabled - Whether dotfiles management is active
59
+ * @property {boolean} chezmoiInstalled - Whether chezmoi binary exists
60
+ * @property {boolean} encryptionConfigured - Whether age encryption is set up
61
+ * @property {string|null} repo - Remote dotfiles repo URL
62
+ * @property {string|null} sourceDir - Chezmoi source directory
63
+ * @property {DotfileEntry[]} files - All managed files
64
+ * @property {{ total: number, encrypted: number, plaintext: number }} summary - File counts
65
+ */
66
+
67
+ /**
68
+ * @typedef {Object} DotfilesAddResult
69
+ * @property {{ path: string, encrypted: boolean }[]} added - Successfully added files
70
+ * @property {{ path: string, reason: string }[]} skipped - Skipped files
71
+ * @property {{ path: string, reason: string }[]} rejected - Rejected files
72
+ */
73
+
74
+ /**
75
+ * @typedef {Object} DotfilesSyncResult
76
+ * @property {'push'|'pull'|'init-remote'|'skipped'} action - Sync action performed
77
+ * @property {string|null} repo - Remote repository URL
78
+ * @property {'success'|'skipped'|'failed'} status - Outcome
79
+ * @property {string} [message] - Human-readable outcome
80
+ * @property {string[]} [conflicts] - Conflicting file paths
17
81
  */
18
82
 
19
83
  /**
@@ -294,11 +358,15 @@
294
358
  */
295
359
 
296
360
  /**
297
- * @typedef {Object} SearchMatch
298
- * @property {string} file - File path (e.g. "docs/deploy.md")
299
- * @property {number} line - Line number (1-based)
300
- * @property {string} context - Line text containing the match
301
- * @property {number} occurrences - Total number of occurrences in the file
361
+ * @typedef {Object} CveSearchResult
362
+ * Represents a single CVE returned from a search query. Used by `dvmi vuln search`.
363
+ * @property {string} id
364
+ * @property {string} description
365
+ * @property {'Critical'|'High'|'Medium'|'Low'|'Unknown'} severity
366
+ * @property {number|null} score
367
+ * @property {string} publishedDate
368
+ * @property {string} lastModified
369
+ * @property {string|null} firstReference - First reference URL from the CVE record, or null
302
370
  */
303
371
 
304
372
  /**
@@ -28,6 +28,8 @@ export class ValidationError extends DvmiError {
28
28
  constructor(message, hint) {
29
29
  super(message, hint, 2)
30
30
  this.name = 'ValidationError'
31
+ // oclif reads this.oclif.exit to determine the process exit code
32
+ this.oclif = { exit: 2 }
31
33
  }
32
34
  }
33
35
 
@@ -0,0 +1,224 @@
1
+ import chalk from 'chalk'
2
+ import { NVD_ATTRIBUTION } from '../../services/nvd.js'
3
+
4
+ // ──────────────────────────────────────────────────────────────────────────────
5
+ // ANSI escape sequences (re-declared locally — avoids cross-module coupling)
6
+ // ──────────────────────────────────────────────────────────────────────────────
7
+
8
+ const ANSI_CLEAR = '\x1b[2J'
9
+ const ANSI_HOME = '\x1b[H'
10
+
11
+ // ──────────────────────────────────────────────────────────────────────────────
12
+ // Internal helpers
13
+ // ──────────────────────────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Clamp a value between min and max (inclusive).
17
+ * @param {number} val
18
+ * @param {number} min
19
+ * @param {number} max
20
+ * @returns {number}
21
+ */
22
+ function clamp(val, min, max) {
23
+ return Math.min(Math.max(val, min), max)
24
+ }
25
+
26
+ /**
27
+ * Center a string within a fixed width, padding with spaces on both sides.
28
+ * @param {string} text - Plain text (no ANSI codes)
29
+ * @param {number} width
30
+ * @returns {string}
31
+ */
32
+ function centerText(text, width) {
33
+ if (text.length >= width) return text.slice(0, width)
34
+ const totalPad = width - text.length
35
+ const left = Math.floor(totalPad / 2)
36
+ const right = totalPad - left
37
+ return ' '.repeat(left) + text + ' '.repeat(right)
38
+ }
39
+
40
+ // ──────────────────────────────────────────────────────────────────────────────
41
+ // T006: buildModalScreen
42
+ // ──────────────────────────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Build the full modal overlay screen string from the current interactive state.
46
+ * Renders the modal content lines with a border, scroll indicators, and footer hints.
47
+ * Prepends ANSI clear + home to replace the previous frame.
48
+ * @param {import('./navigable-table.js').InteractiveTableState} state
49
+ * @returns {string}
50
+ */
51
+ export function buildModalScreen(state) {
52
+ const { modalContent, modalScrollOffset, termRows, termCols, firstRefUrl } = state
53
+
54
+ const lines = []
55
+
56
+ // Modal inner width: leave 4 chars for left/right borders + padding
57
+ const innerWidth = Math.max(20, termCols - 4)
58
+
59
+ // ── Title bar ──────────────────────────────────────────────────────────────
60
+ const titleText = 'CVE Detail'
61
+ lines.push(chalk.bold.cyan('╔' + '═'.repeat(innerWidth + 2) + '╗'))
62
+ lines.push(chalk.bold.cyan('║ ') + chalk.bold(centerText(titleText, innerWidth)) + chalk.bold.cyan(' ║'))
63
+ lines.push(chalk.bold.cyan('╠' + '═'.repeat(innerWidth + 2) + '╣'))
64
+
65
+ const BORDER_LINES = 3 // title bar: top + title + divider
66
+ const FOOTER_LINES = 4 // empty + attribution + hints + bottom border
67
+ const contentViewport = Math.max(1, termRows - BORDER_LINES - FOOTER_LINES)
68
+
69
+ const content = modalContent ?? []
70
+ const maxOffset = Math.max(0, content.length - contentViewport)
71
+ const safeOffset = clamp(modalScrollOffset, 0, maxOffset)
72
+
73
+ // ── Content lines ──────────────────────────────────────────────────────────
74
+ const visibleLines = content.slice(safeOffset, safeOffset + contentViewport)
75
+ for (const line of visibleLines) {
76
+ // Truncate to inner width to avoid terminal wrapping
77
+ const truncated = line.length > innerWidth ? line.slice(0, innerWidth - 1) + '…' : line
78
+ lines.push(chalk.bold.cyan('║ ') + truncated.padEnd(innerWidth) + chalk.bold.cyan(' ║'))
79
+ }
80
+ // Pad to fill the content viewport
81
+ for (let i = visibleLines.length; i < contentViewport; i++) {
82
+ lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
83
+ }
84
+
85
+ // ── Footer ─────────────────────────────────────────────────────────────────
86
+ lines.push(chalk.bold.cyan('╠' + '═'.repeat(innerWidth + 2) + '╣'))
87
+ lines.push(chalk.bold.cyan('║ ') + chalk.dim(NVD_ATTRIBUTION).slice(0, innerWidth).padEnd(innerWidth) + chalk.bold.cyan(' ║'))
88
+
89
+ const scrollHint = content.length > contentViewport ? ' ↑↓/PgUp/PgDn scroll' : ''
90
+ const openHint = firstRefUrl ? ' o open ref' : ''
91
+ const hintLine = ` ↑↓ scroll Esc back to table q exit${openHint}${scrollHint}`
92
+ const truncHint = hintLine.length > innerWidth ? hintLine.slice(0, innerWidth - 1) + '…' : hintLine
93
+ lines.push(chalk.bold.cyan('║ ') + chalk.dim(truncHint).padEnd(innerWidth) + chalk.bold.cyan(' ║'))
94
+ lines.push(chalk.bold.cyan('╚' + '═'.repeat(innerWidth + 2) + '╝'))
95
+
96
+ return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
97
+ }
98
+
99
+ // ──────────────────────────────────────────────────────────────────────────────
100
+ // T007: buildLoadingScreen
101
+ // ──────────────────────────────────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Build a "loading detail" screen to display while a CVE detail fetch is in flight.
105
+ * @param {string} cveId - The CVE identifier being fetched
106
+ * @param {number} termRows - Current terminal height
107
+ * @param {number} termCols - Current terminal width
108
+ * @returns {string}
109
+ */
110
+ export function buildLoadingScreen(cveId, termRows, termCols) {
111
+ const lines = []
112
+ const innerWidth = Math.max(20, termCols - 4)
113
+ const midRow = Math.floor(termRows / 2)
114
+
115
+ lines.push(chalk.bold.cyan('╔' + '═'.repeat(innerWidth + 2) + '╗'))
116
+
117
+ for (let i = 1; i < midRow - 1; i++) {
118
+ lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
119
+ }
120
+
121
+ const loadingText = `Loading ${cveId}…`
122
+ const centred = centerText(loadingText, innerWidth)
123
+ lines.push(chalk.bold.cyan('║ ') + chalk.yellow(centred) + chalk.bold.cyan(' ║'))
124
+
125
+ const remaining = termRows - lines.length - 1
126
+ for (let i = 0; i < remaining; i++) {
127
+ lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
128
+ }
129
+
130
+ lines.push(chalk.bold.cyan('╚' + '═'.repeat(innerWidth + 2) + '╝'))
131
+
132
+ return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
133
+ }
134
+
135
+ // ──────────────────────────────────────────────────────────────────────────────
136
+ // T008: buildErrorScreen
137
+ // ──────────────────────────────────────────────────────────────────────────────
138
+
139
+ /**
140
+ * Build an error modal screen displayed when a CVE detail fetch fails.
141
+ * @param {string} cveId - The CVE identifier that failed to load
142
+ * @param {string} errorMessage - The error message to display
143
+ * @param {number} termRows - Current terminal height
144
+ * @param {number} termCols - Current terminal width
145
+ * @returns {string}
146
+ */
147
+ export function buildErrorScreen(cveId, errorMessage, termRows, termCols) {
148
+ const lines = []
149
+ const innerWidth = Math.max(20, termCols - 4)
150
+ const midRow = Math.floor(termRows / 2)
151
+
152
+ lines.push(chalk.bold.cyan('╔' + '═'.repeat(innerWidth + 2) + '╗'))
153
+
154
+ for (let i = 1; i < midRow - 2; i++) {
155
+ lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
156
+ }
157
+
158
+ const titleText = `Failed to load ${cveId}`
159
+ lines.push(chalk.bold.cyan('║ ') + chalk.red.bold(centerText(titleText, innerWidth)) + chalk.bold.cyan(' ║'))
160
+ lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
161
+
162
+ const truncErr =
163
+ errorMessage.length > innerWidth ? errorMessage.slice(0, innerWidth - 1) + '…' : errorMessage
164
+ lines.push(chalk.bold.cyan('║ ') + chalk.red(truncErr.padEnd(innerWidth)) + chalk.bold.cyan(' ║'))
165
+
166
+ const remaining = termRows - lines.length - 2
167
+ for (let i = 0; i < remaining; i++) {
168
+ lines.push(chalk.bold.cyan('║ ') + ' '.repeat(innerWidth) + chalk.bold.cyan(' ║'))
169
+ }
170
+
171
+ const hintText = centerText('Press Esc to return to the table', innerWidth)
172
+ lines.push(chalk.bold.cyan('║ ') + chalk.dim(hintText) + chalk.bold.cyan(' ║'))
173
+ lines.push(chalk.bold.cyan('╚' + '═'.repeat(innerWidth + 2) + '╝'))
174
+
175
+ return ANSI_CLEAR + ANSI_HOME + lines.join('\n')
176
+ }
177
+
178
+ // ──────────────────────────────────────────────────────────────────────────────
179
+ // T015: handleModalKeypress
180
+ // ──────────────────────────────────────────────────────────────────────────────
181
+
182
+ /**
183
+ * Pure state reducer for keypresses in the modal view.
184
+ * Returns a new state on scroll, a control object on exit/back/open-url, or unchanged state.
185
+ * @param {import('./navigable-table.js').InteractiveTableState} state
186
+ * @param {{ name: string, ctrl?: boolean }} key - readline keypress event
187
+ * @returns {
188
+ * import('./navigable-table.js').InteractiveTableState |
189
+ * { backToTable: true } |
190
+ * { exit: true } |
191
+ * { openUrl: string }
192
+ * }
193
+ */
194
+ export function handleModalKeypress(state, key) {
195
+ const { modalContent, modalScrollOffset, termRows, firstRefUrl } = state
196
+
197
+ if (key.ctrl && key.name === 'c') return { exit: true }
198
+ if (key.name === 'q') return { exit: true }
199
+
200
+ if (key.name === 'escape') return { backToTable: true }
201
+
202
+ if (key.name === 'o' && firstRefUrl) return { openUrl: firstRefUrl }
203
+
204
+ const contentLen = modalContent ? modalContent.length : 0
205
+ const BORDER_LINES = 3
206
+ const FOOTER_LINES = 4
207
+ const contentViewport = Math.max(1, termRows - BORDER_LINES - FOOTER_LINES)
208
+ const maxOffset = Math.max(0, contentLen - contentViewport)
209
+
210
+ if (key.name === 'up') {
211
+ return { ...state, modalScrollOffset: clamp(modalScrollOffset - 1, 0, maxOffset) }
212
+ }
213
+ if (key.name === 'down') {
214
+ return { ...state, modalScrollOffset: clamp(modalScrollOffset + 1, 0, maxOffset) }
215
+ }
216
+ if (key.name === 'pageup') {
217
+ return { ...state, modalScrollOffset: clamp(modalScrollOffset - contentViewport, 0, maxOffset) }
218
+ }
219
+ if (key.name === 'pagedown') {
220
+ return { ...state, modalScrollOffset: clamp(modalScrollOffset + contentViewport, 0, maxOffset) }
221
+ }
222
+
223
+ return state // unrecognized key — no state change
224
+ }