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.
@@ -0,0 +1,165 @@
1
+ import { Command, Flags } from '@oclif/core'
2
+ import { input } from '@inquirer/prompts'
3
+ import ora from 'ora'
4
+ import { getTrendCosts, getTwoMonthPeriod } from '../../services/aws-costs.js'
5
+ import { loadConfig } from '../../services/config.js'
6
+ import { barChart, lineChart } from '../../formatters/charts.js'
7
+ import { DvmiError } from '../../utils/errors.js'
8
+ import {
9
+ awsVaultPrefix,
10
+ isAwsVaultSession,
11
+ reexecCurrentCommandWithAwsVault,
12
+ reexecCurrentCommandWithAwsVaultProfile,
13
+ } from '../../utils/aws-vault.js'
14
+
15
+ export default class CostsTrend extends Command {
16
+ static description = 'Show a rolling 2-month daily cost trend chart'
17
+
18
+ static examples = [
19
+ '<%= config.bin %> costs trend',
20
+ '<%= config.bin %> costs trend --line',
21
+ '<%= config.bin %> costs trend --group-by tag --tag-key env',
22
+ '<%= config.bin %> costs trend --group-by both --tag-key env',
23
+ '<%= config.bin %> costs trend --group-by tag --tag-key env --line',
24
+ '<%= config.bin %> costs trend --json',
25
+ ]
26
+
27
+ static enableJsonFlag = true
28
+
29
+ static flags = {
30
+ 'group-by': Flags.string({
31
+ description: 'Grouping dimension: service, tag, or both',
32
+ default: 'service',
33
+ options: ['service', 'tag', 'both'],
34
+ }),
35
+ 'tag-key': Flags.string({
36
+ description: 'Tag key for grouping when --group-by tag or both',
37
+ }),
38
+ line: Flags.boolean({
39
+ description: 'Render as line chart instead of default bar chart',
40
+ default: false,
41
+ }),
42
+ }
43
+
44
+ async run() {
45
+ const { flags } = await this.parse(CostsTrend)
46
+ const isJson = flags.json
47
+ const isInteractive = !isJson && process.stdout.isTTY && process.env.CI !== 'true'
48
+ const groupBy = /** @type {'service'|'tag'|'both'} */ (flags['group-by'])
49
+
50
+ const config = await loadConfig()
51
+
52
+ if (
53
+ isInteractive &&
54
+ !isAwsVaultSession() &&
55
+ process.env.DVMI_AWS_VAULT_REEXEC !== '1'
56
+ ) {
57
+ const profile = await input({
58
+ message: 'AWS profile (aws-vault):',
59
+ default: config.awsProfile || process.env.AWS_VAULT || 'default',
60
+ })
61
+
62
+ const selected = profile.trim()
63
+ if (!selected) {
64
+ this.error('AWS profile is required to run this command.')
65
+ }
66
+
67
+ const promptedReexecExitCode = await reexecCurrentCommandWithAwsVaultProfile(selected)
68
+ if (promptedReexecExitCode !== null) {
69
+ this.exit(promptedReexecExitCode)
70
+ return
71
+ }
72
+ }
73
+
74
+ // Transparent aws-vault usage: if a profile is configured and no AWS creds are present,
75
+ // re-run this exact command via `aws-vault exec <profile> -- ...`.
76
+ const reexecExitCode = await reexecCurrentCommandWithAwsVault(config)
77
+ if (reexecExitCode !== null) {
78
+ this.exit(reexecExitCode)
79
+ return
80
+ }
81
+
82
+ const configTagKey = config.projectTags ? Object.keys(config.projectTags)[0] : undefined
83
+ const tagKey = flags['tag-key'] ?? configTagKey
84
+
85
+ if ((groupBy === 'tag' || groupBy === 'both') && !tagKey) {
86
+ throw new DvmiError(
87
+ 'No tag key available.',
88
+ 'Pass --tag-key or configure projectTags in dvmi config.',
89
+ )
90
+ }
91
+
92
+ const spinner = isJson ? null : ora('Fetching cost trend data...').start()
93
+
94
+ try {
95
+ const trendSeries = await getTrendCosts(groupBy, tagKey)
96
+ spinner?.stop()
97
+
98
+ const { start, end } = getTwoMonthPeriod()
99
+
100
+ if (isJson) {
101
+ return {
102
+ groupBy,
103
+ tagKey: tagKey ?? null,
104
+ period: { start, end },
105
+ series: trendSeries,
106
+ }
107
+ }
108
+
109
+ if (trendSeries.length === 0) {
110
+ this.log('No cost data found for the last 2 months.')
111
+ return
112
+ }
113
+
114
+ // Convert CostTrendSeries[] → ChartSeries[]
115
+ // All series must share the same label (date) axis — use the union of all dates
116
+ const allDates = Array.from(
117
+ new Set(trendSeries.flatMap((s) => s.points.map((p) => p.date))),
118
+ ).sort()
119
+
120
+ /** @type {import('../../formatters/charts.js').ChartSeries[]} */
121
+ const chartSeries = trendSeries.map((s) => {
122
+ const dateToAmount = new Map(s.points.map((p) => [p.date, p.amount]))
123
+ return {
124
+ name: s.name,
125
+ values: allDates.map((d) => dateToAmount.get(d) ?? 0),
126
+ labels: allDates,
127
+ }
128
+ })
129
+
130
+ const title = `AWS Cost Trend — last 2 months (${start} → ${end})`
131
+ const rendered = flags.line
132
+ ? lineChart(chartSeries, { title })
133
+ : barChart(chartSeries, { title })
134
+
135
+ this.log(rendered)
136
+ } catch (err) {
137
+ spinner?.stop()
138
+ if (String(err).includes('AccessDenied') || String(err).includes('UnauthorizedAccess')) {
139
+ this.error('Missing IAM permission: ce:GetCostAndUsage. Contact your AWS admin.')
140
+ }
141
+ if (String(err).includes('CredentialsProviderError') || String(err).includes('No credentials')) {
142
+ if (isInteractive) {
143
+ const suggestedProfile = config.awsProfile || process.env.AWS_VAULT || 'default'
144
+ const profile = await input({
145
+ message: 'No AWS credentials. Enter aws-vault profile to retry (empty to cancel):',
146
+ default: suggestedProfile,
147
+ })
148
+
149
+ const selected = profile.trim()
150
+ if (selected) {
151
+ const retryExitCode = await reexecCurrentCommandWithAwsVaultProfile(selected)
152
+ if (retryExitCode !== null) {
153
+ this.exit(retryExitCode)
154
+ return
155
+ }
156
+ }
157
+ }
158
+
159
+ const prefix = awsVaultPrefix(config)
160
+ this.error(`No AWS credentials. Use: ${prefix}dvmi costs trend`)
161
+ }
162
+ throw err
163
+ }
164
+ }
165
+ }
@@ -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 = []
@@ -72,7 +72,12 @@ export default class Init extends Command {
72
72
  steps.push({ name: 'aws-vault', status: 'ok', action: 'found' })
73
73
  } else {
74
74
  steps.push({ name: 'aws-vault', status: 'warn', action: 'not installed' })
75
- if (!isJson) this.log(chalk.yellow(' aws-vault not found. Install: brew install aws-vault'))
75
+ if (!isJson) {
76
+ const installHint = platform.platform === 'macos'
77
+ ? 'brew install aws-vault'
78
+ : 'run `dvmi security setup` (Debian/Ubuntu/WSL2) or install aws-vault manually'
79
+ this.log(chalk.yellow(` aws-vault not found. Install: ${installHint}`))
80
+ }
76
81
  }
