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.
- package/oclif.manifest.json +64 -1
- package/package.json +1 -1
- package/src/commands/init.js +2 -2
- 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/security.js +119 -0
- package/src/help.js +8 -0
- package/src/services/clickup.js +9 -3
- 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 +66 -0
- package/src/utils/welcome.js +173 -0
package/oclif.manifest.json
CHANGED
|
@@ -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.
|
|
1564
|
+
"version": "1.2.0"
|
|
1502
1565
|
}
|
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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 ─────────────────────────────────────────────────────────────
|
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(() => ({})))
|
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
|
}
|