devvami 1.0.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +255 -0
  3. package/bin/dev.cmd +3 -0
  4. package/bin/dev.js +5 -0
  5. package/bin/run.cmd +3 -0
  6. package/bin/run.js +5 -0
  7. package/oclif.manifest.json +1238 -0
  8. package/package.json +161 -0
  9. package/src/commands/auth/login.js +89 -0
  10. package/src/commands/changelog.js +102 -0
  11. package/src/commands/costs/get.js +73 -0
  12. package/src/commands/create/repo.js +196 -0
  13. package/src/commands/docs/list.js +110 -0
  14. package/src/commands/docs/projects.js +92 -0
  15. package/src/commands/docs/read.js +172 -0
  16. package/src/commands/docs/search.js +103 -0
  17. package/src/commands/doctor.js +115 -0
  18. package/src/commands/init.js +222 -0
  19. package/src/commands/open.js +75 -0
  20. package/src/commands/pipeline/logs.js +41 -0
  21. package/src/commands/pipeline/rerun.js +66 -0
  22. package/src/commands/pipeline/status.js +62 -0
  23. package/src/commands/pr/create.js +114 -0
  24. package/src/commands/pr/detail.js +83 -0
  25. package/src/commands/pr/review.js +51 -0
  26. package/src/commands/pr/status.js +70 -0
  27. package/src/commands/repo/list.js +113 -0
  28. package/src/commands/search.js +62 -0
  29. package/src/commands/tasks/assigned.js +131 -0
  30. package/src/commands/tasks/list.js +133 -0
  31. package/src/commands/tasks/today.js +73 -0
  32. package/src/commands/upgrade.js +52 -0
  33. package/src/commands/whoami.js +85 -0
  34. package/src/formatters/cost.js +54 -0
  35. package/src/formatters/markdown.js +108 -0
  36. package/src/formatters/openapi.js +146 -0
  37. package/src/formatters/status.js +48 -0
  38. package/src/formatters/table.js +87 -0
  39. package/src/help.js +312 -0
  40. package/src/hooks/init.js +9 -0
  41. package/src/hooks/postrun.js +18 -0
  42. package/src/index.js +1 -0
  43. package/src/services/auth.js +83 -0
  44. package/src/services/aws-costs.js +80 -0
  45. package/src/services/clickup.js +288 -0
  46. package/src/services/config.js +59 -0
  47. package/src/services/docs.js +210 -0
  48. package/src/services/github.js +377 -0
  49. package/src/services/platform.js +48 -0
  50. package/src/services/shell.js +42 -0
  51. package/src/services/version-check.js +58 -0
  52. package/src/types.js +228 -0
  53. package/src/utils/banner.js +48 -0
  54. package/src/utils/errors.js +61 -0
  55. package/src/utils/gradient.js +130 -0
  56. package/src/utils/open-browser.js +29 -0
  57. package/src/utils/typewriter.js +48 -0
  58. package/src/validators/repo-name.js +42 -0
