devvami 1.2.0 → 1.4.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 +380 -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/dotfiles/add.js +249 -0
- package/src/commands/dotfiles/setup.js +190 -0
- package/src/commands/dotfiles/status.js +103 -0
- package/src/commands/dotfiles/sync.js +375 -0
- package/src/commands/init.js +41 -3
- package/src/commands/logs/index.js +190 -0
- package/src/formatters/charts.js +205 -0
- package/src/formatters/cost.js +18 -5
- package/src/formatters/dotfiles.js +259 -0
- package/src/help.js +85 -28
- package/src/services/aws-costs.js +130 -6
- package/src/services/cloudwatch-logs.js +92 -0
- package/src/services/config.js +17 -1
- package/src/services/dotfiles.js +573 -0
- package/src/types.js +130 -4
- package/src/utils/aws-vault.js +144 -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
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { Command, Flags, Args } from '@oclif/core'
|
|
2
|
+
import ora from 'ora'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { checkbox, confirm, input } from '@inquirer/prompts'
|
|
5
|
+
import { detectPlatform } from '../../services/platform.js'
|
|
6
|
+
import {
|
|
7
|
+
isChezmoiInstalled,
|
|
8
|
+
getManagedFiles,
|
|
9
|
+
getDefaultFileList,
|
|
10
|
+
getSensitivePatterns,
|
|
11
|
+
isPathSensitive,
|
|
12
|
+
isWSLWindowsPath,
|
|
13
|
+
} from '../../services/dotfiles.js'
|
|
14
|
+
import { loadConfig } from '../../services/config.js'
|
|
15
|
+
import { execOrThrow } from '../../services/shell.js'
|
|
16
|
+
import { formatDotfilesAdd } from '../../formatters/dotfiles.js'
|
|
17
|
+
import { DvmiError } from '../../utils/errors.js'
|
|
18
|
+
import { homedir } from 'node:os'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import { existsSync } from 'node:fs'
|
|
21
|
+
|
|
22
|
+
/** @import { DotfilesAddResult } from '../../types.js' */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Expand tilde to home directory.
|
|
26
|
+
* @param {string} p
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function expandTilde(p) {
|
|
30
|
+
if (p.startsWith('~/') || p === '~') {
|
|
31
|
+
return join(homedir(), p.slice(2))
|
|
32
|
+
}
|
|
33
|
+
return p
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default class DotfilesAdd extends Command {
|
|
37
|
+
static description = 'Add dotfiles to chezmoi management with automatic encryption for sensitive files'
|
|
38
|
+
|
|
39
|
+
static examples = [
|
|
40
|
+
'<%= config.bin %> dotfiles add',
|
|
41
|
+
'<%= config.bin %> dotfiles add ~/.zshrc',
|
|
42
|
+
'<%= config.bin %> dotfiles add ~/.zshrc ~/.gitconfig',
|
|
43
|
+
'<%= config.bin %> dotfiles add ~/.ssh/id_ed25519 --encrypt',
|
|
44
|
+
'<%= config.bin %> dotfiles add --json ~/.zshrc',
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
static enableJsonFlag = true
|
|
48
|
+
|
|
49
|
+
static flags = {
|
|
50
|
+
help: Flags.help({ char: 'h' }),
|
|
51
|
+
encrypt: Flags.boolean({ char: 'e', description: 'Force encryption for all files being added', default: false }),
|
|
52
|
+
'no-encrypt': Flags.boolean({ description: 'Disable auto-encryption (add all as plaintext)', default: false }),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static args = {
|
|
56
|
+
files: Args.string({ description: 'File paths to add', required: false }),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// oclif does not support variadic args natively via Args.string for multiple values;
|
|
60
|
+
// we'll parse extra args from this.argv
|
|
61
|
+
static strict = false
|
|
62
|
+
|
|
63
|
+
async run() {
|
|
64
|
+
const { flags } = await this.parse(DotfilesAdd)
|
|
65
|
+
const isJson = flags.json
|
|
66
|
+
const forceEncrypt = flags.encrypt
|
|
67
|
+
const forceNoEncrypt = flags['no-encrypt']
|
|
68
|
+
|
|
69
|
+
// Collect file args from argv (strict=false allows extra positional args)
|
|
70
|
+
const rawArgs = this.argv.filter((a) => !a.startsWith('-'))
|
|
71
|
+
const fileArgs = rawArgs
|
|
72
|
+
|
|
73
|
+
// Pre-checks
|
|
74
|
+
const config = await loadConfig()
|
|
75
|
+
if (!config.dotfiles?.enabled) {
|
|
76
|
+
throw new DvmiError(
|
|
77
|
+
'Chezmoi dotfiles management is not configured',
|
|
78
|
+
'Run `dvmi dotfiles setup` first',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const chezmoiInstalled = await isChezmoiInstalled()
|
|
83
|
+
if (!chezmoiInstalled) {
|
|
84
|
+
const platformInfo = await detectPlatform()
|
|
85
|
+
const hint = platformInfo.platform === 'macos'
|
|
86
|
+
? 'Run `brew install chezmoi` or visit https://chezmoi.io/install'
|
|
87
|
+
: 'Run `sh -c "$(curl -fsLS get.chezmoi.io)"` or visit https://chezmoi.io/install'
|
|
88
|
+
throw new DvmiError('chezmoi is not installed', hint)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const platformInfo = await detectPlatform()
|
|
92
|
+
const { platform } = platformInfo
|
|
93
|
+
const sensitivePatterns = getSensitivePatterns(config)
|
|
94
|
+
|
|
95
|
+
// Get already-managed files for V-007 check
|
|
96
|
+
const managedFiles = await getManagedFiles()
|
|
97
|
+
const managedPaths = new Set(managedFiles.map((f) => f.path))
|
|
98
|
+
|
|
99
|
+
/** @type {DotfilesAddResult} */
|
|
100
|
+
const result = { added: [], skipped: [], rejected: [] }
|
|
101
|
+
|
|
102
|
+
if (fileArgs.length > 0) {
|
|
103
|
+
// Direct mode — files provided as arguments
|
|
104
|
+
for (const rawPath of fileArgs) {
|
|
105
|
+
const absPath = expandTilde(rawPath)
|
|
106
|
+
const displayPath = rawPath
|
|
107
|
+
|
|
108
|
+
// V-002: WSL2 Windows path rejection
|
|
109
|
+
if (platform === 'wsl2' && isWSLWindowsPath(absPath)) {
|
|
110
|
+
result.rejected.push({ path: displayPath, reason: 'Windows filesystem paths not supported on WSL2. Use Linux-native paths (~/) instead.' })
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// V-001: file must exist
|
|
115
|
+
if (!existsSync(absPath)) {
|
|
116
|
+
result.skipped.push({ path: displayPath, reason: 'File not found' })
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// V-007: not already managed
|
|
121
|
+
if (managedPaths.has(absPath)) {
|
|
122
|
+
result.skipped.push({ path: displayPath, reason: 'Already managed by chezmoi' })
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Determine encryption
|
|
127
|
+
let encrypt = false
|
|
128
|
+
if (forceEncrypt) {
|
|
129
|
+
encrypt = true
|
|
130
|
+
} else if (forceNoEncrypt) {
|
|
131
|
+
encrypt = false
|
|
132
|
+
} else {
|
|
133
|
+
encrypt = isPathSensitive(rawPath, sensitivePatterns)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const args = ['add']
|
|
138
|
+
if (encrypt) args.push('--encrypt')
|
|
139
|
+
args.push(absPath)
|
|
140
|
+
await execOrThrow('chezmoi', args)
|
|
141
|
+
result.added.push({ path: displayPath, encrypted: encrypt })
|
|
142
|
+
} catch {
|
|
143
|
+
result.skipped.push({ path: displayPath, reason: `Failed to add to chezmoi. Run \`chezmoi doctor\` to verify your setup.` })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (isJson) return result
|
|
148
|
+
this.log(formatDotfilesAdd(result))
|
|
149
|
+
return result
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Interactive mode — no file args
|
|
153
|
+
if (isJson) {
|
|
154
|
+
// In --json with no files: return empty result
|
|
155
|
+
return result
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Non-interactive guard for interactive mode
|
|
159
|
+
const isCI = process.env.CI === 'true'
|
|
160
|
+
const isNonInteractive = !process.stdout.isTTY
|
|
161
|
+
if (isCI || isNonInteractive) {
|
|
162
|
+
this.error(
|
|
163
|
+
'This command requires an interactive terminal (TTY) when no files are specified. Provide file paths as arguments or run with --json.',
|
|
164
|
+
{ exit: 1 },
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const spinner = ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Loading recommended files...') }).start()
|
|
169
|
+
const recommended = getDefaultFileList(platform)
|
|
170
|
+
spinner.stop()
|
|
171
|
+
|
|
172
|
+
// Filter and build choices
|
|
173
|
+
const choices = recommended.map((rec) => {
|
|
174
|
+
const absPath = expandTilde(rec.path)
|
|
175
|
+
const exists = existsSync(absPath)
|
|
176
|
+
const alreadyManaged = managedPaths.has(absPath)
|
|
177
|
+
const sensitive = rec.autoEncrypt || isPathSensitive(rec.path, sensitivePatterns)
|
|
178
|
+
const encTag = sensitive ? chalk.dim(' (auto-encrypted)') : ''
|
|
179
|
+
const statusTag = !exists ? chalk.dim(' (not found)') : alreadyManaged ? chalk.dim(' (already tracked)') : ''
|
|
180
|
+
return {
|
|
181
|
+
name: `${rec.path}${encTag}${statusTag} — ${rec.description}`,
|
|
182
|
+
value: rec.path,
|
|
183
|
+
checked: exists && !alreadyManaged,
|
|
184
|
+
disabled: alreadyManaged ? 'already tracked' : false,
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const selected = await checkbox({
|
|
189
|
+
message: 'Select files to add to chezmoi:',
|
|
190
|
+
choices,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Offer custom file
|
|
194
|
+
const addCustom = await confirm({ message: 'Add a custom file path?', default: false })
|
|
195
|
+
if (addCustom) {
|
|
196
|
+
const customPath = await input({ message: 'Enter file path:' })
|
|
197
|
+
if (customPath.trim()) selected.push(customPath.trim())
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (selected.length === 0) {
|
|
201
|
+
this.log(chalk.dim(' No files selected.'))
|
|
202
|
+
return result
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const addSpinner = ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Adding files to chezmoi...') }).start()
|
|
206
|
+
addSpinner.stop()
|
|
207
|
+
|
|
208
|
+
for (const rawPath of selected) {
|
|
209
|
+
const absPath = expandTilde(rawPath)
|
|
210
|
+
|
|
211
|
+
if (platform === 'wsl2' && isWSLWindowsPath(absPath)) {
|
|
212
|
+
result.rejected.push({ path: rawPath, reason: 'Windows filesystem paths not supported on WSL2' })
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!existsSync(absPath)) {
|
|
217
|
+
result.skipped.push({ path: rawPath, reason: 'File not found' })
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (managedPaths.has(absPath)) {
|
|
222
|
+
result.skipped.push({ path: rawPath, reason: 'Already managed by chezmoi' })
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let encrypt = false
|
|
227
|
+
if (forceEncrypt) {
|
|
228
|
+
encrypt = true
|
|
229
|
+
} else if (forceNoEncrypt) {
|
|
230
|
+
encrypt = false
|
|
231
|
+
} else {
|
|
232
|
+
encrypt = isPathSensitive(rawPath, sensitivePatterns)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const args = ['add']
|
|
237
|
+
if (encrypt) args.push('--encrypt')
|
|
238
|
+
args.push(absPath)
|
|
239
|
+
await execOrThrow('chezmoi', args)
|
|
240
|
+
result.added.push({ path: rawPath, encrypted: encrypt })
|
|
241
|
+
} catch {
|
|
242
|
+
result.skipped.push({ path: rawPath, reason: `Failed to add. Run \`chezmoi doctor\` to verify your setup.` })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
this.log(formatDotfilesAdd(result))
|
|
247
|
+
return result
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { Command, Flags } from '@oclif/core'
|
|
2
|
+
import ora from 'ora'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { confirm } from '@inquirer/prompts'
|
|
5
|
+
import { detectPlatform } from '../../services/platform.js'
|
|
6
|
+
import { isChezmoiInstalled, getChezmoiConfig, buildSetupSteps } from '../../services/dotfiles.js'
|
|
7
|
+
import { formatDotfilesSetup } from '../../formatters/dotfiles.js'
|
|
8
|
+
import { DvmiError } from '../../utils/errors.js'
|
|
9
|
+
|
|
10
|
+
/** @import { DotfilesSetupResult, SetupStep, StepResult } from '../../types.js' */
|
|
11
|
+
|
|
12
|
+
export default class DotfilesSetup extends Command {
|
|
13
|
+
static description = 'Interactive wizard to configure chezmoi with age encryption for dotfile management'
|
|
14
|
+
|
|
15
|
+
static examples = [
|
|
16
|
+
'<%= config.bin %> dotfiles setup',
|
|
17
|
+
'<%= config.bin %> dotfiles 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(DotfilesSetup)
|
|
28
|
+
const isJson = flags.json
|
|
29
|
+
|
|
30
|
+
// Non-interactive guard
|
|
31
|
+
const isCI = process.env.CI === 'true'
|
|
32
|
+
const isNonInteractive = !process.stdout.isTTY
|
|
33
|
+
if ((isCI || isNonInteractive) && !isJson) {
|
|
34
|
+
this.error(
|
|
35
|
+
'This command requires an interactive terminal (TTY). Run with --json for a non-interactive status check.',
|
|
36
|
+
{ exit: 1 },
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const platformInfo = await detectPlatform()
|
|
41
|
+
const { platform } = platformInfo
|
|
42
|
+
|
|
43
|
+
// --json branch: non-interactive setup attempt
|
|
44
|
+
if (isJson) {
|
|
45
|
+
const chezmoiInstalled = await isChezmoiInstalled()
|
|
46
|
+
if (!chezmoiInstalled) {
|
|
47
|
+
/** @type {DotfilesSetupResult} */
|
|
48
|
+
return {
|
|
49
|
+
platform,
|
|
50
|
+
chezmoiInstalled: false,
|
|
51
|
+
encryptionConfigured: false,
|
|
52
|
+
sourceDir: null,
|
|
53
|
+
publicKey: null,
|
|
54
|
+
status: 'failed',
|
|
55
|
+
message: platform === 'macos'
|
|
56
|
+
? 'chezmoi is not installed. Run `brew install chezmoi` or visit https://chezmoi.io/install'
|
|
57
|
+
: 'chezmoi is not installed. Run `sh -c "$(curl -fsLS get.chezmoi.io)"` or visit https://chezmoi.io/install',
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const existingConfig = await getChezmoiConfig()
|
|
62
|
+
const encryptionConfigured = existingConfig?.encryption?.tool === 'age' || !!existingConfig?.age?.identity
|
|
63
|
+
|
|
64
|
+
/** @type {DotfilesSetupResult} */
|
|
65
|
+
return {
|
|
66
|
+
platform,
|
|
67
|
+
chezmoiInstalled: true,
|
|
68
|
+
encryptionConfigured,
|
|
69
|
+
sourceDir: existingConfig?.sourceDir ?? existingConfig?.sourcePath ?? null,
|
|
70
|
+
publicKey: null,
|
|
71
|
+
status: 'success',
|
|
72
|
+
message: encryptionConfigured ? 'Chezmoi configured with age encryption' : 'Chezmoi configured (no encryption)',
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Interactive mode
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
const preSpinner = ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Checking chezmoi status...') }).start()
|
|
80
|
+
const chezmoiInstalled = await isChezmoiInstalled()
|
|
81
|
+
const existingConfig = await getChezmoiConfig()
|
|
82
|
+
preSpinner.stop()
|
|
83
|
+
|
|
84
|
+
if (!chezmoiInstalled) {
|
|
85
|
+
const hint = platform === 'macos'
|
|
86
|
+
? 'Run `brew install chezmoi` or visit https://chezmoi.io/install'
|
|
87
|
+
: 'Run `sh -c "$(curl -fsLS get.chezmoi.io)"` or visit https://chezmoi.io/install'
|
|
88
|
+
throw new DvmiError('chezmoi is not installed', hint)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check existing config state
|
|
92
|
+
const hasEncryption = existingConfig?.encryption?.tool === 'age' || !!existingConfig?.age?.identity
|
|
93
|
+
if (existingConfig && hasEncryption) {
|
|
94
|
+
this.log(chalk.green(' ✔ chezmoi is already configured with age encryption'))
|
|
95
|
+
const reconfigure = await confirm({ message: 'Reconfigure encryption (regenerate age key)?', default: false })
|
|
96
|
+
if (!reconfigure) {
|
|
97
|
+
const sourceDir = existingConfig?.sourceDir ?? existingConfig?.sourcePath ?? null
|
|
98
|
+
this.log(chalk.dim(' Skipped. Existing encryption configuration kept.'))
|
|
99
|
+
return { platform, chezmoiInstalled: true, encryptionConfigured: true, sourceDir, publicKey: null, status: 'skipped', message: 'Existing encryption configuration kept' }
|
|
100
|
+
}
|
|
101
|
+
} else if (existingConfig) {
|
|
102
|
+
this.log(chalk.yellow(' chezmoi is initialised but encryption is not configured — adding age encryption'))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Build and run steps
|
|
106
|
+
const steps = buildSetupSteps(platform, { existingConfig })
|
|
107
|
+
|
|
108
|
+
this.log('')
|
|
109
|
+
|
|
110
|
+
let publicKey = null
|
|
111
|
+
let sourceDir = null
|
|
112
|
+
|
|
113
|
+
for (const step of steps) {
|
|
114
|
+
const typeColor = { check: chalk.blue, install: chalk.yellow, configure: chalk.cyan, verify: chalk.green }
|
|
115
|
+
const colorFn = typeColor[step.type] ?? chalk.white
|
|
116
|
+
this.log(` ${colorFn(`[${step.type}]`)} ${step.label}`)
|
|
117
|
+
|
|
118
|
+
if (step.requiresConfirmation) {
|
|
119
|
+
const proceed = await confirm({ message: `Proceed with: ${step.label}?`, default: true })
|
|
120
|
+
if (!proceed) {
|
|
121
|
+
this.log(chalk.dim(' Skipped.'))
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const stepSpinner = ora({ spinner: 'arc', color: false, text: chalk.dim(step.label) }).start()
|
|
127
|
+
let result
|
|
128
|
+
try {
|
|
129
|
+
result = await step.run()
|
|
130
|
+
} catch (err) {
|
|
131
|
+
result = { status: /** @type {'failed'} */ ('failed'), hint: err instanceof Error ? err.message : String(err) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (result.status === 'success') {
|
|
135
|
+
stepSpinner.succeed(chalk.green(result.message ?? step.label))
|
|
136
|
+
// Extract public key and source dir from relevant steps
|
|
137
|
+
if (step.id === 'configure-encryption' && result.message) {
|
|
138
|
+
const match = result.message.match(/\(public key: (age1[a-z0-9]+)/)
|
|
139
|
+
if (match) publicKey = match[1]
|
|
140
|
+
}
|
|
141
|
+
if (step.id === 'init-chezmoi' && result.message) {
|
|
142
|
+
const match = result.message.match(/Source dir: (.+)/)
|
|
143
|
+
if (match) sourceDir = match[1]
|
|
144
|
+
}
|
|
145
|
+
} else if (result.status === 'skipped') {
|
|
146
|
+
stepSpinner.info(chalk.dim(result.message ?? 'Skipped'))
|
|
147
|
+
} else {
|
|
148
|
+
stepSpinner.fail(chalk.red(`${step.label} — failed`))
|
|
149
|
+
if (result.hint) this.log(chalk.dim(` → ${result.hint}`))
|
|
150
|
+
this.log(formatDotfilesSetup({ platform, chezmoiInstalled: true, encryptionConfigured: false, sourceDir: null, publicKey: null, status: 'failed', message: result.hint }))
|
|
151
|
+
return { platform, chezmoiInstalled: true, encryptionConfigured: false, sourceDir: null, publicKey: null, status: 'failed', message: result.hint }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Get final config
|
|
156
|
+
const finalConfig = await getChezmoiConfig()
|
|
157
|
+
if (!sourceDir) {
|
|
158
|
+
sourceDir = finalConfig?.sourceDir ?? finalConfig?.sourcePath ?? null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Try to get public key from key file
|
|
162
|
+
if (!publicKey) {
|
|
163
|
+
try {
|
|
164
|
+
const { homedir } = await import('node:os')
|
|
165
|
+
const { join } = await import('node:path')
|
|
166
|
+
const { readFile } = await import('node:fs/promises')
|
|
167
|
+
const keyPath = join(homedir(), '.config', 'chezmoi', 'key.txt')
|
|
168
|
+
const keyContent = await readFile(keyPath, 'utf8').catch(() => '')
|
|
169
|
+
const match = keyContent.match(/# public key: (age1[a-z0-9]+)/i)
|
|
170
|
+
if (match) publicKey = match[1]
|
|
171
|
+
} catch {
|
|
172
|
+
// ignore
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** @type {DotfilesSetupResult} */
|
|
177
|
+
const finalResult = {
|
|
178
|
+
platform,
|
|
179
|
+
chezmoiInstalled: true,
|
|
180
|
+
encryptionConfigured: true,
|
|
181
|
+
sourceDir,
|
|
182
|
+
publicKey,
|
|
183
|
+
status: 'success',
|
|
184
|
+
message: 'Chezmoi configured with age encryption',
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.log(formatDotfilesSetup(finalResult))
|
|
188
|
+
return finalResult
|
|
189
|
+
}
|
|
190
|
+
}
|