devvami 1.2.0 → 1.4.0
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/README.md +36 -1
- package/oclif.manifest.json +380 -7
- package/package.json +2 -1
- package/src/commands/costs/get.js +112 -18
- package/src/commands/costs/trend.js +165 -0
- 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 +41 -3
- package/src/commands/logs/index.js +190 -0
- package/src/formatters/charts.js +205 -0
- package/src/formatters/cost.js +18 -5
- package/src/formatters/dotfiles.js +259 -0
- package/src/help.js +85 -28
- package/src/services/aws-costs.js +130 -6
- package/src/services/cloudwatch-logs.js +92 -0
- package/src/services/config.js +17 -1
- package/src/services/dotfiles.js +573 -0
- package/src/types.js +130 -4
- package/src/utils/aws-vault.js +144 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core'
|
|
2
|
+
import ora from 'ora'
|
|
3
|
+
import { search, input } from '@inquirer/prompts'
|
|
4
|
+
import { listLogGroups, filterLogEvents, sinceToEpochMs } from '../../services/cloudwatch-logs.js'
|
|
5
|
+
import { loadConfig } from '../../services/config.js'
|
|
6
|
+
import { DvmiError } from '../../utils/errors.js'
|
|
7
|
+
|
|
8
|
+
const SINCE_OPTIONS = ['1h', '24h', '7d']
|
|
9
|
+
|
|
10
|
+
export default class Logs extends Command {
|
|
11
|
+
static description = 'Browse and query CloudWatch log groups interactively'
|
|
12
|
+
|
|
13
|
+
static examples = [
|
|
14
|
+
'<%= config.bin %> logs',
|
|
15
|
+
'<%= config.bin %> logs --group /aws/lambda/my-fn',
|
|
16
|
+
'<%= config.bin %> logs --group /aws/lambda/my-fn --filter "ERROR" --since 24h',
|
|
17
|
+
'<%= config.bin %> logs --group /aws/lambda/my-fn --limit 50 --json',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
static enableJsonFlag = true
|
|
21
|
+
|
|
22
|
+
static flags = {
|
|
23
|
+
group: Flags.string({
|
|
24
|
+
description: 'Log group name — bypasses interactive picker',
|
|
25
|
+
char: 'g',
|
|
26
|
+
}),
|
|
27
|
+
filter: Flags.string({
|
|
28
|
+
description: 'CloudWatch filter pattern (empty = all events)',
|
|
29
|
+
char: 'f',
|
|
30
|
+
default: '',
|
|
31
|
+
}),
|
|
32
|
+
since: Flags.string({
|
|
33
|
+
description: 'Time window: 1h, 24h, 7d',
|
|
34
|
+
default: '1h',
|
|
35
|
+
}),
|
|
36
|
+
limit: Flags.integer({
|
|
37
|
+
description: 'Max log events to return (1–10000)',
|
|
38
|
+
default: 100,
|
|
39
|
+
}),
|
|
40
|
+
region: Flags.string({
|
|
41
|
+
description: 'AWS region (defaults to project config awsRegion)',
|
|
42
|
+
char: 'r',
|
|
43
|
+
}),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async run() {
|
|
47
|
+
const { flags } = await this.parse(Logs)
|
|
48
|
+
const isJson = flags.json
|
|
49
|
+
|
|
50
|
+
// Validate --limit
|
|
51
|
+
if (flags.limit < 1 || flags.limit > 10_000) {
|
|
52
|
+
throw new DvmiError('--limit must be between 1 and 10000.', '')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Validate --since
|
|
56
|
+
if (!SINCE_OPTIONS.includes(flags.since)) {
|
|
57
|
+
throw new DvmiError('--since must be one of: 1h, 24h, 7d.', '')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const config = await loadConfig()
|
|
61
|
+
const region = flags.region ?? config.awsRegion ?? 'eu-west-1'
|
|
62
|
+
|
|
63
|
+
let logGroupName = flags.group
|
|
64
|
+
let filterPattern = flags.filter
|
|
65
|
+
|
|
66
|
+
// Interactive mode: pick log group + filter pattern
|
|
67
|
+
if (!logGroupName) {
|
|
68
|
+
const spinner = ora('Loading log groups...').start()
|
|
69
|
+
let groups
|
|
70
|
+
try {
|
|
71
|
+
groups = await listLogGroups(region)
|
|
72
|
+
} catch (err) {
|
|
73
|
+
spinner.stop()
|
|
74
|
+
this._handleAwsError(err, region)
|
|
75
|
+
throw err
|
|
76
|
+
}
|
|
77
|
+
spinner.stop()
|
|
78
|
+
|
|
79
|
+
if (groups.length === 0) {
|
|
80
|
+
this.log(`No log groups found in region ${region}. Check your AWS credentials and region.`)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
logGroupName = await search({
|
|
86
|
+
message: 'Select a log group',
|
|
87
|
+
source: async (input) => {
|
|
88
|
+
const term = (input ?? '').toLowerCase()
|
|
89
|
+
return groups
|
|
90
|
+
.filter((g) => g.name.toLowerCase().includes(term))
|
|
91
|
+
.map((g) => ({ name: g.name, value: g.name }))
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
filterPattern = await input({
|
|
96
|
+
message: 'Filter pattern (leave empty for all events)',
|
|
97
|
+
default: '',
|
|
98
|
+
})
|
|
99
|
+
} catch {
|
|
100
|
+
// Ctrl+C — clean exit with code 130
|
|
101
|
+
process.exit(130)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { startTime, endTime } = sinceToEpochMs(/** @type {'1h'|'24h'|'7d'} */ (flags.since))
|
|
106
|
+
|
|
107
|
+
const fetchSpinner = isJson ? null : ora('Fetching log events...').start()
|
|
108
|
+
|
|
109
|
+
let result
|
|
110
|
+
try {
|
|
111
|
+
result = await filterLogEvents(logGroupName, filterPattern, startTime, endTime, flags.limit, region)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
fetchSpinner?.stop()
|
|
114
|
+
this._handleAwsError(err, region, logGroupName)
|
|
115
|
+
throw err
|
|
116
|
+
}
|
|
117
|
+
fetchSpinner?.stop()
|
|
118
|
+
|
|
119
|
+
if (isJson) {
|
|
120
|
+
// NDJSON to stdout, summary to stderr
|
|
121
|
+
for (const event of result.events) {
|
|
122
|
+
this.log(
|
|
123
|
+
JSON.stringify({
|
|
124
|
+
eventId: event.eventId,
|
|
125
|
+
logStreamName: event.logStreamName,
|
|
126
|
+
timestamp: event.timestamp,
|
|
127
|
+
message: event.message,
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
process.stderr.write(
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
logGroupName: result.logGroupName,
|
|
134
|
+
filterPattern: result.filterPattern,
|
|
135
|
+
startTime: result.startTime,
|
|
136
|
+
endTime: result.endTime,
|
|
137
|
+
truncated: result.truncated,
|
|
138
|
+
count: result.events.length,
|
|
139
|
+
}) + '\n',
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Table output
|
|
145
|
+
const startIso = new Date(startTime).toISOString()
|
|
146
|
+
const endIso = new Date(endTime).toISOString()
|
|
147
|
+
const divider = '─'.repeat(74)
|
|
148
|
+
|
|
149
|
+
this.log(`Log Group: ${logGroupName}`)
|
|
150
|
+
this.log(`Period: last ${flags.since} (${startIso} → ${endIso})`)
|
|
151
|
+
this.log(`Filter: ${filterPattern ? `"${filterPattern}"` : '(none)'}`)
|
|
152
|
+
this.log(divider)
|
|
153
|
+
|
|
154
|
+
for (const event of result.events) {
|
|
155
|
+
const ts = new Date(event.timestamp).toISOString()
|
|
156
|
+
const msg = event.message.length > 200 ? event.message.slice(0, 200) + '…' : event.message
|
|
157
|
+
this.log(` ${ts} ${event.logStreamName.slice(-20).padEnd(20)} ${msg}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this.log(divider)
|
|
161
|
+
const truncationNotice = result.truncated ? ' [Truncated — use --limit or a narrower --since to see more]' : ''
|
|
162
|
+
this.log(` ${result.events.length} events shown${truncationNotice}`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Handle common AWS errors and throw DvmiError with spec-defined messages.
|
|
167
|
+
* @param {unknown} err
|
|
168
|
+
* @param {string} _region
|
|
169
|
+
* @param {string} [_logGroupName]
|
|
170
|
+
*/
|
|
171
|
+
_handleAwsError(err, _region, _logGroupName) {
|
|
172
|
+
const msg = String(err)
|
|
173
|
+
if (msg.includes('AccessDenied') || msg.includes('UnauthorizedAccess')) {
|
|
174
|
+
this.error(
|
|
175
|
+
'Access denied. Ensure your role has logs:DescribeLogGroups and logs:FilterLogEvents permissions.',
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
if (msg.includes('ResourceNotFoundException')) {
|
|
179
|
+
this.error(
|
|
180
|
+
`Log group not found. Check the name and confirm you are using the correct region (--region).`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
if (msg.includes('InvalidParameterException')) {
|
|
184
|
+
this.error('Invalid filter pattern or parameter. Check the pattern syntax and time range.')
|
|
185
|
+
}
|
|
186
|
+
if (msg.includes('CredentialsProviderError') || msg.includes('No credentials')) {
|
|
187
|
+
this.error('No AWS credentials. Configure aws-vault and run `dvmi init` to set up your profile.')
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
|
|
3
|
+
/** @import { ChartSeries } from '../types.js' */
|
|
4
|
+
|
|
5
|
+
// Colour palette for multi-series charts (cycles if more than 8 series)
|
|
6
|
+
const PALETTE = [
|
|
7
|
+
chalk.cyan,
|
|
8
|
+
chalk.yellow,
|
|
9
|
+
chalk.green,
|
|
10
|
+
chalk.magenta,
|
|
11
|
+
chalk.blue,
|
|
12
|
+
chalk.red,
|
|
13
|
+
chalk.white,
|
|
14
|
+
chalk.gray,
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the terminal width, falling back to 80 columns.
|
|
19
|
+
* @returns {number}
|
|
20
|
+
*/
|
|
21
|
+
function terminalWidth() {
|
|
22
|
+
return process.stdout.columns ?? 80
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Scale a value to a bar width in characters.
|
|
27
|
+
* @param {number} value
|
|
28
|
+
* @param {number} max
|
|
29
|
+
* @param {number} maxWidth
|
|
30
|
+
* @returns {number}
|
|
31
|
+
*/
|
|
32
|
+
function scaleBar(value, max, maxWidth) {
|
|
33
|
+
if (max === 0) return 0
|
|
34
|
+
return Math.round((value / max) * maxWidth)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render a single-series or stacked ASCII bar chart.
|
|
39
|
+
*
|
|
40
|
+
* Each series contributes a coloured segment to each bar. With a single series
|
|
41
|
+
* the bars are monochrome cyan. Labels on the x-axis are sampled to avoid
|
|
42
|
+
* overlap at ~10-column intervals.
|
|
43
|
+
*
|
|
44
|
+
* @param {ChartSeries[]} series - One or more data series
|
|
45
|
+
* @param {{ title?: string, width?: number }} [options]
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function barChart(series, options = {}) {
|
|
49
|
+
if (!series.length) return '(no data)'
|
|
50
|
+
|
|
51
|
+
const width = options.width ?? Math.min(terminalWidth() - 2, 120)
|
|
52
|
+
const labelColWidth = 12 // space reserved for y-axis labels ($xx.xx)
|
|
53
|
+
const chartWidth = Math.max(width - labelColWidth - 2, 10)
|
|
54
|
+
|
|
55
|
+
// Combine all series into per-label totals for scaling
|
|
56
|
+
const allLabels = series[0]?.labels ?? []
|
|
57
|
+
const totals = allLabels.map((_, i) =>
|
|
58
|
+
series.reduce((sum, s) => sum + (s.values[i] ?? 0), 0),
|
|
59
|
+
)
|
|
60
|
+
const maxTotal = Math.max(...totals, 0)
|
|
61
|
+
|
|
62
|
+
const lines = []
|
|
63
|
+
|
|
64
|
+
if (options.title) {
|
|
65
|
+
lines.push(chalk.bold(options.title))
|
|
66
|
+
lines.push('─'.repeat(width))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Bar rows: one per time label
|
|
70
|
+
const BAR_HEIGHT = 8
|
|
71
|
+
|
|
72
|
+
// Build chart column by column (one char per day)
|
|
73
|
+
// We render it as a 2D grid: rows = height levels, cols = days
|
|
74
|
+
const grid = Array.from({ length: BAR_HEIGHT }, () => Array(allLabels.length).fill(' '))
|
|
75
|
+
|
|
76
|
+
for (let col = 0; col < allLabels.length; col++) {
|
|
77
|
+
const total = totals[col]
|
|
78
|
+
const barLen = scaleBar(total, maxTotal, BAR_HEIGHT)
|
|
79
|
+
|
|
80
|
+
// Determine colour per series for stacked effect (simplified: colour by dominant series)
|
|
81
|
+
let dominantIdx = 0
|
|
82
|
+
let dominantVal = 0
|
|
83
|
+
for (let si = 0; si < series.length; si++) {
|
|
84
|
+
if ((series[si].values[col] ?? 0) > dominantVal) {
|
|
85
|
+
dominantVal = series[si].values[col] ?? 0
|
|
86
|
+
dominantIdx = si
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const colour = PALETTE[dominantIdx % PALETTE.length]
|
|
90
|
+
|
|
91
|
+
for (let row = 0; row < barLen; row++) {
|
|
92
|
+
grid[BAR_HEIGHT - 1 - row][col] = colour('█')
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Render grid rows with y-axis labels on the leftmost and rightmost rows
|
|
97
|
+
for (let row = 0; row < BAR_HEIGHT; row++) {
|
|
98
|
+
let yLabel = ' '
|
|
99
|
+
if (row === 0) {
|
|
100
|
+
yLabel = `$${maxTotal.toFixed(2)}`.padStart(labelColWidth)
|
|
101
|
+
} else if (row === BAR_HEIGHT - 1) {
|
|
102
|
+
yLabel = '$0.00'.padStart(labelColWidth)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Sample columns to fit chartWidth (one char per label, spaced if needed)
|
|
106
|
+
const step = Math.max(1, Math.ceil(allLabels.length / chartWidth))
|
|
107
|
+
const rowStr = grid[row].filter((_, i) => i % step === 0).join('')
|
|
108
|
+
lines.push(`${yLabel} ${rowStr}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// X-axis date labels (sample every ~10 positions)
|
|
112
|
+
const step = Math.max(1, Math.ceil(allLabels.length / Math.floor(chartWidth / 10)))
|
|
113
|
+
const xLabels = allLabels
|
|
114
|
+
.filter((_, i) => i % step === 0)
|
|
115
|
+
.map((l) => l.slice(5)) // "MM-DD"
|
|
116
|
+
lines.push(' '.repeat(labelColWidth + 1) + xLabels.join(' '))
|
|
117
|
+
|
|
118
|
+
// Legend for multi-series
|
|
119
|
+
if (series.length > 1) {
|
|
120
|
+
lines.push('')
|
|
121
|
+
lines.push(chalk.dim('Legend:'))
|
|
122
|
+
for (let i = 0; i < series.length; i++) {
|
|
123
|
+
const colour = PALETTE[i % PALETTE.length]
|
|
124
|
+
lines.push(` ${colour('█')} ${series[i].name}`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return lines.join('\n')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Render an ASCII line chart. Each series is drawn as a distinct coloured line.
|
|
133
|
+
* Values are plotted on a fixed-height canvas using half-block characters.
|
|
134
|
+
*
|
|
135
|
+
* @param {ChartSeries[]} series - One or more data series
|
|
136
|
+
* @param {{ title?: string, width?: number }} [options]
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
export function lineChart(series, options = {}) {
|
|
140
|
+
if (!series.length) return '(no data)'
|
|
141
|
+
|
|
142
|
+
const width = options.width ?? Math.min(terminalWidth() - 2, 120)
|
|
143
|
+
const labelColWidth = 12
|
|
144
|
+
const chartWidth = Math.max(width - labelColWidth - 2, 10)
|
|
145
|
+
const chartHeight = 10
|
|
146
|
+
|
|
147
|
+
const allLabels = series[0]?.labels ?? []
|
|
148
|
+
const allValues = series.flatMap((s) => s.values)
|
|
149
|
+
const maxVal = Math.max(...allValues, 0)
|
|
150
|
+
|
|
151
|
+
const lines = []
|
|
152
|
+
|
|
153
|
+
if (options.title) {
|
|
154
|
+
lines.push(chalk.bold(options.title))
|
|
155
|
+
lines.push('─'.repeat(width))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Build a 2D canvas: rows = chartHeight, cols = chartWidth
|
|
159
|
+
const canvas = Array.from({ length: chartHeight }, () =>
|
|
160
|
+
Array(chartWidth).fill(' '),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const step = Math.max(1, Math.ceil(allLabels.length / chartWidth))
|
|
164
|
+
|
|
165
|
+
for (let si = 0; si < series.length; si++) {
|
|
166
|
+
const colour = PALETTE[si % PALETTE.length]
|
|
167
|
+
const sampledValues = series[si].values.filter((_, i) => i % step === 0)
|
|
168
|
+
|
|
169
|
+
for (let col = 0; col < Math.min(sampledValues.length, chartWidth); col++) {
|
|
170
|
+
const val = sampledValues[col] ?? 0
|
|
171
|
+
const row = maxVal === 0 ? chartHeight - 1 : chartHeight - 1 - Math.round((val / maxVal) * (chartHeight - 1))
|
|
172
|
+
canvas[Math.max(0, Math.min(row, chartHeight - 1))][col] = colour('●')
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Render rows with y-axis labels
|
|
177
|
+
for (let row = 0; row < chartHeight; row++) {
|
|
178
|
+
let yLabel = ' '
|
|
179
|
+
if (row === 0) {
|
|
180
|
+
yLabel = `$${maxVal.toFixed(2)}`.padStart(labelColWidth)
|
|
181
|
+
} else if (row === chartHeight - 1) {
|
|
182
|
+
yLabel = '$0.00'.padStart(labelColWidth)
|
|
183
|
+
}
|
|
184
|
+
lines.push(`${yLabel} ${canvas[row].join('')}`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// X-axis date labels
|
|
188
|
+
const xStep = Math.max(1, Math.ceil(allLabels.length / Math.floor(chartWidth / 10)))
|
|
189
|
+
const xLabels = allLabels
|
|
190
|
+
.filter((_, i) => i % xStep === 0)
|
|
191
|
+
.map((l) => l.slice(5))
|
|
192
|
+
lines.push(' '.repeat(labelColWidth + 1) + xLabels.join(' '))
|
|
193
|
+
|
|
194
|
+
// Legend
|
|
195
|
+
if (series.length > 0) {
|
|
196
|
+
lines.push('')
|
|
197
|
+
lines.push(chalk.dim('Legend:'))
|
|
198
|
+
for (let i = 0; i < series.length; i++) {
|
|
199
|
+
const colour = PALETTE[i % PALETTE.length]
|
|
200
|
+
lines.push(` ${colour('●')} ${series[i].name}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return lines.join('\n')
|
|
205
|
+
}
|
package/src/formatters/cost.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** @import { AWSCostEntry } from '../types.js' */
|
|
1
|
+
/** @import { AWSCostEntry, CostGroupMode } from '../types.js' */
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Format a USD amount as currency string.
|
|
@@ -31,21 +31,34 @@ export function formatTrend(current, previous) {
|
|
|
31
31
|
return `${sign}${pct.toFixed(1)}%`
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Derive the display row label for a cost entry based on the grouping mode.
|
|
36
|
+
* @param {AWSCostEntry} entry
|
|
37
|
+
* @param {CostGroupMode} groupBy
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function rowLabel(entry, groupBy) {
|
|
41
|
+
if (groupBy === 'tag') return entry.tagValue ?? entry.serviceName
|
|
42
|
+
if (groupBy === 'both') return `${entry.serviceName} / ${entry.tagValue ?? '(untagged)'}`
|
|
43
|
+
return entry.serviceName
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
/**
|
|
35
47
|
* Format cost entries as a printable table string.
|
|
36
48
|
* @param {AWSCostEntry[]} entries
|
|
37
|
-
* @param {string}
|
|
49
|
+
* @param {string} label - Display label for the header
|
|
50
|
+
* @param {CostGroupMode} [groupBy] - Grouping mode (default: 'service')
|
|
38
51
|
* @returns {string}
|
|
39
52
|
*/
|
|
40
|
-
export function formatCostTable(entries,
|
|
53
|
+
export function formatCostTable(entries, label, groupBy = 'service') {
|
|
41
54
|
const total = calculateTotal(entries)
|
|
42
55
|
const rows = entries
|
|
43
56
|
.sort((a, b) => b.amount - a.amount)
|
|
44
|
-
.map((e) => ` ${e.
|
|
57
|
+
.map((e) => ` ${rowLabel(e, groupBy).padEnd(40)} ${formatCurrency(e.amount)}`)
|
|
45
58
|
.join('\n')
|
|
46
59
|
const divider = '─'.repeat(50)
|
|
47
60
|
return [
|
|
48
|
-
`Costs for: ${
|
|
61
|
+
`Costs for: ${label}`,
|
|
49
62
|
divider,
|
|
50
63
|
rows,
|
|
51
64
|
divider,
|
|
@@ -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
|
+
}
|