devvami 1.1.2 → 1.2.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.
@@ -273,6 +273,29 @@
273
273
  "upgrade.js"
274
274
  ]
275
275
  },
276
+ "welcome": {
277
+ "aliases": [],
278
+ "args": {},
279
+ "description": "Show the dvmi mission dashboard with animated intro",
280
+ "examples": [
281
+ "<%= config.bin %> welcome"
282
+ ],
283
+ "flags": {},
284
+ "hasDynamicHelp": false,
285
+ "hiddenAliases": [],
286
+ "id": "welcome",
287
+ "pluginAlias": "devvami",
288
+ "pluginName": "devvami",
289
+ "pluginType": "core",
290
+ "strict": true,
291
+ "enableJsonFlag": false,
292
+ "isESM": true,
293
+ "relativePath": [
294
+ "src",
295
+ "commands",
296
+ "welcome.js"
297
+ ]
298
+ },
276
299
  "whoami": {
277
300
  "aliases": [],
278
301
  "args": {},
@@ -1346,6 +1369,46 @@
1346
1369
  "list.js"
1347
1370
  ]
1348
1371
  },
1372
+ "security:setup": {
1373
+ "aliases": [],
1374
+ "args": {},
1375
+ "description": "Interactive wizard to install and configure credential protection tools (aws-vault, pass, GPG, Git Credential Manager, macOS Keychain)",
1376
+ "examples": [
1377
+ "<%= config.bin %> security setup",
1378
+ "<%= config.bin %> security setup --json"
1379
+ ],
1380
+ "flags": {
1381
+ "json": {
1382
+ "description": "Format output as json.",
1383
+ "helpGroup": "GLOBAL",
1384
+ "name": "json",
1385
+ "allowNo": false,
1386
+ "type": "boolean"
1387
+ },
1388
+ "help": {
1389
+ "char": "h",
1390
+ "description": "Show CLI help.",
1391
+ "name": "help",
1392
+ "allowNo": false,
1393
+ "type": "boolean"
1394
+ }
1395
+ },
1396
+ "hasDynamicHelp": false,
1397
+ "hiddenAliases": [],
1398
+ "id": "security:setup",
1399
+ "pluginAlias": "devvami",
1400
+ "pluginName": "devvami",
1401
+ "pluginType": "core",
1402
+ "strict": true,
1403
+ "enableJsonFlag": true,
1404
+ "isESM": true,
1405
+ "relativePath": [
1406
+ "src",
1407
+ "commands",
1408
+ "security",
1409
+ "setup.js"
1410
+ ]
1411
+ },
1349
1412
  "tasks:assigned": {
1350
1413
  "aliases": [],
1351
1414
  "args": {},
@@ -1498,5 +1561,5 @@
1498
1561
  ]
1499
1562
  }
1500
1563
  },
1501
- "version": "1.1.2"
1564
+ "version": "1.2.0"
1502
1565
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "devvami",
3
3
  "description": "DevEx CLI for developers and teams — manage repos, PRs, pipelines, tasks, and costs from the terminal",
4
- "version": "1.1.2",
4
+ "version": "1.2.0",
5
5
  "author": "",
6
6
  "type": "module",
