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.
@@ -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
+ }