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.
- package/oclif.manifest.json +397 -1
- package/package.json +1 -1
- package/src/commands/dotfiles/add.js +249 -0
- package/src/commands/dotfiles/setup.js +190 -0
- package/src/commands/dotfiles/status.js +103 -0
- package/src/commands/dotfiles/sync.js +375 -0
- package/src/commands/init.js +35 -2
- package/src/commands/vuln/detail.js +65 -0
- package/src/commands/vuln/scan.js +155 -0
- package/src/commands/vuln/search.js +128 -0
- package/src/formatters/dotfiles.js +259 -0
- package/src/formatters/vuln.js +317 -0
- package/src/help.js +62 -2
- package/src/services/audit-detector.js +120 -0
- package/src/services/audit-runner.js +365 -0
- package/src/services/dotfiles.js +573 -0
- package/src/services/nvd.js +245 -0
- package/src/types.js +73 -5
- package/src/utils/errors.js +2 -0
- package/src/utils/tui/modal.js +224 -0
- package/src/utils/tui/navigable-table.js +496 -0
|
@@ -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}
|
|
298
|
-
*
|
|
299
|
-
* @property {
|
|
300
|
-
* @property {string}
|
|
301
|
-
* @property {
|
|
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
|
/**
|
package/src/utils/errors.js
CHANGED
|
@@ -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
|
+
}
|