@@ -0,0 +1,288 @@
1
+ import http from 'node:http'
2
+ import { openBrowser } from '../utils/open-browser.js'
3
+ import { loadConfig } from './config.js'
4
+
5
+ /** @import { ClickUpTask } from '../types.js' */
6
+
7
+ const API_BASE = process.env.CLICKUP_API_BASE ?? 'https://api.clickup.com/api/v2'
8
+ const TOKEN_KEY = 'clickup_token'
9
+
10
+ /**
11
+ * Format a Date as a local YYYY-MM-DD string (avoids UTC offset issues).
12
+ * @param {Date} date
13
+ * @returns {string}
14
+ */
15
+ function localDateString(date) {
16
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
17
+ }
18
+
19
+ /**
20
+ * Get stored ClickUp OAuth token.
21
+ * Reads from (in order): CLICKUP_TOKEN env var, OS keychain, config file.
22
+ * @returns {Promise<string|null>}
23
+ */
24
+ async function getToken() {
25
+ // Allow tests / CI to inject a token via environment variable
26
+ if (process.env.CLICKUP_TOKEN) return process.env.CLICKUP_TOKEN
27
+ try {
28
+ const { default: keytar } = await import('keytar')
29
+ return keytar.getPassword('devvami', TOKEN_KEY)
30
+ } catch {
31
+ // keytar not available (e.g. WSL2 without D-Bus) — fallback to config
32
+ const config = await loadConfig()
33
+ return config.clickup?.token ?? null
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Store ClickUp token securely (works for both OAuth and Personal API Tokens).
39
+ * @param {string} token
40
+ * @returns {Promise<void>}
41
+ */
42
+ export async function storeToken(token) {
43
+ try {
44
+ const { default: keytar } = await import('keytar')
45
+ await keytar.setPassword('devvami', TOKEN_KEY, token)
46
+ } catch {
47
+ // Fallback: store in config (less secure)
48
+ const config = await loadConfig()
49
+ await saveConfig({ ...config, clickup: { ...config.clickup, token } })
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Run the ClickUp OAuth localhost redirect flow.
55
+ * @param {string} clientId
56
+ * @param {string} clientSecret
57
+ * @returns {Promise<string>} Access token
58
+ */
59
+ export async function oauthFlow(clientId, clientSecret) {
60
+ return new Promise((resolve, reject) => {
61
+ const server = http.createServer(async (req, res) => {
62
+ const url = new URL(req.url ?? '/', 'http://localhost')
63
+ const code = url.searchParams.get('code')
64
+ if (!code) return
65
+ res.end('Authorization successful! You can close this tab.')
66
+ server.close()
67
+ try {
68
+ const resp = await fetch(`${API_BASE}/oauth/token`, {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code }),
72
+ })
73
+ const data = /** @type {any} */ (await resp.json())
74
+ await storeToken(data.access_token)
75
+ resolve(data.access_token)
76
+ } catch (err) {
77
+ reject(err)
78
+ }
79
+ })
80
+ server.listen(0, async () => {
81
+ const addr = /** @type {import('node:net').AddressInfo} */ (server.address())
82
+ const callbackUrl = `http://localhost:${addr.port}/callback`
83
+ const authUrl = `https://app.clickup.com/api?client_id=${clientId}&redirect_uri=${encodeURIComponent(callbackUrl)}`
84
+ await openBrowser(authUrl)
85
+ })
86
+ server.on('error', reject)
87
+ })
88
+ }
89
+
90
+ /**
91
+ * Make an authenticated request to the ClickUp API.
92
+ * @param {string} path
93
+ * @returns {Promise<unknown>}
94
+ */
95
+ async function clickupFetch(path) {
96
+ const token = await getToken()
97
+ if (!token) throw new Error('ClickUp not authenticated. Run `dvmi init` to authorize.')
98
+ const resp = await fetch(`${API_BASE}${path}`, {
99
+ headers: { Authorization: token },
100
+ })
101
+ if (resp.status === 429) {
102
+ const reset = Number(resp.headers.get('X-RateLimit-Reset') ?? Date.now() + 1000)
103
+ await new Promise((r) => setTimeout(r, Math.max(reset - Date.now(), 1000)))
104
+ return clickupFetch(path)
105
+ }
106
+ if (!resp.ok) {
107
+ const body = /** @type {any} */ (await resp.json().catch(() => ({})))
108
+ throw new Error(`ClickUp API ${resp.status}: ${body.err ?? resp.statusText}`)
109
+ }
110
+ return resp.json()
111
+ }
112
+
113
+ /**
114
+ * Get ClickUp user info (used to get user ID for filtering tasks).
115
+ * @returns {Promise<{ id: string, username: string }>}
116
+ */
117
+ export async function getUser() {
118
+ const data = /** @type {any} */ (await clickupFetch('/user'))
119
+ return { id: String(data.user.id), username: data.user.username }
120
+ }
121
+
122
+ /**
123
+ * Map a raw ClickUp API task object to the normalized ClickUpTask shape.
124
+ * @param {any} t - Raw task object from the ClickUp API response
125
+ * @returns {ClickUpTask}
126
+ */
127
+ function mapTask(t) {
128
+ const folderHidden = t.folder?.hidden === true
129
+ return {
130
+ id: t.id,
131
+ name: t.name,
132
+ status: t.status?.status ?? '',
133
+ statusType: t.status?.type ?? 'open',
134
+ priority: t.priority?.id ? Number(t.priority.id) : 3,
135
+ startDate: t.start_date ? localDateString(new Date(Number(t.start_date))) : null,
136
+ dueDate: t.due_date ? localDateString(new Date(Number(t.due_date))) : null,
137
+ url: t.url,
138
+ assignees: (t.assignees ?? []).map((a) => a.username),
139
+ listId: t.list?.id ?? null,
140
+ listName: t.list?.name ?? null,
141
+ folderId: folderHidden ? null : (t.folder?.id ?? null),
142
+ folderName: folderHidden ? null : (t.folder?.name ?? null),
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Get tasks assigned to the current user, with automatic pagination.
148
+ * @param {string} teamId - ClickUp workspace/team ID
149
+ * @param {{ status?: string, due_date_lt?: number }} [filters]
150
+ * @param {((count: number) => void)} [onProgress] - Called after each page with cumulative task count
151
+ * @returns {Promise<ClickUpTask[]>}
152
+ */
153
+ export async function getTasks(teamId, filters = {}, onProgress) {
154
+ let basePath = `/team/${teamId}/task?assignees[]=${(await getUser()).id}`
155
+ if (filters.status) basePath += `&statuses[]=${encodeURIComponent(filters.status)}`
156
+ if (filters.due_date_lt != null) basePath += `&due_date_lt=${filters.due_date_lt}`
157
+
158
+ let page = 0
159
+ /** @type {ClickUpTask[]} */
160
+ const allTasks = []
161
+ let hasMore = true
162
+
163
+ while (hasMore) {
164
+ const data = /** @type {any} */ (await clickupFetch(`${basePath}&page=${page}`))
165
+ allTasks.push(...data.tasks.map(mapTask))
166
+ hasMore = data.has_more ?? false
167
+ page++
168
+ if (onProgress) onProgress(allTasks.length)
169
+ }
170
+
171
+ return allTasks
172
+ }
173
+
174
+ /**
175
+ * Get tasks active today: runs two parallel requests (due today/overdue + in progress)
176
+ * and deduplicates by task ID. Excludes tasks whose status type is 'closed'.
177
+ * @param {string} teamId
178
+ * @returns {Promise<ClickUpTask[]>}
179
+ */
180
+ export async function getTasksToday(teamId) {
181
+ const endOfTodayMs = new Date().setHours(23, 59, 59, 999)
182
+
183
+ const [overdueTasks, inProgressTasks] = await Promise.all([
184
+ getTasks(teamId, { due_date_lt: endOfTodayMs }),
185
+ getTasks(teamId, { status: 'in progress' }),
186
+ ])
187
+
188
+ // De-duplicate by task ID (a task may appear in both result sets)
189
+ /** @type {Map<string, ClickUpTask>} */
190
+ const seen = new Map()
191
+ for (const t of [...overdueTasks, ...inProgressTasks]) seen.set(t.id, t)
192
+ const merged = [...seen.values()]
193
+
194
+ const today = localDateString(new Date())
195
+
196
+ return merged.filter((t) => {
197
+ // Exclude tasks that ClickUp considers closed (done/completed regardless of language)
198
+ if (t.statusType === 'closed') return false
199
+
200
+ const start = t.startDate
201
+ const due = t.dueDate
202
+
203
+ // Always include overdue tasks (due date in the past, not closed)
204
+ if (due && due < today) return true
205
+
206
+ // today is within [startDate, dueDate]
207
+ if (start && due) return start <= today && today <= due
208
+ if (start && !due) return start <= today
209
+ // No startDate: include only if due today (overdue already handled above)
210
+ if (!start && due) return today === due
211
+
212
+ // No dates at all: fall back to in-progress status keyword
213
+ const status = t.status?.toLowerCase().replace(/_/g, ' ') ?? ''
214
+ return status.includes('in progress')
215
+ })
216
+ }
217
+
218
+ /**
219
+ * Get tasks from a specific ClickUp list, with automatic pagination.
220
+ * @param {string} listId - ClickUp list ID
221
+ * @param {{ status?: string }} [filters]
222
+ * @param {((count: number) => void)} [onProgress] - Called after each page with cumulative task count
223
+ * @returns {Promise<ClickUpTask[]>}
224
+ * @throws {Error} If the list is not found or not accessible (404)
225
+ */
226
+ export async function getTasksByList(listId, filters = {}, onProgress) {
227
+ let basePath = `/list/${listId}/task?include_closed=false`
228
+ if (filters.status) basePath += `&statuses[]=${encodeURIComponent(filters.status)}`
229
+
230
+ let page = 0
231
+ /** @type {ClickUpTask[]} */
232
+ const allTasks = []
233
+ let hasMore = true
234
+
235
+ while (hasMore) {
236
+ let data
237
+ try {
238
+ data = /** @type {any} */ (await clickupFetch(`${basePath}&page=${page}`))
239
+ } catch (err) {
240
+ if (err instanceof Error && err.message.includes('404')) {
241
+ throw new Error("Lista non trovata o non accessibile. Verifica l'ID con l'URL della lista in ClickUp.")
242
+ }
243
+ throw err
244
+ }
245
+ allTasks.push(...data.tasks.map(mapTask))
246
+ hasMore = data.has_more ?? false
247
+ page++
248
+ if (onProgress) onProgress(allTasks.length)
249
+ }
250
+
251
+ return allTasks
252
+ }
253
+
254
+ /**
255
+ * Check if ClickUp is authenticated.
256
+ * @returns {Promise<boolean>}
257
+ */
258
+ export async function isAuthenticated() {
259
+ const token = await getToken()
260
+ return Boolean(token)
261
+ }
262
+
263
+ /**
264
+ * Validate the stored ClickUp token by calling the /user endpoint.
265
+ * Returns { valid: true, user } on success, { valid: false } on 401.
266
+ * @returns {Promise<{ valid: boolean, user?: { id: number, username: string } }>}
267
+ */
268
+ export async function validateToken() {
269
+ try {
270
+ const data = /** @type {any} */ (await clickupFetch('/user'))
271
+ return { valid: true, user: { id: data.user.id, username: data.user.username } }
272
+ } catch (err) {
273
+ // 401 or no token → not valid
274
+ if (err instanceof Error && (err.message.includes('401') || err.message.includes('not authenticated'))) {
275
+ return { valid: false }
276
+ }
277
+ throw err
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Get the list of ClickUp teams/workspaces accessible with the stored token.
283
+ * @returns {Promise<Array<{ id: string, name: string }>>}
284
+ */
285
+ export async function getTeams() {
286
+ const data = /** @type {any} */ (await clickupFetch('/team'))
287
+ return (data.teams ?? []).map((t) => ({ id: String(t.id), name: t.name }))
288
+ }
@@ -0,0 +1,59 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { homedir } from 'node:os'
5
+
6
+ /** @import { CLIConfig } from '../types.js' */
7
+
8
+ const CONFIG_DIR = process.env.XDG_CONFIG_HOME
9
+ ? join(process.env.XDG_CONFIG_HOME, 'dvmi')
10
+ : join(homedir(), '.config', 'dvmi')
11
+
12
+ export const CONFIG_PATH = join(CONFIG_DIR, 'config.json')
13
+
14
+ /** @type {CLIConfig} */
15
+ const DEFAULTS = {
16
+ org: '',
17
+ awsProfile: '',
18
+ awsRegion: 'eu-west-1',
19
+ shell: '',
20
+ clickup: {},
21
+ }
22
+
23
+ /**
24
+ * Load CLI config from disk. Returns defaults if file doesn't exist.
25
+ * @param {string} [configPath] - Override config path (used in tests)
26
+ * @returns {Promise<CLIConfig>}
27
+ */
28
+ export async function loadConfig(configPath = process.env.DVMI_CONFIG_PATH ?? CONFIG_PATH) {
29
+ if (!existsSync(configPath)) return { ...DEFAULTS }
30
+ try {
31
+ const raw = await readFile(configPath, 'utf8')
32
+ return { ...DEFAULTS, ...JSON.parse(raw) }
33
+ } catch {
34
+ return { ...DEFAULTS }
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Save CLI config to disk, creating directory if needed.
40
+ * @param {CLIConfig} config
41
+ * @param {string} [configPath] - Override config path (used in tests)
42
+ * @returns {Promise<void>}
43
+ */
44
+ export async function saveConfig(config, configPath = CONFIG_PATH) {
45
+ const dir = configPath.replace(/\/[^/]+$/, '')
46
+ if (!existsSync(dir)) {
47
+ await mkdir(dir, { recursive: true })
48
+ }
49
+ await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8')
50
+ }
51
+
52
+ /**
53
+ * Check whether config exists on disk.
54
+ * @param {string} [configPath]
55
+ * @returns {boolean}
56
+ */
57
+ export function configExists(configPath = CONFIG_PATH) {
58
+ return existsSync(configPath)
59
+ }
@@ -0,0 +1,210 @@
1
+ import { createOctokit } from './github.js'
2
+ import { exec } from './shell.js'
3
+ import { isOpenApi, isAsyncApi } from '../formatters/openapi.js'
4
+ import { load } from 'js-yaml'
5
+
6
+ /** @import { DocumentEntry, RepoDocsIndex, SearchMatch, DetectedRepo } from '../types.js' */
7
+
8
+ /**
9
+ * Detect GitHub owner and repo from git remote in the current working directory.
10
+ * @returns {Promise<DetectedRepo>}
11
+ */
12
+ export async function detectCurrentRepo() {
13
+ const result = await exec('git', ['remote', 'get-url', 'origin'])
14
+ if (result.exitCode !== 0) {
15
+ throw new Error('Not in a git repository. Use --repo to specify a repository.')
16
+ }
17
+ const match = result.stdout.match(/github\.com[:/]([^/]+)\/([^/.]+?)(\.git)?$/)
18
+ if (!match) {
19
+ throw new Error('Could not detect GitHub repository from git remote. Use --repo to specify a repository.')
20
+ }
21
+ return { owner: match[1], repo: match[2] }
22
+ }
23
+
24
+ /**
25
+ * Classify a tree entry as a DocumentEntry, or null if it is not a doc file.
26
+ * @param {{ path: string, size: number }} entry
27
+ * @returns {DocumentEntry|null}
28
+ */
29
+ function classifyEntry(entry) {
30
+ const { size } = entry
31
+ const path = entry.path
32
+ if (size === 0) return null
33
+ const name = path.split('/').pop() ?? path
34
+
35
+ if (/^readme\.(md|rst|txt)$/i.test(path)) {
36
+ return { name, path, type: 'readme', size }
37
+ }
38
+ if (/(openapi|swagger)\.(ya?ml|json)$/i.test(path)) {
39
+ return { name, path, type: 'swagger', size }
40
+ }
41
+ if (/asyncapi\.(ya?ml|json)$/i.test(path)) {
42
+ return { name, path, type: 'asyncapi', size }
43
+ }
44
+ if (path.startsWith('docs/') && /\.(md|rst|txt)$/.test(path)) {
45
+ return { name, path, type: 'doc', size }
46
+ }
47
+ return null
48
+ }
49
+
50
+ /**
51
+ * Sort DocumentEntry by type priority then path.
52
+ * @param {DocumentEntry} a
53
+ * @param {DocumentEntry} b
54
+ * @returns {number}
55
+ */
56
+ function sortEntries(a, b) {
57
+ const order = { readme: 0, swagger: 1, asyncapi: 2, doc: 3 }
58
+ const diff = order[a.type] - order[b.type]
59
+ return diff !== 0 ? diff : a.path.localeCompare(b.path)
60
+ }
61
+
62
+ /**
63
+ * List documentation files in a repository using the GitHub Tree API.
64
+ * @param {string} owner
65
+ * @param {string} repo
66
+ * @returns {Promise<DocumentEntry[]>}
67
+ */
68
+ export async function listDocs(owner, repo) {
69
+ const octokit = await createOctokit()
70
+
71
+ // 1. Get default branch
72
+ const { data: repoData } = await octokit.rest.repos.get({ owner, repo })
73
+ const defaultBranch = repoData.default_branch
74
+
75
+ // 2. Get HEAD SHA
76
+ const { data: ref } = await octokit.rest.git.getRef({
77
+ owner,
78
+ repo,
79
+ ref: `heads/${defaultBranch}`,
80
+ })
81
+
82
+ // 3. Fetch full recursive tree
83
+ const { data: tree } = await octokit.rest.git.getTree({
84
+ owner,
85
+ repo,
86
+ tree_sha: ref.object.sha,
87
+ recursive: '1',
88
+ })
89
+
90
+ /** @type {DocumentEntry[]} */
91
+ const entries = []
92
+ for (const e of tree.tree) {
93
+ if (e.type !== 'blob') continue
94
+ const entry = classifyEntry({ path: e.path ?? '', size: e.size ?? 0 })
95
+ if (entry) entries.push(entry)
96
+ }
97
+ return entries.sort(sortEntries)
98
+ }
99
+
100
+ /**
101
+ * Read a file's raw content from a repository via GitHub Contents API.
102
+ * @param {string} owner
103
+ * @param {string} repo
104
+ * @param {string} path
105
+ * @returns {Promise<string>}
106
+ */
107
+ export async function readFile(owner, repo, path) {
108
+ const octokit = await createOctokit()
109
+ const { data } = await octokit.rest.repos.getContent({ owner, repo, path })
110
+ if (Array.isArray(data) || data.type !== 'file') {
111
+ throw new Error(`"${path}" is not a file.`)
112
+ }
113
+ return Buffer.from(data.content, 'base64').toString('utf8')
114
+ }
115
+
116
+ /**
117
+ * Search documentation files in a repository for a given term.
118
+ * @param {string} owner
119
+ * @param {string} repo
120
+ * @param {string} term
121
+ * @returns {Promise<SearchMatch[]>}
122
+ */
123
+ export async function searchDocs(owner, repo, term) {
124
+ const entries = await listDocs(owner, repo)
125
+ const q = term.toLowerCase()
126
+
127
+ /** @type {SearchMatch[]} */
128
+ const allMatches = []
129
+
130
+ for (const entry of entries) {
131
+ let content
132
+ try {
133
+ content = await readFile(owner, repo, entry.path)
134
+ } catch {
135
+ continue
136
+ }
137
+ const lines = content.split('\n')
138
+ let occurrences = 0
139
+ for (let i = 0; i < lines.length; i++) {
140
+ if (lines[i].toLowerCase().includes(q)) {
141
+ occurrences++
142
+ allMatches.push({
143
+ file: entry.path,
144
+ line: i + 1,
145
+ context: lines[i].trim(),
146
+ occurrences: 0, // filled below
147
+ })
148
+ }
149
+ }
150
+ // Back-fill occurrences count for all matches from this file
151
+ for (const m of allMatches) {
152
+ if (m.file === entry.path) m.occurrences = occurrences
153
+ }
154
+ }
155
+
156
+ return allMatches
157
+ }
158
+
159
+ /**
160
+ * Build a RepoDocsIndex for every repository in an org.
161
+ * @param {string} org
162
+ * @param {string[]} repoNames - List of repo names to scan
163
+ * @returns {Promise<RepoDocsIndex[]>}
164
+ */
165
+ export async function listProjectsDocs(org, repoNames) {
166
+ /** @type {RepoDocsIndex[]} */
167
+ const indexes = []
168
+
169
+ for (const repoName of repoNames) {
170
+ let entries
171
+ try {
172
+ entries = await listDocs(org, repoName)
173
+ } catch {
174
+ entries = []
175
+ }
176
+ indexes.push({
177
+ repo: repoName,
178
+ hasReadme: entries.some((e) => e.type === 'readme'),
179
+ docsCount: entries.filter((e) => e.type === 'doc').length,
180
+ hasSwagger: entries.some((e) => e.type === 'swagger'),
181
+ hasAsyncApi: entries.some((e) => e.type === 'asyncapi'),
182
+ entries,
183
+ })
184
+ }
185
+
186
+ return indexes
187
+ }
188
+
189
+ /**
190
+ * Detect whether a file path is an API spec (swagger or asyncapi).
191
+ * Returns the type or null.
192
+ * @param {string} path
193
+ * @param {string} content
194
+ * @returns {'swagger'|'asyncapi'|null}
195
+ */
196
+ export function detectApiSpecType(path, content) {
197
+ if (/(openapi|swagger)\.(ya?ml|json)$/i.test(path)) return 'swagger'
198
+ if (/asyncapi\.(ya?ml|json)$/i.test(path)) return 'asyncapi'
199
+ // Try to detect from content
200
+ try {
201
+ const doc = /^\s*\{/.test(content.trim())
202
+ ? JSON.parse(content)
203
+ : load(content)
204
+ if (doc && typeof doc === 'object') {
205
+ if (isOpenApi(/** @type {Record<string, unknown>} */ (doc))) return 'swagger'
206
+ if (isAsyncApi(/** @type {Record<string, unknown>} */ (doc))) return 'asyncapi'
207
+ }
208
+ } catch { /* ignore */ }
209
+ return null
210
+ }