7
7
  "bin": {
@@ -2,7 +2,7 @@ import { Command, Flags } from '@oclif/core'
2
2
  import chalk from 'chalk'
3
3
  import ora from 'ora'
4
4
  import { confirm, input, select } from '@inquirer/prompts'
5
- import { printBanner } from '../utils/banner.js'
5
+ import { printWelcomeScreen } from '../utils/welcome.js'
6
6
  import { typewriterLine } from '../utils/typewriter.js'
7
7
  import { detectPlatform } from '../services/platform.js'
8
8
  import { exec, which } from '../services/shell.js'
@@ -32,7 +32,7 @@ export default class Init extends Command {
32
32
  const isDryRun = flags['dry-run']
33
33
  const isJson = flags.json
34
34
 
35
- if (!isJson) await printBanner()
35
+ if (!isJson) await printWelcomeScreen(this.config.version)
36
36
 
37
37
  const platform = await detectPlatform()
38
38
  const steps = []
@@ -1,7 +1,7 @@
1
1
  import { Command, Args, Flags } from '@oclif/core'
2
2
  import ora from 'ora'
3
3
  import chalk from 'chalk'
4
- import { select } from '@inquirer/prompts'
4
+ import { select, confirm } from '@inquirer/prompts'
5
5
  import { join } from 'node:path'
6
6
  import { readdir } from 'node:fs/promises'
7
7
  import { resolveLocalPrompt, invokeTool, SUPPORTED_TOOLS } from '../../services/prompts.js'
@@ -176,6 +176,24 @@ export default class PromptsRun extends Command {
176
176
  this.log(chalk.bold(`\nRunning: ${chalk.hex('#FF9A5C')(prompt.title)}`))
177
177
  this.log(chalk.dim(` Tool: ${toolName}`) + '\n')
178
178
 
179
+ // Security: show a preview of the prompt content and ask for confirmation.
180
+ // This protects against prompt injection from tampered local files (originally
181
+ // downloaded from remote repositories). Skipped in CI/non-interactive environments.
182
+ if (!process.env.CI && process.stdin.isTTY) {
183
+ const preview = prompt.body.length > 500
184
+ ? prompt.body.slice(0, 500) + chalk.dim('\n…[truncated]')
185
+ : prompt.body
186
+ this.log(chalk.yellow('Prompt preview:'))
187
+ this.log(chalk.dim('─'.repeat(50)))
188
+ this.log(chalk.dim(preview))
189
+ this.log(chalk.dim('─'.repeat(50)) + '\n')
190
+ const ok = await confirm({ message: `Run this prompt with ${toolName}?`, default: true })
191
+ if (!ok) {
192
+ this.log(chalk.dim('Aborted.'))
193
+ return
194
+ }
195
+ }
196
+
179
197
  // Invoke tool
180
198
  try {
181
199
  await invokeTool(toolName, prompt.body)
@@ -0,0 +1,249 @@
1
+ import { Command, Flags } from '@oclif/core'
2
+ import { confirm, select } from '@inquirer/prompts'
3
+ import ora from 'ora'
4
+ import chalk from 'chalk'
5
+ import { execa } from 'execa'
6
+ import { detectPlatform } from '../../services/platform.js'
7
+ import { exec } from '../../services/shell.js'
8
+ import { buildSteps, checkToolStatus, listGpgKeys, deriveOverallStatus } from '../../services/security.js'
9
+ import { formatEducationalIntro, formatStepHeader, formatSecuritySummary } from '../../formatters/security.js'
10
+ /** @import { SetupSession, SetupStep, StepResult, PlatformInfo } from '../../types.js' */
11
+
12
+ export default class SecuritySetup extends Command {
13
+ static description = 'Interactive wizard to install and configure credential protection tools (aws-vault, pass, GPG, Git Credential Manager, macOS Keychain)'
14
+
15
+ static examples = [
16
+ '<%= config.bin %> security setup',
17
+ '<%= config.bin %> security setup --json',
18
+ ]
19
+
20
+ static enableJsonFlag = true
21
+
22
+ static flags = {
23
+ help: Flags.help({ char: 'h' }),
24
+ }
25
+
26
+ async run() {
27
+ const { flags } = await this.parse(SecuritySetup)
28
+ const isJson = flags.json
29
+
30
+ // FR-018: Detect non-interactive environments
31
+ const isCI = process.env.CI === 'true'
32
+ const isNonInteractive = !process.stdout.isTTY
33
+
34
+ if ((isCI || isNonInteractive) && !isJson) {
35
+ this.error(
36
+ 'This command requires an interactive terminal (TTY). Run with --json for a non-interactive health check.',
37
+ { exit: 1 },
38
+ )
39
+ }
40
+
41
+ // Detect platform
42
+ const platformInfo = await detectPlatform()
43
+ const { platform } = platformInfo
44
+
45
+ // FR-019: Sudo pre-flight on Linux/WSL2
46
+ if (platform !== 'macos' && !isJson) {
47
+ const sudoCheck = await exec('sudo', ['-n', 'true'])
48
+ if (sudoCheck.exitCode !== 0) {
49
+ this.error(
50
+ 'sudo access is required to install packages. Run `sudo -v` to authenticate and retry.',
51
+ { exit: 1 },
52
+ )
53
+ }
54
+ }
55
+
56
+ // --json branch: health check only (no interaction)
57
+ if (isJson) {
58
+ const tools = await checkToolStatus(platform)
59
+ const overallStatus = deriveOverallStatus(tools)
60
+ return {
61
+ platform,
62
+ selection: null,
63
+ tools,
64
+ overallStatus,
65
+ }
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Pre-check: show current tool status
70
+ // ---------------------------------------------------------------------------
71
+ const spinner = ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Checking current tool status...') }).start()
72
+ const currentStatus = await checkToolStatus(platform)
73
+ spinner.stop()
74
+
75
+ const anyInstalled = currentStatus.some((t) => t.status === 'installed' && t.status !== 'n/a')
76
+ if (anyInstalled) {
77
+ this.log(chalk.bold('\nCurrent security tool status:'))
78
+ for (const tool of currentStatus) {
79
+ if (tool.status === 'n/a') continue
80
+ let badge
81
+ if (tool.status === 'installed') badge = chalk.green('✔')
82
+ else if (tool.status === 'misconfigured') badge = chalk.yellow('⚠')
83
+ else badge = chalk.red('✗')
84
+ const versionStr = tool.version ? chalk.gray(` ${tool.version}`) : ''
85
+ this.log(` ${badge} ${tool.displayName}${versionStr}`)
86
+ if (tool.hint) this.log(chalk.dim(` → ${tool.hint}`))
87
+ }
88
+ this.log('')
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // FR-002 / FR-003: Educational intro + confirmation
93
+ // ---------------------------------------------------------------------------
94
+ this.log(formatEducationalIntro())
95
+ this.log('')
96
+
97
+ const understood = await confirm({
98
+ message: 'I understand and want to protect my credentials',
99
+ default: true,
100
+ })
101
+ if (!understood) {
102
+ this.log('Setup cancelled.')
103
+ return { platform, selection: null, tools: currentStatus, overallStatus: deriveOverallStatus(currentStatus) }
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // FR-004: Selection menu
108
+ // ---------------------------------------------------------------------------
109
+ const selectionValue = await select({
110
+ message: 'What would you like to set up?',
111
+ choices: [
112
+ { name: 'Both AWS and Git credentials (recommended)', value: 'both' },
113
+ { name: 'AWS credentials only (aws-vault)', value: 'aws' },
114
+ { name: 'Git credentials only (macOS Keychain / GCM)', value: 'git' },
115
+ ],
116
+ })
117
+
118
+ /** @type {'aws'|'git'|'both'} */
119
+ const selection = /** @type {any} */ (selectionValue)
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // GPG key prompt (Linux/WSL2 + AWS selected)
123
+ // ---------------------------------------------------------------------------
124
+ let gpgId = ''
125
+ if (platform !== 'macos' && (selection === 'aws' || selection === 'both')) {
126
+ const existingKeys = await listGpgKeys()
127
+
128
+ if (existingKeys.length > 0) {
129
+ const choices = [
130
+ ...existingKeys.map((k) => ({
131
+ name: `${k.name} <${k.email}> (${k.id})`,
132
+ value: k.id,
133
+ })),
134
+ { name: 'Create a new GPG key', value: '__new__' },
135
+ ]
136
+ const chosen = await select({
137
+ message: 'Select a GPG key for pass and Git Credential Manager:',
138
+ choices,
139
+ })
140
+ if (chosen !== '__new__') gpgId = /** @type {string} */ (chosen)
141
+ }
142
+ // If no keys or user chose __new__, gpgId stays '' and the create-gpg-key step will run interactively
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Build steps
147
+ // ---------------------------------------------------------------------------
148
+ const steps = buildSteps(platformInfo, selection, { gpgId })
149
+
150
+ /** @type {SetupSession} */
151
+ const session = {
152
+ platform,
153
+ selection,
154
+ steps,
155
+ results: new Map(),
156
+ overallStatus: 'in-progress',
157
+ }
158
+
159
+ this.log('')
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Step execution loop
163
+ // ---------------------------------------------------------------------------
164
+ for (const step of steps) {
165
+ this.log(formatStepHeader(step))
166
+
167
+ // FR-014: confirmation prompt before system-level changes
168
+ if (step.requiresConfirmation) {
169
+ const proceed = await confirm({ message: `Proceed with: ${step.label}?`, default: true })
170
+ if (!proceed) {
171
+ session.results.set(step.id, { status: 'skipped', message: 'Skipped by user' })
172
+ this.log(chalk.dim(' Skipped.'))
173
+ continue
174
+ }
175
+ }
176
+
177
+ // Special handling for GPG interactive steps (FR-010)
178
+ if (step.gpgInteractive && !gpgId) {
179
+ this.log(chalk.cyan('\n GPG will now prompt you for a passphrase in your terminal.'))
180
+ this.log(chalk.dim(' Follow the interactive prompts to complete key generation.\n'))
181
+ try {
182
+ await execa('gpg', ['--full-generate-key'], { stdio: 'inherit', reject: true })
183
+ // Refresh the gpgId from newly created key
184
+ const newKeys = await listGpgKeys()
185
+ if (newKeys.length > 0) {
186
+ gpgId = newKeys[0].id
187
+ // gpgId is now set — subsequent step closures capture it via the shared context object
188
+ }
189
+ session.results.set(step.id, { status: 'success', message: `GPG key created (${gpgId || 'new key'})` })
190
+ this.log(chalk.green(' ✔ GPG key created'))
191
+ } catch {
192
+ const result = { status: /** @type {'failed'} */ ('failed'), hint: 'Run manually: gpg --full-generate-key' }
193
+ session.results.set(step.id, result)
194
+ this.log(chalk.red(' ✗ GPG key creation failed'))
195
+ this.log(chalk.dim(` → ${result.hint}`))
196
+ session.overallStatus = 'failed'
197
+ break
198
+ }
199
+ continue
200
+ }
201
+
202
+ // Regular step with spinner
203
+ const stepSpinner = ora({ spinner: 'arc', color: false, text: chalk.dim(step.label) }).start()
204
+
205
+ let result
206
+ try {
207
+ result = await step.run()
208
+ } catch (err) {
209
+ result = {
210
+ status: /** @type {'failed'} */ ('failed'),
211
+ hint: err instanceof Error ? err.message : String(err),
212
+ }
213
+ }
214
+
215
+ session.results.set(step.id, result)
216
+
217
+ if (result.status === 'success') {
218
+ stepSpinner.succeed(chalk.green(result.message ?? step.label))
219
+ } else if (result.status === 'skipped') {
220
+ stepSpinner.info(chalk.dim(result.message ?? 'Skipped'))
221
+ } else {
222
+ // Failed — FR-015: abort immediately
223
+ stepSpinner.fail(chalk.red(`${step.label} — failed`))
224
+ if (result.hint) this.log(chalk.dim(` → ${result.hint}`))
225
+ if (result.hintUrl) this.log(chalk.dim(` ${result.hintUrl}`))
226
+ session.overallStatus = 'failed'
227
+ break
228
+ }
229
+ }
230
+
231
+ // Determine final overall status
232
+ if (session.overallStatus !== 'failed') {
233
+ const anyFailed = [...session.results.values()].some((r) => r.status === 'failed')
234
+ session.overallStatus = anyFailed ? 'failed' : 'completed'
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // FR-016: Completion summary
239
+ // ---------------------------------------------------------------------------
240
+ this.log(formatSecuritySummary(session, platformInfo))
241
+
242
+ return {
243
+ platform,
244
+ selection,
245
+ tools: currentStatus,
246
+ overallStatus: session.overallStatus === 'completed' ? 'success' : session.overallStatus === 'failed' ? 'partial' : 'not-configured',
247
+ }
248
+ }
249
+ }
@@ -0,0 +1,17 @@
1
+ import { Command } from '@oclif/core'
2
+ import { printWelcomeScreen } from '../utils/welcome.js'
3
+
4
+ /**
5
+ * Display the dvmi cyberpunk mission dashboard.
6
+ * Renders the animated DVMI logo followed by a full-color
7
+ * overview of CLI capabilities, focus areas, and quick-start commands.
8
+ */
9
+ export default class Welcome extends Command {
10
+ static description = 'Show the dvmi mission dashboard with animated intro'
11
+
12
+ static examples = ['<%= config.bin %> welcome']
13
+
14
+ async run() {
15
+ await printWelcomeScreen(this.config.version)
16
+ }
17
+ }
@@ -0,0 +1,119 @@
1
+ import chalk from 'chalk'
2
+ import { deriveOverallStatus } from '../services/security.js'
3
+
4
+ /** @import { SetupSession, SecurityToolStatus, PlatformInfo } from '../types.js' */
5
+
6
+ /**
7
+ * Format the educational introduction about credential security.
8
+ * @returns {string}
9
+ */
10
+ export function formatEducationalIntro() {
11
+ const border = chalk.dim('─'.repeat(60))
12
+ const lines = [
13
+ border,
14
+ chalk.bold.yellow(' Why credential security matters'),
15
+ border,
16
+ '',
17
+ chalk.white(' Storing secrets in plaintext (shell history, .env files,'),
18
+ chalk.white(' ~/.aws/credentials) is the leading cause of supply chain'),
19
+ chalk.white(' attacks. One leaked key can compromise your entire org.'),
20
+ '',
21
+ chalk.bold(' What this setup installs:'),
22
+ '',
23
+ chalk.cyan(' aws-vault') + chalk.white(' — stores AWS credentials in an encrypted vault'),
24
+ chalk.cyan(' ') + chalk.white(' (macOS Keychain, pass on Linux).'),
25
+ chalk.cyan(' pass ') + chalk.white(' — GPG-encrypted password store (Linux/WSL2).'),
26
+ chalk.cyan(' GCM ') + chalk.white(' — Git Credential Manager: no more PATs in files.'),
27
+ chalk.cyan(' Keychain ') + chalk.white(' — macOS Keychain as Git credential helper.'),
28
+ '',
29
+ chalk.dim(' References: https://aws.github.io/aws-vault | https://www.passwordstore.org'),
30
+ border,
31
+ ]
32
+ return lines.join('\n')
33
+ }
34
+
35
+ /**
36
+ * Format a step header line for the setup flow.
37
+ * @param {{ id: string, label: string, type: string }} step
38
+ * @returns {string}
39
+ */
40
+ export function formatStepHeader(step) {
41
+ const typeColor = {
42
+ check: chalk.blue,
43
+ install: chalk.yellow,
44
+ configure: chalk.cyan,
45
+ verify: chalk.green,
46
+ }
47
+ const colorFn = typeColor[step.type] ?? chalk.white
48
+ return ` ${colorFn(`[${step.type}]`)} ${step.label}`
49
+ }
50
+
51
+ /**
52
+ * Format the completion summary table for a setup session.
53
+ * @param {SetupSession} session
54
+ * @param {PlatformInfo} platformInfo
55
+ * @returns {string}
56
+ */
57
+ export function formatSecuritySummary(session, platformInfo) {
58
+ const border = chalk.dim('─'.repeat(60))
59
+ const lines = [
60
+ '',
61
+ border,
62
+ chalk.bold(' Security Setup — Summary'),
63
+ border,
64
+ '',
65
+ chalk.bold(` Platform: ${chalk.cyan(platformInfo.platform)}`),
66
+ chalk.bold(` Selection: ${chalk.cyan(session.selection)}`),
67
+ '',
68
+ ]
69
+
70
+ // Build a per-step result table
71
+ for (const step of session.steps) {
72
+ const result = session.results.get(step.id)
73
+ const status = result?.status ?? 'pending'
74
+ let badge
75
+ if (status === 'success') badge = chalk.green('✔')
76
+ else if (status === 'skipped') badge = chalk.dim('─')
77
+ else if (status === 'failed') badge = chalk.red('✗')
78
+ else badge = chalk.gray('○')
79
+
80
+ const label = chalk.white(step.label.padEnd(45))
81
+ const msg = result?.message ? chalk.gray(` ${result.message}`) : ''
82
+ lines.push(` ${badge} ${label}${msg}`)
83
+
84
+ if (status === 'failed' && result?.hint) {
85
+ lines.push(chalk.dim(` → ${result.hint}`))
86
+ if (result.hintUrl) lines.push(chalk.dim(` ${result.hintUrl}`))
87
+ }
88
+ }
89
+
90
+ lines.push('')
91
+
92
+ // Overall status
93
+ const successful = [...session.results.values()].filter((r) => r.status === 'success').length
94
+ const failed = [...session.results.values()].filter((r) => r.status === 'failed').length
95
+ const skipped = [...session.results.values()].filter((r) => r.status === 'skipped').length
96
+
97
+ lines.push(
98
+ ` ${chalk.green(`${successful} succeeded`)} ${chalk.dim(`${skipped} skipped`)} ${failed > 0 ? chalk.red(`${failed} failed`) : chalk.dim('0 failed')}`,
99
+ )
100
+
101
+ if (failed === 0) {
102
+ lines.push('')
103
+ lines.push(chalk.bold.green(' All done! Restart your terminal to apply shell profile changes.'))
104
+ lines.push(chalk.dim(' Then run: dvmi auth login'))
105
+ } else {
106
+ lines.push('')
107
+ lines.push(chalk.bold.red(' Setup incomplete — see failure hints above.'))
108
+ }
109
+
110
+ lines.push(border)
111
+ return lines.join('\n')
112
+ }
113
+
114
+ /**
115
+ * Derive an overall status label from tool statuses (re-exported for convenience).
116
+ * @param {SecurityToolStatus[]} tools
117
+ * @returns {'success'|'partial'|'not-configured'}
118
+ */
119
+ export { deriveOverallStatus }
package/src/help.js CHANGED
@@ -78,6 +78,12 @@ const CATEGORIES = [
78
78
  { id: 'prompts:run', hint: '[PATH] [--tool]' },
79
79
  ],
80
80
  },
81
+ {
82
+ title: 'Sicurezza & Credenziali',
83
+ cmds: [
84
+ { id: 'security:setup', hint: '[--json]' },
85
+ ],
86
+ },
81
87
  {
82
88
  title: 'Setup & Ambiente',
83
89
  cmds: [
@@ -105,6 +111,8 @@ const EXAMPLES = [
105
111
  { cmd: 'dvmi pipeline status', note: 'Ultimi workflow CI/CD' },
106
112
  { cmd: 'dvmi tasks list --search "bug"', note: 'Cerca task ClickUp' },
107
113
  { cmd: 'dvmi costs get --json', note: 'Costi AWS in formato JSON' },
114
+ { cmd: 'dvmi security setup --json', note: 'Controlla lo stato degli strumenti di sicurezza' },
115
+ { cmd: 'dvmi security setup', note: 'Wizard interattivo: installa aws-vault e GCM' },
108
116
  ]
109
117
 
110
118
  // ─── Help class ─────────────────────────────────────────────────────────────
@@ -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(() => ({})))
@@ -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
  }