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.
@@ -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
+ }
@@ -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
+ }
@@ -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 { /* ignore */ }
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
  }
@@ -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
  }