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
package/src/commands/init.js
CHANGED
|
@@ -8,8 +8,8 @@ import { detectPlatform } from '../services/platform.js'
|
|
|
8
8
|
import { exec, which } from '../services/shell.js'
|
|
9
9
|
import { configExists, loadConfig, saveConfig, CONFIG_PATH } from '../services/config.js'
|
|
10
10
|
import { oauthFlow, storeToken, validateToken, getTeams } from '../services/clickup.js'
|
|
11
|
-
|
|
12
11
|
import { SUPPORTED_TOOLS } from '../services/prompts.js'
|
|
12
|
+
import { isChezmoiInstalled, setupChezmoiInline } from '../services/dotfiles.js'
|
|
13
13
|
|
|
14
14
|
export default class Init extends Command {
|
|
15
15
|
static description = 'Setup completo ambiente di sviluppo locale'
|
|
@@ -243,7 +243,40 @@ export default class Init extends Command {
|
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
// 7.
|
|
246
|
+
// 7. Chezmoi dotfiles setup
|
|
247
|
+
if (isDryRun) {
|
|
248
|
+
steps.push({ name: 'dotfiles', status: 'would configure' })
|
|
249
|
+
} else if (isJson) {
|
|
250
|
+
config = await loadConfig()
|
|
251
|
+
steps.push({
|
|
252
|
+
name: 'dotfiles',
|
|
253
|
+
status: config.dotfiles?.enabled ? 'configured' : 'not_configured',
|
|
254
|
+
enabled: config.dotfiles?.enabled ?? false,
|
|
255
|
+
})
|
|
256
|
+
} else {
|
|
257
|
+
const chezmoiInstalled = await isChezmoiInstalled()
|
|
258
|
+
if (!chezmoiInstalled) {
|
|
259
|
+
steps.push({ name: 'dotfiles', status: 'skipped', reason: 'chezmoi not installed' })
|
|
260
|
+
} else {
|
|
261
|
+
const setupDotfiles = await confirm({
|
|
262
|
+
message: 'Set up chezmoi dotfiles management with age encryption?',
|
|
263
|
+
default: false,
|
|
264
|
+
})
|
|
265
|
+
if (setupDotfiles) {
|
|
266
|
+
try {
|
|
267
|
+
const dotfilesResult = await setupChezmoiInline(platform.platform)
|
|
268
|
+
config = await loadConfig()
|
|
269
|
+
steps.push({ name: 'dotfiles', status: dotfilesResult.status, sourceDir: dotfilesResult.sourceDir })
|
|
270
|
+
} catch (err) {
|
|
271
|
+
steps.push({ name: 'dotfiles', status: 'failed', reason: err instanceof Error ? err.message : String(err) })
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
steps.push({ name: 'dotfiles', status: 'skipped', hint: 'Run `dvmi dotfiles setup` anytime to enable' })
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 8. Shell completions
|
|
247
280
|
steps.push({ name: 'shell-completions', status: 'ok', action: 'install via: dvmi autocomplete' })
|
|
248
281
|
|
|
249
282
|
const result = { steps, configPath: CONFIG_PATH }
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Command, Args, Flags } from '@oclif/core'
|
|
2
|
+
import ora from 'ora'
|
|
3
|
+
import { getCveDetail } from '../../services/nvd.js'
|
|
4
|
+
import { formatCveDetail } from '../../formatters/vuln.js'
|
|
5
|
+
import { openBrowser } from '../../utils/open-browser.js'
|
|
6
|
+
import { ValidationError } from '../../utils/errors.js'
|
|
7
|
+
|
|
8
|
+
export default class VulnDetail extends Command {
|
|
9
|
+
static description = 'View full details for a specific CVE'
|
|
10
|
+
|
|
11
|
+
static examples = [
|
|
12
|
+
'<%= config.bin %> vuln detail CVE-2021-44228',
|
|
13
|
+
'<%= config.bin %> vuln detail CVE-2021-44228 --open',
|
|
14
|
+
'<%= config.bin %> vuln detail CVE-2021-44228 --json',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
static enableJsonFlag = true
|
|
18
|
+
|
|
19
|
+
static args = {
|
|
20
|
+
cveId: Args.string({ description: 'CVE identifier (e.g. CVE-2021-44228)', required: true }),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static flags = {
|
|
24
|
+
open: Flags.boolean({
|
|
25
|
+
char: 'o',
|
|
26
|
+
description: 'Open the first reference URL in the default browser',
|
|
27
|
+
default: false,
|
|
28
|
+
}),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async run() {
|
|
32
|
+
const { args, flags } = await this.parse(VulnDetail)
|
|
33
|
+
const isJson = flags.json
|
|
34
|
+
const { cveId } = args
|
|
35
|
+
|
|
36
|
+
if (!cveId || !/^CVE-\d{4}-\d{4,}$/i.test(cveId)) {
|
|
37
|
+
throw new ValidationError(
|
|
38
|
+
`Invalid CVE ID: ${cveId}`,
|
|
39
|
+
'CVE IDs must match the format CVE-YYYY-NNNNN (e.g. CVE-2021-44228)',
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const spinner = isJson ? null : ora(`Fetching ${cveId.toUpperCase()}...`).start()
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const detail = await getCveDetail(cveId)
|
|
47
|
+
spinner?.stop()
|
|
48
|
+
|
|
49
|
+
if (isJson) return detail
|
|
50
|
+
|
|
51
|
+
this.log(formatCveDetail(detail))
|
|
52
|
+
|
|
53
|
+
if (flags.open && detail.references.length > 0) {
|
|
54
|
+
const firstUrl = detail.references[0].url
|
|
55
|
+
this.log(`\nOpening ${firstUrl} ...`)
|
|
56
|
+
await openBrowser(firstUrl)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return detail
|
|
60
|
+
} catch (err) {
|
|
61
|
+
spinner?.stop()
|
|
62
|
+
throw err
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core'
|
|
2
|
+
import { writeFile } from 'node:fs/promises'
|
|
3
|
+
import ora from 'ora'
|
|
4
|
+
import chalk from 'chalk'
|
|
5
|
+
import { detectEcosystems, supportedEcosystemsMessage } from '../../services/audit-detector.js'
|
|
6
|
+
import { runAudit, summarizeFindings, filterBySeverity } from '../../services/audit-runner.js'
|
|
7
|
+
import { formatFindingsTable, formatScanSummary, formatMarkdownReport } from '../../formatters/vuln.js'
|
|
8
|
+
|
|
9
|
+
export default class VulnScan extends Command {
|
|
10
|
+
static description = 'Scan the current directory for known vulnerabilities in dependencies'
|
|
11
|
+
|
|
12
|
+
static examples = [
|
|
13
|
+
'<%= config.bin %> vuln scan',
|
|
14
|
+
'<%= config.bin %> vuln scan --severity high',
|
|
15
|
+
'<%= config.bin %> vuln scan --no-fail',
|
|
16
|
+
'<%= config.bin %> vuln scan --report vuln-report.md',
|
|
17
|
+
'<%= config.bin %> vuln scan --json',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
static enableJsonFlag = true
|
|
21
|
+
|
|
22
|
+
static flags = {
|
|
23
|
+
severity: Flags.string({
|
|
24
|
+
char: 's',
|
|
25
|
+
description: 'Minimum severity filter',
|
|
26
|
+
options: ['low', 'medium', 'high', 'critical'],
|
|
27
|
+
}),
|
|
28
|
+
'no-fail': Flags.boolean({
|
|
29
|
+
description: 'Exit with code 0 even when vulnerabilities are found',
|
|
30
|
+
default: false,
|
|
31
|
+
}),
|
|
32
|
+
report: Flags.string({
|
|
33
|
+
char: 'r',
|
|
34
|
+
description: 'Export vulnerability report to file path (Markdown format)',
|
|
35
|
+
}),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async run() {
|
|
39
|
+
const { flags } = await this.parse(VulnScan)
|
|
40
|
+
const isJson = flags.json
|
|
41
|
+
const { severity, 'no-fail': noFail, report } = flags
|
|
42
|
+
|
|
43
|
+
const projectPath = process.env.DVMI_SCAN_DIR ?? process.cwd()
|
|
44
|
+
const scanDate = new Date().toISOString()
|
|
45
|
+
|
|
46
|
+
// Detect ecosystems
|
|
47
|
+
const ecosystems = detectEcosystems(projectPath)
|
|
48
|
+
|
|
49
|
+
if (ecosystems.length === 0) {
|
|
50
|
+
if (isJson) {
|
|
51
|
+
return {
|
|
52
|
+
projectPath,
|
|
53
|
+
scanDate,
|
|
54
|
+
ecosystems: [],
|
|
55
|
+
findings: [],
|
|
56
|
+
summary: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0, total: 0 },
|
|
57
|
+
errors: [{ ecosystem: 'none', message: 'No supported package manager detected.' }],
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.log(chalk.red(' ✘ No supported package manager detected.'))
|
|
62
|
+
this.log('')
|
|
63
|
+
this.log(' Supported ecosystems:')
|
|
64
|
+
this.log(supportedEcosystemsMessage())
|
|
65
|
+
this.log('')
|
|
66
|
+
this.log(' Tip: Make sure you have a lock file in the current directory.')
|
|
67
|
+
this.exit(2)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Display detected ecosystems
|
|
72
|
+
if (!isJson) {
|
|
73
|
+
this.log(chalk.bold('Vulnerability Scan'))
|
|
74
|
+
this.log('')
|
|
75
|
+
this.log(' Detected ecosystems:')
|
|
76
|
+
for (const eco of ecosystems) {
|
|
77
|
+
this.log(` ${chalk.green('●')} ${eco.name} (${eco.lockFile})`)
|
|
78
|
+
}
|
|
79
|
+
this.log('')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Run audits
|
|
83
|
+
const allFindings = []
|
|
84
|
+
const errors = []
|
|
85
|
+
|
|
86
|
+
for (const eco of ecosystems) {
|
|
87
|
+
const spinner = isJson ? null : ora(` Scanning ${eco.name} dependencies...`).start()
|
|
88
|
+
|
|
89
|
+
const { findings, error } = await runAudit(eco)
|
|
90
|
+
|
|
91
|
+
if (error) {
|
|
92
|
+
spinner?.fail(` Scanning ${eco.name} dependencies... failed`)
|
|
93
|
+
errors.push({ ecosystem: eco.name, message: error })
|
|
94
|
+
} else {
|
|
95
|
+
spinner?.succeed(` Scanning ${eco.name} dependencies... done`)
|
|
96
|
+
allFindings.push(...findings)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Apply severity filter
|
|
101
|
+
const filteredFindings = filterBySeverity(allFindings, severity)
|
|
102
|
+
|
|
103
|
+
// Build summary
|
|
104
|
+
const summary = summarizeFindings(filteredFindings)
|
|
105
|
+
|
|
106
|
+
const result = {
|
|
107
|
+
projectPath,
|
|
108
|
+
scanDate,
|
|
109
|
+
ecosystems,
|
|
110
|
+
findings: filteredFindings,
|
|
111
|
+
summary,
|
|
112
|
+
errors,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Write report if requested
|
|
116
|
+
if (report) {
|
|
117
|
+
const markdown = formatMarkdownReport(result)
|
|
118
|
+
await writeFile(report, markdown, 'utf8')
|
|
119
|
+
if (!isJson) this.log(`\n Report saved to: ${report}`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isJson) return result
|
|
123
|
+
|
|
124
|
+
this.log('')
|
|
125
|
+
|
|
126
|
+
if (filteredFindings.length === 0 && errors.length === 0) {
|
|
127
|
+
this.log(chalk.green(' ✔ No known vulnerabilities found.'))
|
|
128
|
+
return result
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (filteredFindings.length > 0) {
|
|
132
|
+
this.log(chalk.bold(` Findings (${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'})`))
|
|
133
|
+
this.log('')
|
|
134
|
+
this.log(formatFindingsTable(filteredFindings))
|
|
135
|
+
this.log('')
|
|
136
|
+
this.log(chalk.bold(' Summary'))
|
|
137
|
+
this.log(formatScanSummary(summary))
|
|
138
|
+
this.log('')
|
|
139
|
+
this.log(chalk.yellow(` ⚠ ${filteredFindings.length} ${filteredFindings.length === 1 ? 'vulnerability' : 'vulnerabilities'} found. Run \`dvmi vuln detail <CVE-ID>\` for details.`))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (errors.length > 0) {
|
|
143
|
+
this.log('')
|
|
144
|
+
for (const err of errors) {
|
|
145
|
+
this.log(chalk.red(` ✘ ${err.ecosystem}: ${err.message}`))
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (filteredFindings.length > 0 && !noFail) {
|
|
150
|
+
this.exit(1)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return result
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Command, Args, Flags } from '@oclif/core'
|
|
2
|
+
import ora from 'ora'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { searchCves, getCveDetail } from '../../services/nvd.js'
|
|
5
|
+
import { formatCveSearchTable, colorSeverity, formatScore, formatDate, truncate } from '../../formatters/vuln.js'
|
|
6
|
+
import { startInteractiveTable } from '../../utils/tui/navigable-table.js'
|
|
7
|
+
import { ValidationError } from '../../utils/errors.js'
|
|
8
|
+
|
|
9
|
+
// Minimum terminal rows required to show the interactive TUI
|
|
10
|
+
const MIN_TTY_ROWS = 6
|
|
11
|
+
|
|
12
|
+
// Column widths for the navigable table
|
|
13
|
+
const COL_WIDTHS = {
|
|
14
|
+
id: 20,
|
|
15
|
+
severity: 10,
|
|
16
|
+
score: 5,
|
|
17
|
+
published: 10,
|
|
18
|
+
reference: 30,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default class VulnSearch extends Command {
|
|
22
|
+
static description = 'Search for recent CVEs by keyword (omit keyword to see all recent CVEs)'
|
|
23
|
+
|
|
24
|
+
static examples = [
|
|
25
|
+
'<%= config.bin %> vuln search openssl',
|
|
26
|
+
'<%= config.bin %> vuln search openssl --days 30',
|
|
27
|
+
'<%= config.bin %> vuln search log4j --severity critical',
|
|
28
|
+
'<%= config.bin %> vuln search nginx --limit 10 --json',
|
|
29
|
+
'<%= config.bin %> vuln search',
|
|
30
|
+
'<%= config.bin %> vuln search --days 7 --severity high',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
static enableJsonFlag = true
|
|
34
|
+
|
|
35
|
+
static args = {
|
|
36
|
+
keyword: Args.string({ description: 'Product, library, or keyword to search for (optional — omit to see all recent CVEs)', required: false }),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static flags = {
|
|
40
|
+
days: Flags.integer({
|
|
41
|
+
char: 'd',
|
|
42
|
+
description: 'Time window in days (search CVEs published within last N days)',
|
|
43
|
+
default: 14,
|
|
44
|
+
}),
|
|
45
|
+
severity: Flags.string({
|
|
46
|
+
char: 's',
|
|
47
|
+
description: 'Minimum severity filter',
|
|
48
|
+
options: ['low', 'medium', 'high', 'critical'],
|
|
49
|
+
}),
|
|
50
|
+
limit: Flags.integer({
|
|
51
|
+
char: 'l',
|
|
52
|
+
description: 'Maximum number of results to display',
|
|
53
|
+
default: 20,
|
|
54
|
+
}),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async run() {
|
|
58
|
+
const { args, flags } = await this.parse(VulnSearch)
|
|
59
|
+
const isJson = flags.json
|
|
60
|
+
|
|
61
|
+
const { keyword } = args
|
|
62
|
+
const { days, severity, limit } = flags
|
|
63
|
+
|
|
64
|
+
if (days < 1 || days > 120) {
|
|
65
|
+
throw new ValidationError(
|
|
66
|
+
`--days must be between 1 and 120, got ${days}`,
|
|
67
|
+
'The NVD API supports a maximum 120-day date range per request.',
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (limit < 1 || limit > 2000) {
|
|
72
|
+
throw new ValidationError(
|
|
73
|
+
`--limit must be between 1 and 2000, got ${limit}`,
|
|
74
|
+
'The NVD API returns at most 2000 results per page.',
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const spinner = isJson ? null : ora(keyword ? `Searching NVD for "${keyword}"...` : `Fetching recent CVEs (last ${days} days)...`).start()
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const { results, totalResults } = await searchCves({ keyword, days, severity, limit })
|
|
82
|
+
spinner?.stop()
|
|
83
|
+
|
|
84
|
+
const result = { keyword: keyword ?? null, days, severity: severity ?? null, totalResults, results }
|
|
85
|
+
|
|
86
|
+
if (isJson) return result
|
|
87
|
+
|
|
88
|
+
this.log(formatCveSearchTable(results, keyword, days, totalResults))
|
|
89
|
+
|
|
90
|
+
// Interactive navigable table — only in a real TTY with enough rows, skipped in CI / --json / piped output
|
|
91
|
+
const ttyRows = process.stdout.rows ?? 0
|
|
92
|
+
if (process.stdout.isTTY && results.length > 0 && ttyRows >= MIN_TTY_ROWS) {
|
|
93
|
+
const heading = keyword
|
|
94
|
+
? `CVE Search: "${keyword}" (last ${days} days)`
|
|
95
|
+
: `CVE Search: all recent (last ${days} days)`
|
|
96
|
+
|
|
97
|
+
const termCols = process.stdout.columns || 80
|
|
98
|
+
const descWidth = Math.max(20, Math.min(60, termCols - 84))
|
|
99
|
+
|
|
100
|
+
const rows = results.map((r) => ({
|
|
101
|
+
id: r.id,
|
|
102
|
+
severity: r.severity,
|
|
103
|
+
score: formatScore(r.score),
|
|
104
|
+
published: formatDate(r.publishedDate),
|
|
105
|
+
description: truncate(r.description, descWidth),
|
|
106
|
+
reference: r.firstReference ? truncate(r.firstReference, COL_WIDTHS.reference) : '—',
|
|
107
|
+
}))
|
|
108
|
+
|
|
109
|
+
/** @type {import('../../utils/tui/navigable-table.js').TableColumnDef[]} */
|
|
110
|
+
const columns = [
|
|
111
|
+
{ header: 'CVE ID', key: 'id', width: COL_WIDTHS.id, colorize: (v) => chalk.cyan(v) },
|
|
112
|
+
{ header: 'Severity', key: 'severity', width: COL_WIDTHS.severity, colorize: (v) => colorSeverity(v) },
|
|
113
|
+
{ header: 'Score', key: 'score', width: COL_WIDTHS.score },
|
|
114
|
+
{ header: 'Published', key: 'published', width: COL_WIDTHS.published },
|
|
115
|
+
{ header: 'Description', key: 'description', width: descWidth },
|
|
116
|
+
{ header: 'Reference', key: 'reference', width: COL_WIDTHS.reference },
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
await startInteractiveTable(rows, columns, heading, totalResults, getCveDetail)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return result
|
|
123
|
+
} catch (err) {
|
|
124
|
+
spinner?.stop()
|
|
125
|
+
throw err
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
|
|
3
|
+
/** @import { DotfilesSetupResult, DotfilesStatusResult, DotfilesAddResult, DotfilesSyncResult, DotfileEntry } from '../types.js' */
|
|
4
|
+
|
|
5
|
+
const BORDER = chalk.dim('─'.repeat(60))
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// formatDotfilesSetup (T020)
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format the output of `dvmi dotfiles setup` completion.
|
|
13
|
+
* @param {DotfilesSetupResult} result
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function formatDotfilesSetup(result) {
|
|
17
|
+
const lines = [
|
|
18
|
+
'',
|
|
19
|
+
BORDER,
|
|
20
|
+
chalk.bold(' Dotfiles Setup — Summary'),
|
|
21
|
+
BORDER,
|
|
22
|
+
'',
|
|
23
|
+
chalk.bold(` Platform: ${chalk.cyan(result.platform)}`),
|
|
24
|
+
chalk.bold(` Status: ${result.status === 'success' ? chalk.green('success') : result.status === 'skipped' ? chalk.dim('skipped') : chalk.red('failed')}`),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
if (result.sourceDir) {
|
|
28
|
+
lines.push(chalk.white(` Source dir: ${chalk.cyan(result.sourceDir)}`))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (result.publicKey) {
|
|
32
|
+
lines.push('')
|
|
33
|
+
lines.push(chalk.white(` Age public key:`))
|
|
34
|
+
lines.push(chalk.cyan(` ${result.publicKey}`))
|
|
35
|
+
lines.push('')
|
|
36
|
+
lines.push(chalk.yellow(' IMPORTANT: Back up your age key!'))
|
|
37
|
+
lines.push(chalk.dim(` Key file: ~/.config/chezmoi/key.txt`))
|
|
38
|
+
lines.push(chalk.dim(' Without this key you cannot decrypt your dotfiles on a new machine.'))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (result.status === 'success') {
|
|
42
|
+
lines.push('')
|
|
43
|
+
lines.push(chalk.bold.green(' Chezmoi configured with age encryption!'))
|
|
44
|
+
lines.push(chalk.dim(' Run `dvmi dotfiles add` to start tracking files'))
|
|
45
|
+
} else if (result.status === 'failed') {
|
|
46
|
+
lines.push('')
|
|
47
|
+
lines.push(chalk.bold.red(' Setup failed.'))
|
|
48
|
+
if (result.message) lines.push(chalk.dim(` → ${result.message}`))
|
|
49
|
+
} else if (result.status === 'skipped') {
|
|
50
|
+
lines.push('')
|
|
51
|
+
if (result.message) lines.push(chalk.dim(` ${result.message}`))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
lines.push(BORDER)
|
|
55
|
+
return lines.join('\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// formatDotfilesSummary (T012)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Format the file count summary line.
|
|
64
|
+
* @param {{ total: number, encrypted: number, plaintext: number }} summary
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function formatDotfilesSummary(summary) {
|
|
68
|
+
return `${summary.total} total: ${summary.plaintext} plaintext, ${summary.encrypted} encrypted`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// formatDotfilesStatus (T012 / T032)
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Infer a display category from a file path.
|
|
77
|
+
* @param {string} filePath
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function inferCategory(filePath) {
|
|
81
|
+
const lower = filePath.toLowerCase()
|
|
82
|
+
if (lower.includes('.ssh') || lower.includes('.gnupg') || lower.includes('gpg') || lower.includes('secret') || lower.includes('credential') || lower.includes('token') || lower.includes('password')) return 'Security'
|
|
83
|
+
if (lower.includes('.gitconfig') || lower.includes('.gitignore') || lower.includes('.git')) return 'Git'
|
|
84
|
+
if (lower.includes('zshrc') || lower.includes('bashrc') || lower.includes('bash_profile') || lower.includes('zprofile') || lower.includes('fish')) return 'Shell'
|
|
85
|
+
if (lower.includes('vim') || lower.includes('nvim') || lower.includes('emacs') || lower.includes('vscode') || lower.includes('cursor')) return 'Editor'
|
|
86
|
+
if (lower.includes('brew') || lower.includes('npm') || lower.includes('yarn') || lower.includes('pip') || lower.includes('gem')) return 'Package'
|
|
87
|
+
return 'Other'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Format the full `dvmi dotfiles status` interactive output.
|
|
92
|
+
* @param {DotfilesStatusResult} result
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function formatDotfilesStatus(result) {
|
|
96
|
+
const lines = [
|
|
97
|
+
'',
|
|
98
|
+
BORDER,
|
|
99
|
+
chalk.bold(' Dotfiles Status'),
|
|
100
|
+
BORDER,
|
|
101
|
+
'',
|
|
102
|
+
chalk.white(` Platform: ${chalk.cyan(result.platform)}`),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
if (result.sourceDir) {
|
|
106
|
+
lines.push(chalk.white(` Source dir: ${chalk.cyan(result.sourceDir)}`))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const encLabel = result.encryptionConfigured ? chalk.green('age (configured)') : chalk.dim('not configured')
|
|
110
|
+
lines.push(chalk.white(` Encryption: ${encLabel}`))
|
|
111
|
+
|
|
112
|
+
if (result.repo) {
|
|
113
|
+
lines.push(chalk.white(` Remote: ${chalk.cyan(result.repo)}`))
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(chalk.white(` Remote: ${chalk.dim('not configured')}`))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!result.enabled) {
|
|
119
|
+
lines.push('')
|
|
120
|
+
lines.push(chalk.dim(' Dotfiles management not configured.'))
|
|
121
|
+
lines.push(chalk.dim(' Run `dvmi dotfiles setup` to get started.'))
|
|
122
|
+
lines.push(BORDER)
|
|
123
|
+
return lines.join('\n')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Group files by category
|
|
127
|
+
/** @type {Record<string, DotfileEntry[]>} */
|
|
128
|
+
const grouped = {}
|
|
129
|
+
for (const file of result.files) {
|
|
130
|
+
const category = inferCategory(file.path)
|
|
131
|
+
if (!grouped[category]) grouped[category] = []
|
|
132
|
+
grouped[category].push(file)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const summaryLine = formatDotfilesSummary(result.summary)
|
|
136
|
+
lines.push('')
|
|
137
|
+
lines.push(chalk.bold(` Managed Files (${summaryLine})`))
|
|
138
|
+
lines.push(BORDER)
|
|
139
|
+
|
|
140
|
+
for (const [category, files] of Object.entries(grouped)) {
|
|
141
|
+
const catLabel = category === 'Security' ? ` ${category} 🔒` : ` ${category}`
|
|
142
|
+
lines.push('')
|
|
143
|
+
lines.push(chalk.bold(catLabel))
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
const encTag = file.encrypted ? chalk.dim(' encrypted') : ''
|
|
146
|
+
lines.push(` ${file.path}${encTag}`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (result.files.length === 0) {
|
|
151
|
+
lines.push('')
|
|
152
|
+
lines.push(chalk.dim(' No files managed yet. Run `dvmi dotfiles add` to start tracking files.'))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
lines.push('')
|
|
156
|
+
lines.push(BORDER)
|
|
157
|
+
return lines.join('\n')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// formatDotfilesAdd (T027)
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Format the output of `dvmi dotfiles add` completion.
|
|
166
|
+
* @param {DotfilesAddResult} result
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function formatDotfilesAdd(result) {
|
|
170
|
+
const lines = [
|
|
171
|
+
'',
|
|
172
|
+
BORDER,
|
|
173
|
+
chalk.bold(' Dotfiles Add — Summary'),
|
|
174
|
+
BORDER,
|
|
175
|
+
'',
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
if (result.added.length > 0) {
|
|
179
|
+
lines.push(chalk.bold(` Added (${result.added.length}):`))
|
|
180
|
+
for (const item of result.added) {
|
|
181
|
+
const encTag = item.encrypted ? chalk.dim(' [encrypted]') : ''
|
|
182
|
+
lines.push(chalk.green(` ✔ ${item.path}${encTag}`))
|
|
183
|
+
}
|
|
184
|
+
lines.push('')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (result.skipped.length > 0) {
|
|
188
|
+
lines.push(chalk.bold(` Skipped (${result.skipped.length}):`))
|
|
189
|
+
for (const item of result.skipped) {
|
|
190
|
+
lines.push(chalk.dim(` ─ ${item.path} ${item.reason}`))
|
|
191
|
+
}
|
|
192
|
+
lines.push('')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (result.rejected.length > 0) {
|
|
196
|
+
lines.push(chalk.bold(` Rejected (${result.rejected.length}):`))
|
|
197
|
+
for (const item of result.rejected) {
|
|
198
|
+
lines.push(chalk.red(` ✗ ${item.path} ${item.reason}`))
|
|
199
|
+
}
|
|
200
|
+
lines.push('')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (result.added.length === 0 && result.skipped.length === 0 && result.rejected.length === 0) {
|
|
204
|
+
lines.push(chalk.dim(' No files processed.'))
|
|
205
|
+
lines.push('')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
lines.push(BORDER)
|
|
209
|
+
return lines.join('\n')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// formatDotfilesSync (T039 / T044)
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Format the output of `dvmi dotfiles sync` completion.
|
|
218
|
+
* @param {DotfilesSyncResult} result
|
|
219
|
+
* @returns {string}
|
|
220
|
+
*/
|
|
221
|
+
export function formatDotfilesSync(result) {
|
|
222
|
+
const actionLabel = {
|
|
223
|
+
push: 'Push',
|
|
224
|
+
pull: 'Pull',
|
|
225
|
+
'init-remote': 'Remote Setup',
|
|
226
|
+
skipped: 'Skipped',
|
|
227
|
+
}[result.action] ?? result.action
|
|
228
|
+
|
|
229
|
+
const lines = [
|
|
230
|
+
'',
|
|
231
|
+
BORDER,
|
|
232
|
+
chalk.bold(` Dotfiles Sync — ${actionLabel}`),
|
|
233
|
+
BORDER,
|
|
234
|
+
'',
|
|
235
|
+
chalk.white(` Action: ${chalk.cyan(actionLabel)}`),
|
|
236
|
+
chalk.white(` Status: ${result.status === 'success' ? chalk.green('success') : result.status === 'skipped' ? chalk.dim('skipped') : chalk.red('failed')}`),
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
if (result.repo) {
|
|
240
|
+
lines.push(chalk.white(` Remote: ${chalk.cyan(result.repo)}`))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (result.message) {
|
|
244
|
+
lines.push('')
|
|
245
|
+
lines.push(chalk.white(` ${result.message}`))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (result.conflicts && result.conflicts.length > 0) {
|
|
249
|
+
lines.push('')
|
|
250
|
+
lines.push(chalk.bold.red(` Conflicts (${result.conflicts.length}):`))
|
|
251
|
+
for (const conflict of result.conflicts) {
|
|
252
|
+
lines.push(chalk.red(` ✗ ${conflict}`))
|
|
253
|
+
}
|
|
254
|
+
lines.push(chalk.dim(' Resolve conflicts manually and run `chezmoi apply` to continue.'))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
lines.push(BORDER)
|
|
258
|
+
return lines.join('\n')
|
|
259
|
+
}
|