devvami 1.2.0 → 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.
@@ -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: [{ Type: 'DIMENSION', Key: 'SERVICE' }],
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
- entries.push({
70
- serviceName: group.Keys?.[0] ?? 'Unknown',
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
+ }
@@ -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
+ }
package/src/types.js CHANGED
@@ -189,10 +189,72 @@
189
189
 
190
190
  /**
191
191
  * @typedef {Object} AWSCostEntry
192
- * @property {string} serviceName
193
- * @property {number} amount
194
- * @property {string} unit
195
- * @property {{ start: string, end: string }} period
192
+ * @property {string} serviceName - AWS service name (e.g. "Amazon EC2"), or tag value label for tag grouping
193
+ * @property {string} [tagValue] - Tag value when grouping by TAG or BOTH (e.g. "prod"); undefined for service-only grouping
194
+ * @property {number} amount - Cost amount (USD)
195
+ * @property {string} unit - Currency unit (always "USD")
196
+ * @property {{ start: string, end: string }} period - ISO date range (YYYY-MM-DD)
197
+ */
198
+
199
+ /**
200
+ * @typedef {'service' | 'tag' | 'both'} CostGroupMode
201
+ * The dimension used to group cost entries.
202
+ * - 'service': group by AWS service name (default, backward-compatible)
203
+ * - 'tag': group by a tag key's values (requires tagKey)
204
+ * - 'both': group by AWS service + tag value simultaneously
205
+ */
206
+
207
+ /**
208
+ * @typedef {Object} CostTrendPoint
209
+ * @property {string} date - ISO date (YYYY-MM-DD) for this data point
210
+ * @property {number} amount - Total cost for this day (USD)
211
+ * @property {string} [label] - Display label: serviceName for service/both grouping,
212
+ * tag value for tag grouping; omitted when not multi-series
213
+ */
214
+
215
+ /**
216
+ * @typedef {Object} CostTrendSeries
217
+ * @property {string} name - Series label (service name or tag value)
218
+ * @property {CostTrendPoint[]} points - Ordered daily data points (ascending by date)
219
+ */
220
+
221
+ /**
222
+ * @typedef {Object} LogGroup
223
+ * @property {string} name - Full log group name (e.g. "/aws/lambda/my-fn")
224
+ * @property {number} [storedBytes] - Total stored bytes (may be absent for empty groups)
225
+ * @property {number} [retentionDays] - Retention policy in days; undefined = never expire
226
+ * @property {string} [creationTime] - ISO8601 creation timestamp
227
+ */
228
+
229
+ /**
230
+ * @typedef {Object} LogEvent
231
+ * @property {string} eventId - Unique event ID assigned by CloudWatch
232
+ * @property {string} logStreamName - Stream within the log group (e.g. "2026/03/26/[$LATEST]abc")
233
+ * @property {number} timestamp - Event time as epoch milliseconds
234
+ * @property {string} message - Raw log message text
235
+ */
236
+
237
+ /**
238
+ * @typedef {Object} LogFilterResult
239
+ * @property {LogEvent[]} events - Matched log events (up to --limit)
240
+ * @property {boolean} truncated - True when the result was capped by --limit or AWS pagination
241
+ * @property {string} logGroupName - The log group that was queried
242
+ * @property {number} startTime - Query start as epoch milliseconds
243
+ * @property {number} endTime - Query end as epoch milliseconds
244
+ * @property {string} filterPattern - The pattern used ('' = no filter)
245
+ */
246
+
247
+ /**
248
+ * @typedef {Object} ChartBarData
249
+ * @property {string} name - Row label (service name, tag value, or "service / tag")
250
+ * @property {number} value - Cost amount (USD)
251
+ */
252
+
253
+ /**
254
+ * @typedef {Object} ChartSeries
255
+ * @property {string} name - Series label displayed in legend
256
+ * @property {number[]} values - Ordered numeric values (one per day, ~60 for 2 months)
257
+ * @property {string[]} labels - Date labels matching values array (YYYY-MM-DD)
196
258
  */
197
259
 
