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
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
|
{
|
|
@@ -84,6 +87,15 @@ const CATEGORIES = [
|
|
|
84
87
|
{ id: 'security:setup', hint: '[--json]' },
|
|
85
88
|
],
|
|
86
89
|
},
|
|
90
|
+
{
|
|
91
|
+
title: 'Dotfiles & Cifratura',
|
|
92
|
+
cmds: [
|
|
93
|
+
{ id: 'dotfiles:setup', hint: '[--json]' },
|
|
94
|
+
{ id: 'dotfiles:add', hint: '[FILES...] [--encrypt]' },
|
|
95
|
+
{ id: 'dotfiles:status', hint: '[--json]' },
|
|
96
|
+
{ id: 'dotfiles:sync', hint: '[--push] [--pull] [--dry-run]' },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
87
99
|
{
|
|
88
100
|
title: 'Setup & Ambiente',
|
|
89
101
|
cmds: [
|
|
@@ -91,30 +103,12 @@ const CATEGORIES = [
|
|
|
91
103
|
{ id: 'doctor', hint: '' },
|
|
92
104
|
{ id: 'auth:login', hint: '' },
|
|
93
105
|
{ id: 'whoami', hint: '' },
|
|
106
|
+
{ id: 'welcome', hint: '' },
|
|
94
107
|
{ id: 'upgrade', hint: '' },
|
|
95
108
|
],
|
|
96
109
|
},
|
|
97
110
|
]
|
|
98
111
|
|
|
99
|
-
// ─── Example commands shown at bottom of root help ──────────────────────────
|
|
100
|
-
const EXAMPLES = [
|
|
101
|
-
{ cmd: 'dvmi prompts list', note: 'Sfoglia prompt AI dal tuo repository' },
|
|
102
|
-
{ cmd: 'dvmi prompts list --filter refactor', note: 'Filtra prompt per parola chiave' },
|
|
103
|
-
{ cmd: 'dvmi prompts download coding/refactor-prompt.md', note: 'Scarica un prompt localmente' },
|
|
104
|
-
{ cmd: 'dvmi prompts browse skills --query refactor', note: 'Cerca skill su skills.sh' },
|
|
105
|
-
{ cmd: 'dvmi prompts browse awesome --category agents', note: 'Sfoglia awesome-copilot agents' },
|
|
106
|
-
{ cmd: 'dvmi prompts run coding/refactor-prompt.md --tool opencode', note: 'Esegui un prompt con opencode' },
|
|
107
|
-
{ cmd: 'dvmi docs read', note: 'Leggi il README del repo corrente' },
|
|
108
|
-
{ cmd: 'dvmi docs search "authentication"', note: 'Cerca nei docs del repo corrente' },
|
|
109
|
-
{ cmd: 'dvmi repo list --search "api"', note: 'Filtra repository per nome' },
|
|
110
|
-
{ cmd: 'dvmi pr status', note: 'PR aperte e review in attesa' },
|
|
111
|
-
{ cmd: 'dvmi pipeline status', note: 'Ultimi workflow CI/CD' },
|
|
112
|
-
{ cmd: 'dvmi tasks list --search "bug"', note: 'Cerca task ClickUp' },
|
|
113
|
-
{ cmd: 'dvmi costs get --json', note: 'Costi AWS in formato JSON' },
|
|
114
|
-
{ cmd: 'dvmi security setup --json', note: 'Controlla lo stato degli strumenti di sicurezza' },
|
|
115
|
-
{ cmd: 'dvmi security setup', note: 'Wizard interattivo: installa aws-vault e GCM' },
|
|
116
|
-
]
|
|
117
|
-
|
|
118
112
|
// ─── Help class ─────────────────────────────────────────────────────────────
|
|
119
113
|
|
|
120
114
|
/**
|
|
@@ -134,7 +128,20 @@ export default class CustomHelp extends Help {
|
|
|
134
128
|
async showRootHelp() {
|
|
135
129
|
// Animated logo — identical to `dvmi init` (no-ops in CI/non-TTY)
|
|
136
130
|
await printBanner()
|
|
137
|
-
|
|
131
|
+
|
|
132
|
+
// Version check: uses cached result (populated by init hook) — 800 ms timeout
|
|
133
|
+
let versionInfo = null
|
|
134
|
+
try {
|
|
135
|
+
const { checkForUpdate } = await import('./services/version-check.js')
|
|
136
|
+
versionInfo = await Promise.race([
|
|
137
|
+
checkForUpdate(),
|
|
138
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 800)),
|
|
139
|
+
])
|
|
140
|
+
} catch {
|
|
141
|
+
// never block help output
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this.log(this.#buildRootLayout(versionInfo))
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
/**
|
|
@@ -164,14 +171,46 @@ export default class CustomHelp extends Help {
|
|
|
164
171
|
|
|
165
172
|
// ─── Private helpers ──────────────────────────────────────────────────────
|
|
166
173
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Build the full categorized root help layout.
|
|
176
|
+
* @param {{ hasUpdate: boolean, current: string, latest: string|null }|null} [versionInfo]
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
#buildRootLayout(versionInfo = null) {
|
|
172
180
|
/** @type {Map<string, import('@oclif/core').Command.Cached>} */
|
|
173
181
|
const cmdMap = new Map(this.config.commands.map((c) => [c.id, c]))
|
|
174
182
|
|
|
183
|
+
/** @type {Array<{cmd: string, note: string}>} */
|
|
184
|
+
const EXAMPLES = [
|
|
185
|
+
{ cmd: 'dvmi prompts list', note: 'Sfoglia prompt AI dal tuo repository' },
|
|
186
|
+
{ cmd: 'dvmi prompts list --filter refactor', note: 'Filtra prompt per parola chiave' },
|
|
187
|
+
{ cmd: 'dvmi prompts download coding/refactor-prompt.md', note: 'Scarica un prompt localmente' },
|
|
188
|
+
{ cmd: 'dvmi prompts browse skills --query refactor', note: 'Cerca skill su skills.sh' },
|
|
189
|
+
{ cmd: 'dvmi prompts browse awesome --category agents', note: 'Sfoglia awesome-copilot agents' },
|
|
190
|
+
{ cmd: 'dvmi prompts run coding/refactor-prompt.md --tool opencode', note: 'Esegui un prompt con opencode' },
|
|
191
|
+
{ cmd: 'dvmi docs read', note: 'Leggi il README del repo corrente' },
|
|
192
|
+
{ cmd: 'dvmi docs search "authentication"', note: 'Cerca nei docs del repo corrente' },
|
|
193
|
+
{ cmd: 'dvmi repo list --search "api"', note: 'Filtra repository per nome' },
|
|
194
|
+
{ cmd: 'dvmi pr status', note: 'PR aperte e review in attesa' },
|
|
195
|
+
{ cmd: 'dvmi pipeline status', note: 'Ultimi workflow CI/CD' },
|
|
196
|
+
{ cmd: 'dvmi tasks list --search "bug"', note: 'Cerca task ClickUp' },
|
|
197
|
+
{ cmd: 'dvmi tasks today', note: 'Task in lavorazione oggi' },
|
|
198
|
+
{ cmd: 'dvmi costs get --period mtd', note: 'Costi AWS mese corrente per servizio' },
|
|
199
|
+
{ cmd: 'dvmi costs get --group-by tag --tag-key env', note: 'Costi raggruppati per tag env' },
|
|
200
|
+
{ cmd: 'dvmi costs trend --line', note: 'Trend costi 2 mesi (grafico lineare)' },
|
|
201
|
+
{ cmd: 'dvmi costs get --json', note: 'Costi AWS in formato JSON' },
|
|
202
|
+
{ cmd: 'dvmi logs', note: 'Sfoglia log CloudWatch in modo interattivo' },
|
|
203
|
+
{ cmd: 'dvmi logs --group /aws/lambda/my-fn --since 24h', note: 'Log Lambda ultimi 24h' },
|
|
204
|
+
{ cmd: 'dvmi logs --group /aws/lambda/my-fn --filter "ERROR"', note: 'Filtra eventi ERROR su un log group' },
|
|
205
|
+
{ cmd: 'dvmi security setup --json', note: 'Controlla lo stato degli strumenti di sicurezza' },
|
|
206
|
+
{ cmd: 'dvmi security setup', note: 'Wizard interattivo: installa aws-vault e GCM' },
|
|
207
|
+
{ cmd: 'dvmi dotfiles setup', note: 'Configura chezmoi con cifratura age' },
|
|
208
|
+
{ cmd: 'dvmi dotfiles add ~/.zshrc ~/.gitconfig', note: 'Aggiungi dotfile a chezmoi' },
|
|
209
|
+
{ cmd: 'dvmi dotfiles status --json', note: 'Stato dotfile gestiti (JSON)' },
|
|
210
|
+
{ cmd: 'dvmi dotfiles sync --push', note: 'Push dotfile al repository remoto' },
|
|
211
|
+
{ cmd: 'dvmi welcome', note: 'Dashboard missione dvmi con intro animata' },
|
|
212
|
+
]
|
|
213
|
+
|
|
175
214
|
const lines = []
|
|
176
215
|
|
|
177
216
|
// ── Usage ──────────────────────────────────────────────────────────────
|
|
@@ -236,6 +275,24 @@ export default class CustomHelp extends Help {
|
|
|
236
275
|
}
|
|
237
276
|
|
|
238
277
|
lines.push('')
|
|
278
|
+
|
|
279
|
+
// ── Versione + update notice ───────────────────────────────────────────
|
|
280
|
+
const current = versionInfo?.current ?? this.config.version
|
|
281
|
+
const versionStr = isColorEnabled
|
|
282
|
+
? chalk.dim('version ') + chalk.hex(DIM_BLUE)(current)
|
|
283
|
+
: `version ${current}`
|
|
284
|
+
|
|
285
|
+
if (versionInfo?.hasUpdate && versionInfo.latest) {
|
|
286
|
+
const updateStr = isColorEnabled
|
|
287
|
+
? chalk.yellow('update disponibile: ') +
|
|
288
|
+
chalk.dim(current) + chalk.yellow(' → ') + chalk.green(versionInfo.latest) +
|
|
289
|
+
chalk.dim(' (esegui ') + chalk.hex(LIGHT_ORANGE)('dvmi upgrade') + chalk.dim(')')
|
|
290
|
+
: `update disponibile: ${current} → ${versionInfo.latest} (esegui dvmi upgrade)`
|
|
291
|
+
lines.push(' ' + versionStr + chalk.dim(' · ') + updateStr)
|
|
292
|
+
} else {
|
|
293
|
+
lines.push(' ' + versionStr)
|
|
294
|
+
}
|
|
295
|
+
|
|
239
296
|
lines.push(
|
|
240
297
|
' ' + chalk.dim('Approfondisci:') + ' ' +
|
|
241
298
|
chalk.hex(DIM_BLUE)('dvmi <COMANDO> --help') +
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CloudWatchLogsClient,
|
|
3
|
+
paginateDescribeLogGroups,
|
|
4
|
+
FilterLogEventsCommand,
|
|
5
|
+
} from '@aws-sdk/client-cloudwatch-logs'
|
|
6
|
+
|
|
7
|
+
/** @import { LogGroup, LogEvent, LogFilterResult } from '../types.js' */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert a human-readable "since" string to epoch millisecond timestamps.
|
|
11
|
+
* @param {'1h'|'24h'|'7d'} since
|
|
12
|
+
* @returns {{ startTime: number, endTime: number }}
|
|
13
|
+
*/
|
|
14
|
+
export function sinceToEpochMs(since) {
|
|
15
|
+
const now = Date.now()
|
|
16
|
+
const MS = {
|
|
17
|
+
'1h': 60 * 60 * 1000,
|
|
18
|
+
'24h': 24 * 60 * 60 * 1000,
|
|
19
|
+
'7d': 7 * 24 * 60 * 60 * 1000,
|
|
20
|
+
}
|
|
21
|
+
const offset = MS[since]
|
|
22
|
+
if (!offset) throw new Error(`Invalid since value: ${since}. Must be one of: 1h, 24h, 7d`)
|
|
23
|
+
return { startTime: now - offset, endTime: now }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* List all CloudWatch log groups in the given region using pagination.
|
|
28
|
+
* @param {string} [region] - AWS region (defaults to 'eu-west-1')
|
|
29
|
+
* @returns {Promise<LogGroup[]>}
|
|
30
|
+
*/
|
|
31
|
+
export async function listLogGroups(region = 'eu-west-1') {
|
|
32
|
+
const client = new CloudWatchLogsClient({ region })
|
|
33
|
+
/** @type {LogGroup[]} */
|
|
34
|
+
const groups = []
|
|
35
|
+
|
|
36
|
+
const paginator = paginateDescribeLogGroups({ client }, {})
|
|
37
|
+
for await (const page of paginator) {
|
|
38
|
+
for (const lg of page.logGroups ?? []) {
|
|
39
|
+
groups.push({
|
|
40
|
+
name: lg.logGroupName ?? '',
|
|
41
|
+
storedBytes: lg.storedBytes,
|
|
42
|
+
retentionDays: lg.retentionInDays,
|
|
43
|
+
creationTime: lg.creationTime ? new Date(lg.creationTime).toISOString() : undefined,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return groups
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Filter log events from a CloudWatch log group.
|
|
53
|
+
* @param {string} logGroupName
|
|
54
|
+
* @param {string} filterPattern - CloudWatch filter pattern ('' = all events)
|
|
55
|
+
* @param {number} startTime - Epoch milliseconds
|
|
56
|
+
* @param {number} endTime - Epoch milliseconds
|
|
57
|
+
* @param {number} limit - Max events to return (1–10000)
|
|
58
|
+
* @param {string} [region] - AWS region (defaults to 'eu-west-1')
|
|
59
|
+
* @returns {Promise<LogFilterResult>}
|
|
60
|
+
*/
|
|
61
|
+
export async function filterLogEvents(logGroupName, filterPattern, startTime, endTime, limit, region = 'eu-west-1') {
|
|
62
|
+
const client = new CloudWatchLogsClient({ region })
|
|
63
|
+
|
|
64
|
+
const command = new FilterLogEventsCommand({
|
|
65
|
+
logGroupName,
|
|
66
|
+
filterPattern: filterPattern || undefined,
|
|
67
|
+
startTime,
|
|
68
|
+
endTime,
|
|
69
|
+
limit,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const result = await client.send(command)
|
|
73
|
+
|
|
74
|
+
/** @type {LogEvent[]} */
|
|
75
|
+
const events = (result.events ?? []).map((e) => ({
|
|
76
|
+
eventId: e.eventId ?? '',
|
|
77
|
+
logStreamName: e.logStreamName ?? '',
|
|
78
|
+
timestamp: e.timestamp ?? 0,
|
|
79
|
+
message: e.message ?? '',
|
|
80
|
+
}))
|
|
81
|
+
|
|
82
|
+
const truncated = events.length >= limit || Boolean(result.nextToken)
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
events,
|
|
86
|
+
truncated,
|
|
87
|
+
logGroupName,
|
|
88
|
+
startTime,
|
|
89
|
+
endTime,
|
|
90
|
+
filterPattern: filterPattern ?? '',
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/services/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises'
|
|
2
|
-
import { existsSync } from 'node:fs'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
|
|
@@ -58,3 +58,19 @@ export async function saveConfig(config, configPath = CONFIG_PATH) {
|
|
|
58
58
|
export function configExists(configPath = CONFIG_PATH) {
|
|
59
59
|
return existsSync(configPath)
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Load CLI config synchronously. Intended for use in static getters where async is unavailable.
|
|
64
|
+
* Returns defaults if file doesn't exist or cannot be parsed.
|
|
65
|
+
* @param {string} [configPath] - Override config path (used in tests)
|
|
66
|
+
* @returns {CLIConfig}
|
|
67
|
+
*/
|
|
68
|
+
export function loadConfigSync(configPath = process.env.DVMI_CONFIG_PATH ?? CONFIG_PATH) {
|
|
69
|
+
if (!existsSync(configPath)) return { ...DEFAULTS }
|
|
70
|
+
try {
|
|
71
|
+
const raw = readFileSync(configPath, 'utf8')
|
|
72
|
+
return { ...DEFAULTS, ...JSON.parse(raw) }
|
|
73
|
+
} catch {
|
|
74
|
+
return { ...DEFAULTS }
|
|
75
|
+
}
|
|
76
|
+
}
|