77
82
 
78
83
  // 4. Create/update config
@@ -0,0 +1,190 @@
1
+ import { Command, Flags } from '@oclif/core'
2
+ import ora from 'ora'
3
+ import { search, input } from '@inquirer/prompts'
4
+ import { listLogGroups, filterLogEvents, sinceToEpochMs } from '../../services/cloudwatch-logs.js'
5
+ import { loadConfig } from '../../services/config.js'
6
+ import { DvmiError } from '../../utils/errors.js'
7
+
8
+ const SINCE_OPTIONS = ['1h', '24h', '7d']
9
+
10
+ export default class Logs extends Command {
11
+ static description = 'Browse and query CloudWatch log groups interactively'
12
+
13
+ static examples = [
14
+ '<%= config.bin %> logs',
15
+ '<%= config.bin %> logs --group /aws/lambda/my-fn',
16
+ '<%= config.bin %> logs --group /aws/lambda/my-fn --filter "ERROR" --since 24h',
17
+ '<%= config.bin %> logs --group /aws/lambda/my-fn --limit 50 --json',
18
+ ]
19
+
20
+ static enableJsonFlag = true
21
+
22
+ static flags = {
23
+ group: Flags.string({
24
+ description: 'Log group name — bypasses interactive picker',
25
+ char: 'g',
26
+ }),
27
+ filter: Flags.string({
28
+ description: 'CloudWatch filter pattern (empty = all events)',
29
+ char: 'f',
30
+ default: '',
31
+ }),
32
+ since: Flags.string({
33
+ description: 'Time window: 1h, 24h, 7d',
34
+ default: '1h',
35
+ }),
36
+ limit: Flags.integer({
37
+ description: 'Max log events to return (1–10000)',
38
+ default: 100,
39
+ }),
40
+ region: Flags.string({
41
+ description: 'AWS region (defaults to project config awsRegion)',
42
+ char: 'r',
43
+ }),
44
+ }
45
+
46
+ async run() {
47
+ const { flags } = await this.parse(Logs)
48
+ const isJson = flags.json
49
+
50
+ // Validate --limit
51
+ if (flags.limit < 1 || flags.limit > 10_000) {
52
+ throw new DvmiError('--limit must be between 1 and 10000.', '')
53
+ }
54
+
55
+ // Validate --since
56
+ if (!SINCE_OPTIONS.includes(flags.since)) {
57
+ throw new DvmiError('--since must be one of: 1h, 24h, 7d.', '')
58
+ }
59
+
60
+ const config = await loadConfig()
61
+ const region = flags.region ?? config.awsRegion ?? 'eu-west-1'
62
+
63
+ let logGroupName = flags.group
64
+ let filterPattern = flags.filter
65
+
66
+ // Interactive mode: pick log group + filter pattern
67
+ if (!logGroupName) {
68
+ const spinner = ora('Loading log groups...').start()
69
+ let groups
70
+ try {
71
+ groups = await listLogGroups(region)
72
+ } catch (err) {
73
+ spinner.stop()
74
+ this._handleAwsError(err, region)
75
+ throw err
76
+ }
77
+ spinner.stop()
78
+
79
+ if (groups.length === 0) {
80
+ this.log(`No log groups found in region ${region}. Check your AWS credentials and region.`)
81
+ return
82
+ }
83
+
84
+ try {
85
+ logGroupName = await search({
86
+ message: 'Select a log group',
87
+ source: async (input) => {
88
+ const term = (input ?? '').toLowerCase()
89
+ return groups
90
+ .filter((g) => g.name.toLowerCase().includes(term))
91
+ .map((g) => ({ name: g.name, value: g.name }))
92
+ },
93
+ })
94
+
95
+ filterPattern = await input({
96
+ message: 'Filter pattern (leave empty for all events)',
97
+ default: '',
98
+ })
99
+ } catch {
100
+ // Ctrl+C — clean exit with code 130
101
+ process.exit(130)
102
+ }
103
+ }
104
+
105
+ const { startTime, endTime } = sinceToEpochMs(/** @type {'1h'|'24h'|'7d'} */ (flags.since))
106
+
107
+ const fetchSpinner = isJson ? null : ora('Fetching log events...').start()
108
+
109
+ let result
110
+ try {
111
+ result = await filterLogEvents(logGroupName, filterPattern, startTime, endTime, flags.limit, region)
112
+ } catch (err) {
113
+ fetchSpinner?.stop()
114
+ this._handleAwsError(err, region, logGroupName)
115
+ throw err
116
+ }
117
+ fetchSpinner?.stop()
118
+
119
+ if (isJson) {
120
+ // NDJSON to stdout, summary to stderr
121
+ for (const event of result.events) {
122
+ this.log(
123
+ JSON.stringify({
124
+ eventId: event.eventId,
125
+ logStreamName: event.logStreamName,
126
+ timestamp: event.timestamp,
127
+ message: event.message,
128
+ }),
129
+ )
130
+ }
131
+ process.stderr.write(
132
+ JSON.stringify({
133
+ logGroupName: result.logGroupName,
134
+ filterPattern: result.filterPattern,
135
+ startTime: result.startTime,
136
+ endTime: result.endTime,
137
+ truncated: result.truncated,
138
+ count: result.events.length,
139
+ }) + '\n',
140
+ )
141
+ return
142
+ }
143
+
144
+ // Table output
145
+ const startIso = new Date(startTime).toISOString()
146
+ const endIso = new Date(endTime).toISOString()
147
+ const divider = '─'.repeat(74)
148
+
149
+ this.log(`Log Group: ${logGroupName}`)
150
+ this.log(`Period: last ${flags.since} (${startIso} → ${endIso})`)
151
+ this.log(`Filter: ${filterPattern ? `"${filterPattern}"` : '(none)'}`)
152
+ this.log(divider)
153
+
154
+ for (const event of result.events) {
155
+ const ts = new Date(event.timestamp).toISOString()
156
+ const msg = event.message.length > 200 ? event.message.slice(0, 200) + '…' : event.message
157
+ this.log(` ${ts} ${event.logStreamName.slice(-20).padEnd(20)} ${msg}`)
158
+ }
159
+
160
+ this.log(divider)
161
+ const truncationNotice = result.truncated ? ' [Truncated — use --limit or a narrower --since to see more]' : ''
162
+ this.log(` ${result.events.length} events shown${truncationNotice}`)
163
+ }
164
+
165
+ /**
166
+ * Handle common AWS errors and throw DvmiError with spec-defined messages.
167
+ * @param {unknown} err
168
+ * @param {string} _region
169
+ * @param {string} [_logGroupName]
170
+ */
171
+ _handleAwsError(err, _region, _logGroupName) {
172
+ const msg = String(err)
173
+ if (msg.includes('AccessDenied') || msg.includes('UnauthorizedAccess')) {
174
+ this.error(
175
+ 'Access denied. Ensure your role has logs:DescribeLogGroups and logs:FilterLogEvents permissions.',
176
+ )
177
+ }
178
+ if (msg.includes('ResourceNotFoundException')) {
179
+ this.error(
180
+ `Log group not found. Check the name and confirm you are using the correct region (--region).`,
181
+ )
182
+ }
183
+ if (msg.includes('InvalidParameterException')) {
184
+ this.error('Invalid filter pattern or parameter. Check the pattern syntax and time range.')
185
+ }
186
+ if (msg.includes('CredentialsProviderError') || msg.includes('No credentials')) {
187
+ this.error('No AWS credentials. Configure aws-vault and run `dvmi init` to set up your profile.')
188
+ }
189
+ }
190
+ }
@@ -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
+ }