198
260
  /**
@@ -0,0 +1,144 @@
1
+ import { loadConfigSync } from '../services/config.js'
2
+ import { execa } from 'execa'
3
+
4
+ /**
5
+ * Returns the aws-vault exec prefix to prepend to AWS CLI commands.
6
+ *
7
+ * Detection order:
8
+ * 1. process.env.AWS_VAULT — set by `aws-vault exec` at runtime in the child process
9
+ * 2. config.awsProfile — if an already-loaded config object is passed (avoids sync I/O)
10
+ * 3. loadConfigSync() — synchronous fallback for static getters where async is unavailable
11
+ *
12
+ * @param {{ awsProfile?: string } | null} [config] - Already-loaded config (optional).
13
+ * Pass this when the caller has already loaded config asynchronously to avoid a redundant sync read.
14
+ * @returns {string} e.g. `"aws-vault exec myprofile -- "` or `""`
15
+ *
16
+ * @example
17
+ * // Inside an async run() method where config is already loaded:
18
+ * const prefix = awsVaultPrefix(config)
19
+ * this.error(`No credentials. Use: ${prefix}dvmi costs get`)
20
+ *
21
+ * @example
22
+ * // Inside a static getter (no async available):
23
+ * static get examples() {
24
+ * const prefix = awsVaultPrefix()
25
+ * return [`${prefix}<%= config.bin %> costs get my-service`]
26
+ * }
27
+ */
28
+ export function awsVaultPrefix(config = null) {
29
+ // 1. Runtime env var — set by aws-vault exec in the subprocess environment
30
+ if (process.env.AWS_VAULT) return `aws-vault exec ${process.env.AWS_VAULT} -- `
31
+
32
+ // 2. Already-loaded config passed by the caller
33
+ if (config?.awsProfile) return `aws-vault exec ${config.awsProfile} -- `
34
+
35
+ // 3. Synchronous config read — fallback for static getters
36
+ const synced = loadConfigSync()
37
+ if (synced.awsProfile) return `aws-vault exec ${synced.awsProfile} -- `
38
+
39
+ return ''
40
+ }
41
+
42
+ /**
43
+ * Returns true when the current process already has AWS credentials in env.
44
+ * @returns {boolean}
45
+ */
46
+ export function hasAwsCredentialEnv() {
47
+ return Boolean(
48
+ process.env.AWS_ACCESS_KEY_ID ||
49
+ process.env.AWS_SESSION_TOKEN,
50
+ )
51
+ }
52
+
53
+ /**
54
+ * Returns true when this process is already running inside aws-vault exec.
55
+ * @returns {boolean}
56
+ */
57
+ export function isAwsVaultSession() {
58
+ return Boolean(process.env.AWS_VAULT)
59
+ }
60
+
61
+ /**
62
+ * Re-execute the current dvmi command under aws-vault and mirror stdio.
63
+ * Returns null when re-exec should not run.
64
+ *
65
+ * Guard conditions:
66
+ * - awsProfile must be configured
67
+ * - command must not already be inside aws-vault
68
+ * - process must not already have AWS credentials in env
69
+ * - re-exec must not have already happened in this process chain
70
+ *
71
+ * @param {{ awsProfile?: string } | null} [config]
72
+ * @returns {Promise<number | null>} child exit code or null when skipped
73
+ */
74
+ export async function reexecCurrentCommandWithAwsVault(config = null) {
75
+ const profile = config?.awsProfile ?? loadConfigSync().awsProfile
76
+ if (!profile) return null
77
+ if (isAwsVaultSession()) return null
78
+ if (hasAwsCredentialEnv()) return null
79
+ if (process.env.DVMI_AWS_VAULT_REEXEC === '1') return null
80
+
81
+ try {
82
+ const child = await execa(
83
+ 'aws-vault',
84
+ [
85
+ 'exec',
86
+ profile,
87
+ '--',
88
+ process.execPath,
89
+ ...process.argv.slice(1),
90
+ ],
91
+ {
92
+ reject: false,
93
+ stdio: 'inherit',
94
+ env: {
95
+ ...process.env,
96
+ DVMI_AWS_VAULT_REEXEC: '1',
97
+ },
98
+ },
99
+ )
100
+
101
+ return child.exitCode ?? 1
102
+ } catch {
103
+ // aws-vault missing or failed to spawn; fallback to normal execution path
104
+ return null
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Re-execute the current dvmi command under aws-vault using an explicit profile.
110
+ * This bypasses auto-detection guards and is intended for interactive recovery flows.
111
+ *
112
+ * @param {string} profile
113
+ * @param {Record<string, string>} [extraEnv]
114
+ * @returns {Promise<number | null>} child exit code or null when skipped/failed to spawn
115
+ */
116
+ export async function reexecCurrentCommandWithAwsVaultProfile(profile, extraEnv = {}) {
117
+ if (!profile) return null
118
+
119
+ try {
120
+ const child = await execa(
121
+ 'aws-vault',
122
+ [
123
+ 'exec',
124
+ profile,
125
+ '--',
126
+ process.execPath,
127
+ ...process.argv.slice(1),
128
+ ],
129
+ {
130
+ reject: false,
131
+ stdio: 'inherit',
132
+ env: {
133
+ ...process.env,
134
+ DVMI_AWS_VAULT_REEXEC: '1',
135
+ ...extraEnv,
136
+ },
137
+ },
138
+ )
139
+
140
+ return child.exitCode ?? 1
141
+ } catch {
142
+ return null
143
+ }
144
+ }