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
package/src/services/clickup.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import http from 'node:http'
|
|
2
2
|
import { randomBytes } from 'node:crypto'
|
|
3
3
|
import { openBrowser } from '../utils/open-browser.js'
|
|
4
|
-
import { loadConfig } from './config.js'
|
|
4
|
+
import { loadConfig, saveConfig } from './config.js'
|
|
5
5
|
|
|
6
6
|
/** @import { ClickUpTask } from '../types.js' */
|
|
7
7
|
|
|
@@ -103,19 +103,25 @@ export async function oauthFlow(clientId, clientSecret) {
|
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
105
|
* Make an authenticated request to the ClickUp API.
|
|
106
|
+
* Retries automatically on HTTP 429 (rate limit) up to MAX_RETRIES times.
|
|
106
107
|
* @param {string} path
|
|
108
|
+
* @param {number} [retries]
|
|
107
109
|
* @returns {Promise<unknown>}
|
|
108
110
|
*/
|
|
109
|
-
async function clickupFetch(path) {
|
|
111
|
+
async function clickupFetch(path, retries = 0) {
|
|
112
|
+
const MAX_RETRIES = 5
|
|
110
113
|
const token = await getToken()
|
|
111
114
|
if (!token) throw new Error('ClickUp not authenticated. Run `dvmi init` to authorize.')
|
|
112
115
|
const resp = await fetch(`${API_BASE}${path}`, {
|
|
113
116
|
headers: { Authorization: token },
|
|
114
117
|
})
|
|
115
118
|
if (resp.status === 429) {
|
|
119
|
+
if (retries >= MAX_RETRIES) {
|
|
120
|
+
throw new Error(`ClickUp API rate limit exceeded after ${MAX_RETRIES} retries. Try again later.`)
|
|
121
|
+
}
|
|
116
122
|
const reset = Number(resp.headers.get('X-RateLimit-Reset') ?? Date.now() + 1000)
|
|
117
123
|
await new Promise((r) => setTimeout(r, Math.max(reset - Date.now(), 1000)))
|
|
118
|
-
return clickupFetch(path)
|
|
124
|
+
return clickupFetch(path, retries + 1)
|
|
119
125
|
}
|
|
120
126
|
if (!resp.ok) {
|
|
121
127
|
const body = /** @type {any} */ (await resp.json().catch(() => ({})))
|
|
@@ -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/services/docs.js
CHANGED
|
@@ -205,6 +205,10 @@ export function detectApiSpecType(path, content) {
|
|
|
205
205
|
if (isOpenApi(/** @type {Record<string, unknown>} */ (doc))) return 'swagger'
|
|
206
206
|
if (isAsyncApi(/** @type {Record<string, unknown>} */ (doc))) return 'asyncapi'
|
|
207
207
|
}
|
|
208
|
-
} catch {
|
|
208
|
+
} catch (err) {
|
|
209
|
+
// File content is not valid YAML/JSON — not an API spec, return null.
|
|
210
|
+
// Log at debug level for troubleshooting without exposing parse errors to users.
|
|
211
|
+
if (process.env.DVMI_DEBUG) process.stderr.write(`[detectApiSpecType] parse failed: ${/** @type {Error} */ (err).message}\n`)
|
|
212
|
+
}
|
|
209
213
|
return null
|
|
210
214
|
}
|
package/src/services/prompts.js
CHANGED
|
@@ -237,8 +237,8 @@ export async function downloadPrompt(relativePath, localDir, opts = {}) {
|
|
|
237
237
|
|
|
238
238
|
const content = serializeFrontmatter(fm, prompt.body)
|
|
239
239
|
|
|
240
|
-
await mkdir(dirname(destPath), { recursive: true })
|
|
241
|
-
await writeFile(destPath, content, 'utf8')
|
|
240
|
+
await mkdir(dirname(destPath), { recursive: true, mode: 0o700 })
|
|
241
|
+
await writeFile(destPath, content, { encoding: 'utf8', mode: 0o600 })
|
|
242
242
|
|
|
243
243
|
return { path: destPath, skipped: false }
|
|
244
244
|
}
|