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.
- package/README.md +36 -1
- package/oclif.manifest.json +170 -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 +6 -1
- package/src/commands/logs/index.js +190 -0
- package/src/formatters/charts.js +205 -0
- package/src/formatters/cost.js +18 -5
- package/src/help.js +38 -26
- 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/types.js +66 -4
- package/src/utils/aws-vault.js +144 -0
|
@@ -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
|
+
}
|
package/src/types.js
CHANGED
|
@@ -189,10 +189,72 @@
|
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
191
|
* @typedef {Object} AWSCostEntry
|
|
192
|
-
* @property {string} serviceName
|
|
193
|
-
* @property {
|
|
194
|
-
* @property {
|
|
195
|
-
* @property {
|
|
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
|
+
}
|