devvami 1.1.2 → 1.3.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 +233 -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/init.js +8 -3
- package/src/commands/logs/index.js +190 -0
- package/src/commands/prompts/run.js +19 -1
- package/src/commands/security/setup.js +249 -0
- package/src/commands/welcome.js +17 -0
- package/src/formatters/charts.js +205 -0
- package/src/formatters/cost.js +18 -5
- package/src/formatters/security.js +119 -0
- package/src/help.js +44 -24
- package/src/services/aws-costs.js +130 -6
- package/src/services/clickup.js +9 -3
- package/src/services/cloudwatch-logs.js +92 -0
- package/src/services/config.js +17 -1
- package/src/services/docs.js +5 -1
- package/src/services/prompts.js +2 -2
- package/src/services/security.js +634 -0
- package/src/types.js +132 -4
- package/src/utils/aws-vault.js +144 -0
- package/src/utils/welcome.js +173 -0
|
@@ -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,119 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { deriveOverallStatus } from '../services/security.js'
|
|
3
|
+
|
|
4
|
+
/** @import { SetupSession, SecurityToolStatus, PlatformInfo } from '../types.js' */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format the educational introduction about credential security.
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function formatEducationalIntro() {
|
|
11
|
+
const border = chalk.dim('─'.repeat(60))
|
|
12
|
+
const lines = [
|
|
13
|
+
border,
|
|
14
|
+
chalk.bold.yellow(' Why credential security matters'),
|
|
15
|
+
border,
|
|
16
|
+
'',
|
|
17
|
+
chalk.white(' Storing secrets in plaintext (shell history, .env files,'),
|
|
18
|
+
chalk.white(' ~/.aws/credentials) is the leading cause of supply chain'),
|
|
19
|
+
chalk.white(' attacks. One leaked key can compromise your entire org.'),
|
|
20
|
+
'',
|
|
21
|
+
chalk.bold(' What this setup installs:'),
|
|
22
|
+
'',
|
|
23
|
+
chalk.cyan(' aws-vault') + chalk.white(' — stores AWS credentials in an encrypted vault'),
|
|
24
|
+
chalk.cyan(' ') + chalk.white(' (macOS Keychain, pass on Linux).'),
|
|
25
|
+
chalk.cyan(' pass ') + chalk.white(' — GPG-encrypted password store (Linux/WSL2).'),
|
|
26
|
+
chalk.cyan(' GCM ') + chalk.white(' — Git Credential Manager: no more PATs in files.'),
|
|
27
|
+
chalk.cyan(' Keychain ') + chalk.white(' — macOS Keychain as Git credential helper.'),
|
|
28
|
+
'',
|
|
29
|
+
chalk.dim(' References: https://aws.github.io/aws-vault | https://www.passwordstore.org'),
|
|
30
|
+
border,
|
|
31
|
+
]
|
|
32
|
+
return lines.join('\n')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Format a step header line for the setup flow.
|
|
37
|
+
* @param {{ id: string, label: string, type: string }} step
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function formatStepHeader(step) {
|
|
41
|
+
const typeColor = {
|
|
42
|
+
check: chalk.blue,
|
|
43
|
+
install: chalk.yellow,
|
|
44
|
+
configure: chalk.cyan,
|
|
45
|
+
verify: chalk.green,
|
|
46
|
+
}
|
|
47
|
+
const colorFn = typeColor[step.type] ?? chalk.white
|
|
48
|
+
return ` ${colorFn(`[${step.type}]`)} ${step.label}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Format the completion summary table for a setup session.
|
|
53
|
+
* @param {SetupSession} session
|
|
54
|
+
* @param {PlatformInfo} platformInfo
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
export function formatSecuritySummary(session, platformInfo) {
|
|
58
|
+
const border = chalk.dim('─'.repeat(60))
|
|
59
|
+
const lines = [
|
|
60
|
+
'',
|
|
61
|
+
border,
|
|
62
|
+
chalk.bold(' Security Setup — Summary'),
|
|
63
|
+
border,
|
|
64
|
+
'',
|
|
65
|
+
chalk.bold(` Platform: ${chalk.cyan(platformInfo.platform)}`),
|
|
66
|
+
chalk.bold(` Selection: ${chalk.cyan(session.selection)}`),
|
|
67
|
+
'',
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
// Build a per-step result table
|
|
71
|
+
for (const step of session.steps) {
|
|
72
|
+
const result = session.results.get(step.id)
|
|
73
|
+
const status = result?.status ?? 'pending'
|
|
74
|
+
let badge
|
|
75
|
+
if (status === 'success') badge = chalk.green('✔')
|
|
76
|
+
else if (status === 'skipped') badge = chalk.dim('─')
|
|
77
|
+
else if (status === 'failed') badge = chalk.red('✗')
|
|
78
|
+
else badge = chalk.gray('○')
|
|
79
|
+
|
|
80
|
+
const label = chalk.white(step.label.padEnd(45))
|
|
81
|
+
const msg = result?.message ? chalk.gray(` ${result.message}`) : ''
|
|
82
|
+
lines.push(` ${badge} ${label}${msg}`)
|
|
83
|
+
|
|
84
|
+
if (status === 'failed' && result?.hint) {
|
|
85
|
+
lines.push(chalk.dim(` → ${result.hint}`))
|
|
86
|
+
if (result.hintUrl) lines.push(chalk.dim(` ${result.hintUrl}`))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
lines.push('')
|
|
91
|
+
|
|
92
|
+
// Overall status
|
|
93
|
+
const successful = [...session.results.values()].filter((r) => r.status === 'success').length
|
|
94
|
+
const failed = [...session.results.values()].filter((r) => r.status === 'failed').length
|
|
95
|
+
const skipped = [...session.results.values()].filter((r) => r.status === 'skipped').length
|
|
96
|
+
|
|
97
|
+
lines.push(
|
|
98
|
+
` ${chalk.green(`${successful} succeeded`)} ${chalk.dim(`${skipped} skipped`)} ${failed > 0 ? chalk.red(`${failed} failed`) : chalk.dim('0 failed')}`,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if (failed === 0) {
|
|
102
|
+
lines.push('')
|
|
103
|
+
lines.push(chalk.bold.green(' All done! Restart your terminal to apply shell profile changes.'))
|
|
104
|
+
lines.push(chalk.dim(' Then run: dvmi auth login'))
|
|
105
|
+
} else {
|
|
106
|
+
lines.push('')
|
|
107
|
+
lines.push(chalk.bold.red(' Setup incomplete — see failure hints above.'))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
lines.push(border)
|
|
111
|
+
return lines.join('\n')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Derive an overall status label from tool statuses (re-exported for convenience).
|
|
116
|
+
* @param {SecurityToolStatus[]} tools
|
|
117
|
+
* @returns {'success'|'partial'|'not-configured'}
|
|
118
|
+
*/
|
|
119
|
+
export { deriveOverallStatus }
|
package/src/help.js
CHANGED
|
@@ -58,14 +58,17 @@ const CATEGORIES = [
|
|
|
58
58
|
{
|
|
59
59
|
title: 'Tasks (ClickUp)',
|
|
60
60
|
cmds: [
|
|
61
|
-
{ id: 'tasks:list',
|
|
62
|
-
{ id: 'tasks:today',
|
|
61
|
+
{ id: 'tasks:list', hint: '[--status] [--search]' },
|
|
62
|
+
{ id: 'tasks:today', hint: '' },
|
|
63
|
+
{ id: 'tasks:assigned', hint: '[--status] [--search]' },
|
|
63
64
|
],
|
|
64
65
|
},
|
|
65
66
|
{
|
|
66
67
|
title: 'Cloud & Costi',
|
|
67
68
|
cmds: [
|
|
68
|
-
{ id: 'costs:get',
|
|
69
|
+
{ id: 'costs:get', hint: '[SERVICE] [--period] [--group-by] [--tag-key]' },
|
|
70
|
+
{ id: 'costs:trend', hint: '[--group-by] [--tag-key] [--line]' },
|
|
71
|
+
{ id: 'logs', hint: '[--group] [--filter] [--since] [--limit] [--region]' },
|
|
69
72
|
],
|
|
70
73
|
},
|
|
71
74
|
{
|
|
@@ -78,6 +81,12 @@ const CATEGORIES = [
|
|
|
78
81
|
{ id: 'prompts:run', hint: '[PATH] [--tool]' },
|
|
79
82
|
],
|
|
80
83
|
},
|
|
84
|
+
{
|
|
85
|
+
title: 'Sicurezza & Credenziali',
|
|
86
|
+
cmds: [
|
|
87
|
+
{ id: 'security:setup', hint: '[--json]' },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
81
90
|
{
|
|
82
91
|
title: 'Setup & Ambiente',
|
|
83
92
|
cmds: [
|
|
@@ -85,28 +94,12 @@ const CATEGORIES = [
|
|
|
85
94
|
{ id: 'doctor', hint: '' },
|
|
86
95
|
{ id: 'auth:login', hint: '' },
|
|
87
96
|
{ id: 'whoami', hint: '' },
|
|
97
|
+
{ id: 'welcome', hint: '' },
|
|
88
98
|
{ id: 'upgrade', hint: '' },
|
|
89
99
|
],
|
|
90
100
|
},
|
|
91
101
|
]
|
|
92
102
|
|
|
93
|
-
// ─── Example commands shown at bottom of root help ──────────────────────────
|
|
94
|
-
const EXAMPLES = [
|
|
95
|
-
{ cmd: 'dvmi prompts list', note: 'Sfoglia prompt AI dal tuo repository' },
|
|
96
|
-
{ cmd: 'dvmi prompts list --filter refactor', note: 'Filtra prompt per parola chiave' },
|
|
97
|
-
{ cmd: 'dvmi prompts download coding/refactor-prompt.md', note: 'Scarica un prompt localmente' },
|
|
98
|
-
{ cmd: 'dvmi prompts browse skills --query refactor', note: 'Cerca skill su skills.sh' },
|
|
99
|
-
{ cmd: 'dvmi prompts browse awesome --category agents', note: 'Sfoglia awesome-copilot agents' },
|
|
100
|
-
{ cmd: 'dvmi prompts run coding/refactor-prompt.md --tool opencode', note: 'Esegui un prompt con opencode' },
|
|
101
|
-
{ cmd: 'dvmi docs read', note: 'Leggi il README del repo corrente' },
|
|
102
|
-
{ cmd: 'dvmi docs search "authentication"', note: 'Cerca nei docs del repo corrente' },
|
|
103
|
-
{ cmd: 'dvmi repo list --search "api"', note: 'Filtra repository per nome' },
|
|
104
|
-
{ cmd: 'dvmi pr status', note: 'PR aperte e review in attesa' },
|
|
105
|
-
{ cmd: 'dvmi pipeline status', note: 'Ultimi workflow CI/CD' },
|
|
106
|
-
{ cmd: 'dvmi tasks list --search "bug"', note: 'Cerca task ClickUp' },
|
|
107
|
-
{ cmd: 'dvmi costs get --json', note: 'Costi AWS in formato JSON' },
|
|
108
|
-
]
|
|
109
|
-
|
|
110
103
|
// ─── Help class ─────────────────────────────────────────────────────────────
|
|
111
104
|
|
|
112
105
|
/**
|
|
@@ -156,14 +149,41 @@ export default class CustomHelp extends Help {
|
|
|
156
149
|
|
|
157
150
|
// ─── Private helpers ──────────────────────────────────────────────────────
|
|
158
151
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Build the full categorized root help layout.
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
163
156
|
#buildRootLayout() {
|
|
164
157
|
/** @type {Map<string, import('@oclif/core').Command.Cached>} */
|
|
165
158
|
const cmdMap = new Map(this.config.commands.map((c) => [c.id, c]))
|
|
166
159
|
|
|
160
|
+
/** @type {Array<{cmd: string, note: string}>} */
|
|
161
|
+
const EXAMPLES = [
|
|
162
|
+
{ cmd: 'dvmi prompts list', note: 'Sfoglia prompt AI dal tuo repository' },
|
|
163
|
+
{ cmd: 'dvmi prompts list --filter refactor', note: 'Filtra prompt per parola chiave' },
|
|
164
|
+
{ cmd: 'dvmi prompts download coding/refactor-prompt.md', note: 'Scarica un prompt localmente' },
|
|
165
|
+
{ cmd: 'dvmi prompts browse skills --query refactor', note: 'Cerca skill su skills.sh' },
|
|
166
|
+
{ cmd: 'dvmi prompts browse awesome --category agents', note: 'Sfoglia awesome-copilot agents' },
|
|
167
|
+
{ cmd: 'dvmi prompts run coding/refactor-prompt.md --tool opencode', note: 'Esegui un prompt con opencode' },
|
|
168
|
+
{ cmd: 'dvmi docs read', note: 'Leggi il README del repo corrente' },
|
|
169
|
+
{ cmd: 'dvmi docs search "authentication"', note: 'Cerca nei docs del repo corrente' },
|
|
170
|
+
{ cmd: 'dvmi repo list --search "api"', note: 'Filtra repository per nome' },
|
|
171
|
+
{ cmd: 'dvmi pr status', note: 'PR aperte e review in attesa' },
|
|
172
|
+
{ cmd: 'dvmi pipeline status', note: 'Ultimi workflow CI/CD' },
|
|
173
|
+
{ cmd: 'dvmi tasks list --search "bug"', note: 'Cerca task ClickUp' },
|
|
174
|
+
{ cmd: 'dvmi tasks today', note: 'Task in lavorazione oggi' },
|
|
175
|
+
{ cmd: 'dvmi costs get --period mtd', note: 'Costi AWS mese corrente per servizio' },
|
|
176
|
+
{ cmd: 'dvmi costs get --group-by tag --tag-key env', note: 'Costi raggruppati per tag env' },
|
|
177
|
+
{ cmd: 'dvmi costs trend --line', note: 'Trend costi 2 mesi (grafico lineare)' },
|
|
178
|
+
{ cmd: 'dvmi costs get --json', note: 'Costi AWS in formato JSON' },
|
|
179
|
+
{ cmd: 'dvmi logs', note: 'Sfoglia log CloudWatch in modo interattivo' },
|
|
180
|
+
{ cmd: 'dvmi logs --group /aws/lambda/my-fn --since 24h', note: 'Log Lambda ultimi 24h' },
|
|
181
|
+
{ cmd: 'dvmi logs --group /aws/lambda/my-fn --filter "ERROR"', note: 'Filtra eventi ERROR su un log group' },
|
|
182
|
+
{ cmd: 'dvmi security setup --json', note: 'Controlla lo stato degli strumenti di sicurezza' },
|
|
183
|
+
{ cmd: 'dvmi security setup', note: 'Wizard interattivo: installa aws-vault e GCM' },
|
|
184
|
+
{ cmd: 'dvmi welcome', note: 'Dashboard missione dvmi con intro animata' },
|
|
185
|
+
]
|
|
186
|
+
|
|
167
187
|
const lines = []
|
|
168
188
|
|
|
169
189
|
// ── Usage ──────────────────────────────────────────────────────────────
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CostExplorerClient, GetCostAndUsageCommand } from '@aws-sdk/client-cost-explorer'
|
|
2
2
|
|
|
3
|
-
/** @import { AWSCostEntry } from '../types.js' */
|
|
3
|
+
/** @import { AWSCostEntry, CostGroupMode, CostTrendSeries } from '../types.js' */
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Get the date range for a cost period.
|
|
@@ -28,14 +28,62 @@ function getPeriodDates(period) {
|
|
|
28
28
|
return { start: fmt(start), end: fmt(now) }
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Get the rolling 2-month date range for trend queries.
|
|
33
|
+
* @returns {{ start: string, end: string }}
|
|
34
|
+
*/
|
|
35
|
+
export function getTwoMonthPeriod() {
|
|
36
|
+
const now = new Date()
|
|
37
|
+
const fmt = (d) => d.toISOString().split('T')[0]
|
|
38
|
+
const start = new Date(now.getFullYear(), now.getMonth() - 2, 1)
|
|
39
|
+
return { start: fmt(start), end: fmt(now) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Strip the "tagkey$" prefix from an AWS tag group key response.
|
|
44
|
+
* AWS returns tag values as "<tagKey>$<value>" — strip the prefix.
|
|
45
|
+
* An empty stripped value is displayed as "(untagged)".
|
|
46
|
+
* @param {string} rawKey - Raw key from AWS response (e.g. "env$prod" or "env$")
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
function stripTagPrefix(rawKey) {
|
|
50
|
+
const dollarIdx = rawKey.indexOf('$')
|
|
51
|
+
if (dollarIdx === -1) return rawKey
|
|
52
|
+
const stripped = rawKey.slice(dollarIdx + 1)
|
|
53
|
+
return stripped === '' ? '(untagged)' : stripped
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the GroupBy array for Cost Explorer based on the grouping mode.
|
|
58
|
+
* AWS limits GroupBy to max 2 entries.
|
|
59
|
+
* @param {CostGroupMode} groupBy
|
|
60
|
+
* @param {string} [tagKey]
|
|
61
|
+
* @returns {Array<{Type: string, Key: string}>}
|
|
62
|
+
*/
|
|
63
|
+
function buildGroupBy(groupBy, tagKey) {
|
|
64
|
+
if (groupBy === 'service') {
|
|
65
|
+
return [{ Type: 'DIMENSION', Key: 'SERVICE' }]
|
|
66
|
+
}
|
|
67
|
+
if (groupBy === 'tag') {
|
|
68
|
+
return [{ Type: 'TAG', Key: tagKey ?? '' }]
|
|
69
|
+
}
|
|
70
|
+
// both
|
|
71
|
+
return [
|
|
72
|
+
{ Type: 'DIMENSION', Key: 'SERVICE' },
|
|
73
|
+
{ Type: 'TAG', Key: tagKey ?? '' },
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
|
|
31
77
|
/**
|
|
32
78
|
* Query AWS Cost Explorer for costs filtered by project tags.
|
|
33
79
|
* @param {string} serviceName - Tag value for filtering
|
|
34
80
|
* @param {Record<string, string>} tags - Project tags (key-value pairs)
|
|
35
81
|
* @param {'last-month'|'last-week'|'mtd'} [period]
|
|
82
|
+
* @param {CostGroupMode} [groupBy]
|
|
83
|
+
* @param {string} [tagKey]
|
|
36
84
|
* @returns {Promise<{ entries: AWSCostEntry[], period: { start: string, end: string } }>}
|
|
37
85
|
*/
|
|
38
|
-
export async function getServiceCosts(serviceName, tags, period = 'last-month') {
|
|
86
|
+
export async function getServiceCosts(serviceName, tags, period = 'last-month', groupBy = 'service', tagKey) {
|
|
39
87
|
// Cost Explorer always uses us-east-1
|
|
40
88
|
const client = new CostExplorerClient({ region: 'us-east-1' })
|
|
41
89
|
const { start, end } = getPeriodDates(period)
|
|
@@ -56,7 +104,7 @@ export async function getServiceCosts(serviceName, tags, period = 'last-month')
|
|
|
56
104
|
Granularity: 'MONTHLY',
|
|
57
105
|
Metrics: ['UnblendedCost'],
|
|
58
106
|
Filter: filter,
|
|
59
|
-
GroupBy:
|
|
107
|
+
GroupBy: buildGroupBy(groupBy, tagKey),
|
|
60
108
|
})
|
|
61
109
|
|
|
62
110
|
const result = await client.send(command)
|
|
@@ -66,15 +114,91 @@ export async function getServiceCosts(serviceName, tags, period = 'last-month')
|
|
|
66
114
|
for (const group of timeResult.Groups ?? []) {
|
|
67
115
|
const amount = Number(group.Metrics?.UnblendedCost?.Amount ?? 0)
|
|
68
116
|
if (amount > 0) {
|
|
69
|
-
|
|
70
|
-
|
|
117
|
+
const keys = group.Keys ?? []
|
|
118
|
+
/** @type {AWSCostEntry} */
|
|
119
|
+
const entry = {
|
|
120
|
+
serviceName: groupBy === 'tag' ? stripTagPrefix(keys[0] ?? '') : (keys[0] ?? 'Unknown'),
|
|
71
121
|
amount,
|
|
72
122
|
unit: group.Metrics?.UnblendedCost?.Unit ?? 'USD',
|
|
73
123
|
period: { start, end },
|
|
74
|
-
}
|
|
124
|
+
}
|
|
125
|
+
if (groupBy === 'both') {
|
|
126
|
+
entry.tagValue = stripTagPrefix(keys[1] ?? '')
|
|
127
|
+
} else if (groupBy === 'tag') {
|
|
128
|
+
entry.tagValue = entry.serviceName
|
|
129
|
+
}
|
|
130
|
+
entries.push(entry)
|
|
75
131
|
}
|
|
76
132
|
}
|
|
77
133
|
}
|
|
78
134
|
|
|
79
135
|
return { entries, period: { start, end } }
|
|
80
136
|
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Query AWS Cost Explorer for daily costs over the last 2 months, grouped by the given mode.
|
|
140
|
+
* Handles NextPageToken pagination internally.
|
|
141
|
+
* @param {CostGroupMode} groupBy
|
|
142
|
+
* @param {string} [tagKey]
|
|
143
|
+
* @returns {Promise<CostTrendSeries[]>}
|
|
144
|
+
*/
|
|
145
|
+
export async function getTrendCosts(groupBy = 'service', tagKey) {
|
|
146
|
+
const client = new CostExplorerClient({ region: 'us-east-1' })
|
|
147
|
+
const { start, end } = getTwoMonthPeriod()
|
|
148
|
+
|
|
149
|
+
/** @type {Map<string, Map<string, number>>} seriesName → date → amount */
|
|
150
|
+
const seriesMap = new Map()
|
|
151
|
+
|
|
152
|
+
let nextPageToken = undefined
|
|
153
|
+
do {
|
|
154
|
+
const command = new GetCostAndUsageCommand({
|
|
155
|
+
TimePeriod: { Start: start, End: end },
|
|
156
|
+
Granularity: 'DAILY',
|
|
157
|
+
Metrics: ['UnblendedCost'],
|
|
158
|
+
GroupBy: buildGroupBy(groupBy, tagKey),
|
|
159
|
+
...(nextPageToken ? { NextPageToken: nextPageToken } : {}),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const result = await client.send(command)
|
|
163
|
+
nextPageToken = result.NextPageToken
|
|
164
|
+
|
|
165
|
+
for (const timeResult of result.ResultsByTime ?? []) {
|
|
166
|
+
const date = timeResult.TimePeriod?.Start ?? ''
|
|
167
|
+
for (const group of timeResult.Groups ?? []) {
|
|
168
|
+
const amount = Number(group.Metrics?.UnblendedCost?.Amount ?? 0)
|
|
169
|
+
const keys = group.Keys ?? []
|
|
170
|
+
|
|
171
|
+
let seriesName
|
|
172
|
+
if (groupBy === 'service') {
|
|
173
|
+
seriesName = keys[0] ?? 'Unknown'
|
|
174
|
+
} else if (groupBy === 'tag') {
|
|
175
|
+
seriesName = stripTagPrefix(keys[0] ?? '')
|
|
176
|
+
} else {
|
|
177
|
+
// both: "ServiceName / tagValue"
|
|
178
|
+
const svc = keys[0] ?? 'Unknown'
|
|
179
|
+
const tag = stripTagPrefix(keys[1] ?? '')
|
|
180
|
+
seriesName = `${svc} / ${tag}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!seriesMap.has(seriesName)) {
|
|
184
|
+
seriesMap.set(seriesName, new Map())
|
|
185
|
+
}
|
|
186
|
+
const dateMap = seriesMap.get(seriesName)
|
|
187
|
+
dateMap.set(date, (dateMap.get(date) ?? 0) + amount)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} while (nextPageToken)
|
|
191
|
+
|
|
192
|
+
/** @type {CostTrendSeries[]} */
|
|
193
|
+
const series = []
|
|
194
|
+
for (const [name, dateMap] of seriesMap) {
|
|
195
|
+
const points = Array.from(dateMap.entries())
|
|
196
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
197
|
+
.map(([date, amount]) => ({ date, amount }))
|
|
198
|
+
if (points.some((p) => p.amount > 0)) {
|
|
199
|
+
series.push({ name, points })
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return series
|
|
204
|
+
}
|