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.
- package/README.md +36 -1
- package/oclif.manifest.json +233 -7
- package/package.json +2 -1
- package/src/commands/costs/get.js +112 -18
- package/src/commands/costs/trend.js +165 -0
- package/src/commands/init.js +8 -3
- package/src/commands/logs/index.js +190 -0
- 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/charts.js +205 -0
- package/src/formatters/cost.js +18 -5
- package/src/formatters/security.js +119 -0
- package/src/help.js +44 -24
- package/src/services/aws-costs.js +130 -6
- package/src/services/clickup.js +9 -3
- package/src/services/cloudwatch-logs.js +92 -0
- package/src/services/config.js +17 -1
- 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 +132 -4
- package/src/utils/aws-vault.js +144 -0
- package/src/utils/welcome.js +173 -0
|
@@ -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
|
+
}
|
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 = []
|
|
@@ -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)
|
|
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
|
+
}
|