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,573 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { which, exec, execOrThrow } from './shell.js'
|
|
5
|
+
import { loadConfig, saveConfig } from './config.js'
|
|
6
|
+
|
|
7
|
+
/** @import { Platform, DotfileEntry, DotfileRecommendation, DotfilesSetupResult, DotfilesAddResult, SetupStep, StepResult, CLIConfig } from '../types.js' */
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Constants
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sensitive path glob patterns that trigger auto-encryption.
|
|
15
|
+
* @type {string[]}
|
|
16
|
+
*/
|
|
17
|
+
export const SENSITIVE_PATTERNS = [
|
|
18
|
+
'~/.ssh/id_*',
|
|
19
|
+
'~/.gnupg/*',
|
|
20
|
+
'~/.netrc',
|
|
21
|
+
'~/.aws/credentials',
|
|
22
|
+
'**/.*token*',
|
|
23
|
+
'**/.*credential*',
|
|
24
|
+
'**/.*secret*',
|
|
25
|
+
'**/.*password*',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Curated dotfile recommendations with platform and category metadata.
|
|
30
|
+
* @type {DotfileRecommendation[]}
|
|
31
|
+
*/
|
|
32
|
+
export const DEFAULT_FILE_LIST = [
|
|
33
|
+
// Shell
|
|
34
|
+
{ path: '~/.zshrc', category: 'shell', platforms: ['macos', 'linux', 'wsl2'], autoEncrypt: false, description: 'Zsh configuration' },
|
|
35
|
+
{ path: '~/.bashrc', category: 'shell', platforms: ['linux', 'wsl2'], autoEncrypt: false, description: 'Bash configuration' },
|
|
36
|
+
{ path: '~/.bash_profile', category: 'shell', platforms: ['macos', 'linux', 'wsl2'], autoEncrypt: false, description: 'Bash profile' },
|
|
37
|
+
{ path: '~/.zprofile', category: 'shell', platforms: ['macos'], autoEncrypt: false, description: 'Zsh login profile' },
|
|
38
|
+
{ path: '~/.config/fish/config.fish', category: 'shell', platforms: ['macos', 'linux', 'wsl2'], autoEncrypt: false, description: 'Fish shell configuration' },
|
|
39
|
+
// Git
|
|
40
|
+
{ path: '~/.gitconfig', category: 'git', platforms: ['macos', 'linux', 'wsl2'], autoEncrypt: false, description: 'Git global config' },
|
|
41
|
+
{ path: '~/.gitignore_global', category: 'git', platforms: ['macos', 'linux', 'wsl2'], autoEncrypt: false, description: 'Global gitignore patterns' },
|
|
42
|
+
// Editor
|
|
43
|
+
{ path: '~/.vimrc', category: 'editor', platforms: ['macos', 'linux', 'wsl2'], autoEncrypt: false, description: 'Vim configuration' },
|
|
44
|
+
{ path: '~/.config/nvim/init.vim', category: 'editor', platforms: ['macos', 'linux', 'wsl2'], autoEncrypt: false, description: 'Neovim configuration' },
|
|
45
|
+
{ path: '~/.config/nvim/init.lua', category: 'editor', platforms: ['macos', 'linux', 'wsl2'], autoEncrypt: false, description: 'Neovim Lua configuration' },
|
|
46
|
+
// Package / macOS-specific
|
|
47
|
+
{ path: '~/.Brewfile', category: 'package', platforms: ['macos'], autoEncrypt: false, description: 'Homebrew bundle file' },
|
|
48
|
+
{ path: '~/.config/nvim', category: 'editor', platforms: ['macos', 'linux', 'wsl2'], autoEncrypt: false, description: 'Neovim config directory' },
|
|
49
|
+
// Security
|
|
50
|
+
{ path: '~/.ssh/config', category: 'security', platforms: ['macos', 'linux', 'wsl2'], autoEncrypt: true, description: 'SSH client configuration (auto-encrypted)' },
|
|
51
|
+
{ path: '~/.ssh/id_ed25519', category: 'security', platforms: ['macos', 'linux', 'wsl2'], autoEncrypt: true, description: 'SSH private key (auto-encrypted)' },
|
|
52
|
+
{ path: '~/.ssh/id_rsa', category: 'security', platforms: ['macos', 'linux', 'wsl2'], autoEncrypt: true, description: 'SSH RSA private key (auto-encrypted)' },
|
|
53
|
+
{ path: '~/.gnupg/pubring.kbx', category: 'security', platforms: ['macos', 'linux', 'wsl2'], autoEncrypt: true, description: 'GPG public keyring (auto-encrypted)' },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// T005: isChezmoiInstalled
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check whether chezmoi is available in PATH.
|
|
62
|
+
* @returns {Promise<boolean>}
|
|
63
|
+
*/
|
|
64
|
+
export async function isChezmoiInstalled() {
|
|
65
|
+
const path = await which('chezmoi')
|
|
66
|
+
return path !== null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// T006: getChezmoiConfig
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Retrieve chezmoi's current configuration as a parsed object.
|
|
75
|
+
* Returns null if chezmoi is not initialised or the command fails.
|
|
76
|
+
* @returns {Promise<Object|null>}
|
|
77
|
+
*/
|
|
78
|
+
export async function getChezmoiConfig() {
|
|
79
|
+
const result = await exec('chezmoi', ['dump-config', '--format', 'json'])
|
|
80
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) return null
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(result.stdout)
|
|
83
|
+
} catch {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// T007: getManagedFiles
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* List all files currently managed by chezmoi with encryption metadata.
|
|
94
|
+
* Uses `chezmoi managed --format json --path-style all` to get both target
|
|
95
|
+
* and source paths, then inspects source path for `encrypted_` prefix.
|
|
96
|
+
* @returns {Promise<DotfileEntry[]>}
|
|
97
|
+
*/
|
|
98
|
+
export async function getManagedFiles() {
|
|
99
|
+
const result = await exec('chezmoi', ['managed', '--format', 'json', '--path-style', 'all'])
|
|
100
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) return []
|
|
101
|
+
|
|
102
|
+
let raw
|
|
103
|
+
try {
|
|
104
|
+
raw = JSON.parse(result.stdout)
|
|
105
|
+
} catch {
|
|
106
|
+
return []
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// chezmoi returns an array of objects: { targetPath, sourcePath, sourceRelPath, type }
|
|
110
|
+
if (!Array.isArray(raw)) return []
|
|
111
|
+
|
|
112
|
+
return raw.map((entry) => {
|
|
113
|
+
const sourcePath = entry.sourcePath ?? entry.sourceRelPath ?? ''
|
|
114
|
+
const basename = sourcePath.split('/').at(-1) ?? ''
|
|
115
|
+
const parentDir = sourcePath.split('/').slice(-2, -1)[0] ?? ''
|
|
116
|
+
const encrypted = basename.startsWith('encrypted_') || parentDir.startsWith('encrypted_')
|
|
117
|
+
return {
|
|
118
|
+
path: entry.targetPath ?? entry.path ?? '',
|
|
119
|
+
sourcePath,
|
|
120
|
+
encrypted,
|
|
121
|
+
type: /** @type {'file'|'dir'|'symlink'} */ (entry.type ?? 'file'),
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// T008: isPathSensitive
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Expand a tilde-prefixed path to absolute.
|
|
132
|
+
* @param {string} p
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
function expandTilde(p) {
|
|
136
|
+
if (p.startsWith('~/') || p === '~') {
|
|
137
|
+
return join(homedir(), p.slice(2))
|
|
138
|
+
}
|
|
139
|
+
return p
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Convert a simple glob pattern (supporting `*`, `**`, `?`) to a RegExp.
|
|
144
|
+
* Handles tilde expansion. Case-insensitive.
|
|
145
|
+
* @param {string} pattern
|
|
146
|
+
* @returns {RegExp}
|
|
147
|
+
*/
|
|
148
|
+
function globToRegex(pattern) {
|
|
149
|
+
const expanded = expandTilde(pattern)
|
|
150
|
+
// Split on `**` to handle double-star separately
|
|
151
|
+
const parts = expanded.split('**')
|
|
152
|
+
const escaped = parts.map((part) =>
|
|
153
|
+
part
|
|
154
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex special chars
|
|
155
|
+
.replace(/\*/g, '[^/]*') // single * → any non-separator
|
|
156
|
+
.replace(/\?/g, '[^/]'), // ? → any single non-separator char
|
|
157
|
+
)
|
|
158
|
+
const src = escaped.join('.*') // ** → match anything including /
|
|
159
|
+
return new RegExp(`^${src}$`, 'i')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Check whether a file path matches any sensitive glob pattern.
|
|
164
|
+
* Both tilde and absolute paths are supported.
|
|
165
|
+
* @param {string} filePath
|
|
166
|
+
* @param {string[]} patterns
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
export function isPathSensitive(filePath, patterns) {
|
|
170
|
+
const absPath = expandTilde(filePath)
|
|
171
|
+
for (const pattern of patterns) {
|
|
172
|
+
if (globToRegex(pattern).test(absPath)) return true
|
|
173
|
+
// Also test the original (non-expanded) path for patterns without tilde
|
|
174
|
+
if (globToRegex(pattern).test(filePath)) return true
|
|
175
|
+
}
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// T009: isWSLWindowsPath
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Detect if a file path is on a Windows filesystem mount under WSL2.
|
|
185
|
+
* Paths like /mnt/c/Users/... are Windows mounts and cannot be managed
|
|
186
|
+
* by chezmoi (they live outside $HOME on the Linux side).
|
|
187
|
+
* @param {string} filePath
|
|
188
|
+
* @returns {boolean}
|
|
189
|
+
*/
|
|
190
|
+
export function isWSLWindowsPath(filePath) {
|
|
191
|
+
return /^\/mnt\/[a-z]\//i.test(filePath)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// T010: getDefaultFileList
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Return the curated list of recommended dotfiles filtered for a given platform.
|
|
200
|
+
* @param {Platform} platform
|
|
201
|
+
* @returns {DotfileRecommendation[]}
|
|
202
|
+
*/
|
|
203
|
+
export function getDefaultFileList(platform) {
|
|
204
|
+
return DEFAULT_FILE_LIST.filter((f) => f.platforms.includes(platform))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// T011: getSensitivePatterns
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Merge hardcoded sensitive patterns with any user-configured custom patterns.
|
|
213
|
+
* @param {CLIConfig} config
|
|
214
|
+
* @returns {string[]}
|
|
215
|
+
*/
|
|
216
|
+
export function getSensitivePatterns(config) {
|
|
217
|
+
const custom = config.dotfiles?.customSensitivePatterns ?? []
|
|
218
|
+
return [...SENSITIVE_PATTERNS, ...custom]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// T017: buildSetupSteps
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Build an ordered list of setup steps for chezmoi initialisation with age encryption.
|
|
227
|
+
* Pure function — all side effects are in the `run()` closures.
|
|
228
|
+
* @param {Platform} platform
|
|
229
|
+
* @param {{ existingConfig?: Object|null, ageKeyPath?: string }} [options]
|
|
230
|
+
* @returns {SetupStep[]}
|
|
231
|
+
*/
|
|
232
|
+
export function buildSetupSteps(platform, options = {}) {
|
|
233
|
+
const ageKeyPath = options.ageKeyPath ?? join(homedir(), '.config', 'chezmoi', 'key.txt')
|
|
234
|
+
const chezmoiConfigDir = join(homedir(), '.config', 'chezmoi')
|
|
235
|
+
|
|
236
|
+
/** @type {SetupStep[]} */
|
|
237
|
+
const steps = []
|
|
238
|
+
|
|
239
|
+
// Step 1: Check chezmoi is installed
|
|
240
|
+
steps.push({
|
|
241
|
+
id: 'check-chezmoi',
|
|
242
|
+
label: 'Check chezmoi installation',
|
|
243
|
+
toolId: 'chezmoi',
|
|
244
|
+
type: 'check',
|
|
245
|
+
requiresConfirmation: false,
|
|
246
|
+
run: async () => {
|
|
247
|
+
const installed = await isChezmoiInstalled()
|
|
248
|
+
if (!installed) {
|
|
249
|
+
const hint = platform === 'macos'
|
|
250
|
+
? 'Run `brew install chezmoi` or visit https://chezmoi.io/install'
|
|
251
|
+
: 'Run `sh -c "$(curl -fsLS get.chezmoi.io)"` or visit https://chezmoi.io/install'
|
|
252
|
+
return { status: 'failed', hint }
|
|
253
|
+
}
|
|
254
|
+
const result = await exec('chezmoi', ['--version'])
|
|
255
|
+
const version = (result.stdout || result.stderr).trim()
|
|
256
|
+
return { status: 'success', message: `chezmoi ${version}` }
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// Step 2: Check for existing config
|
|
261
|
+
steps.push({
|
|
262
|
+
id: 'check-existing-config',
|
|
263
|
+
label: 'Check existing chezmoi configuration',
|
|
264
|
+
toolId: 'chezmoi',
|
|
265
|
+
type: 'check',
|
|
266
|
+
requiresConfirmation: false,
|
|
267
|
+
run: async () => {
|
|
268
|
+
const config = options.existingConfig !== undefined ? options.existingConfig : await getChezmoiConfig()
|
|
269
|
+
if (!config) {
|
|
270
|
+
return { status: 'success', message: 'No existing configuration — fresh setup' }
|
|
271
|
+
}
|
|
272
|
+
const hasEncryption = config.encryption?.tool === 'age' || !!config.age?.identity
|
|
273
|
+
if (hasEncryption) {
|
|
274
|
+
return { status: 'skipped', message: 'Age encryption already configured' }
|
|
275
|
+
}
|
|
276
|
+
return { status: 'success', message: 'Existing config found without encryption — will add age' }
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// Step 3: Generate age key pair
|
|
281
|
+
steps.push({
|
|
282
|
+
id: 'generate-age-key',
|
|
283
|
+
label: 'Generate age encryption key pair',
|
|
284
|
+
toolId: 'chezmoi',
|
|
285
|
+
type: 'configure',
|
|
286
|
+
requiresConfirmation: true,
|
|
287
|
+
run: async () => {
|
|
288
|
+
// Skip if key already exists
|
|
289
|
+
if (existsSync(ageKeyPath)) {
|
|
290
|
+
return { status: 'skipped', message: `Age key already exists at ${ageKeyPath}` }
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
// chezmoi uses `age-keygen` via its own embedded command
|
|
294
|
+
await execOrThrow('chezmoi', ['age', 'keygen', '-o', ageKeyPath])
|
|
295
|
+
return { status: 'success', message: `Age key generated at ${ageKeyPath}` }
|
|
296
|
+
} catch {
|
|
297
|
+
// Fallback: try standalone age-keygen
|
|
298
|
+
try {
|
|
299
|
+
// age-keygen writes public key to stderr, private key to file
|
|
300
|
+
await execOrThrow('age-keygen', ['-o', ageKeyPath])
|
|
301
|
+
return { status: 'success', message: `Age key generated at ${ageKeyPath}` }
|
|
302
|
+
} catch {
|
|
303
|
+
return {
|
|
304
|
+
status: 'failed',
|
|
305
|
+
hint: 'Failed to generate age encryption key. Verify chezmoi is properly installed: `chezmoi doctor`',
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// Step 4: Configure chezmoi.toml with age encryption
|
|
313
|
+
steps.push({
|
|
314
|
+
id: 'configure-encryption',
|
|
315
|
+
label: 'Configure chezmoi with age encryption',
|
|
316
|
+
toolId: 'chezmoi',
|
|
317
|
+
type: 'configure',
|
|
318
|
+
requiresConfirmation: true,
|
|
319
|
+
run: async () => {
|
|
320
|
+
try {
|
|
321
|
+
// Read the public key from the key file (age-keygen outputs "# public key: age1..." as comment)
|
|
322
|
+
const keyResult = await exec('cat', [ageKeyPath])
|
|
323
|
+
const pubKeyMatch = keyResult.stdout.match(/# public key: (age1[a-z0-9]+)/i)
|
|
324
|
+
const publicKey = pubKeyMatch?.[1] ?? null
|
|
325
|
+
|
|
326
|
+
// Write chezmoi.toml
|
|
327
|
+
const configPath = join(chezmoiConfigDir, 'chezmoi.toml')
|
|
328
|
+
const tomlContent = [
|
|
329
|
+
'[age]',
|
|
330
|
+
` identity = "${ageKeyPath}"`,
|
|
331
|
+
publicKey ? ` recipients = ["${publicKey}"]` : '',
|
|
332
|
+
'',
|
|
333
|
+
'[encryption]',
|
|
334
|
+
' tool = "age"',
|
|
335
|
+
'',
|
|
336
|
+
]
|
|
337
|
+
.filter((l) => l !== undefined)
|
|
338
|
+
.join('\n')
|
|
339
|
+
|
|
340
|
+
const { writeFile, mkdir } = await import('node:fs/promises')
|
|
341
|
+
await mkdir(chezmoiConfigDir, { recursive: true })
|
|
342
|
+
await writeFile(configPath, tomlContent, 'utf8')
|
|
343
|
+
|
|
344
|
+
return { status: 'success', message: `chezmoi.toml written with age encryption${publicKey ? ` (public key: ${publicKey.slice(0, 16)}...)` : ''}` }
|
|
345
|
+
} catch (err) {
|
|
346
|
+
return {
|
|
347
|
+
status: 'failed',
|
|
348
|
+
hint: `Failed to write chezmoi config: ${err instanceof Error ? err.message : String(err)}`,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// Step 5: Init chezmoi source directory
|
|
355
|
+
steps.push({
|
|
356
|
+
id: 'init-chezmoi',
|
|
357
|
+
label: 'Initialise chezmoi source directory',
|
|
358
|
+
toolId: 'chezmoi',
|
|
359
|
+
type: 'configure',
|
|
360
|
+
requiresConfirmation: false,
|
|
361
|
+
run: async () => {
|
|
362
|
+
try {
|
|
363
|
+
await execOrThrow('chezmoi', ['init'])
|
|
364
|
+
const configResult = await getChezmoiConfig()
|
|
365
|
+
const sourceDir = configResult?.sourceDir ?? configResult?.sourcePath ?? null
|
|
366
|
+
return { status: 'success', message: sourceDir ? `Source dir: ${sourceDir}` : 'chezmoi initialised' }
|
|
367
|
+
} catch {
|
|
368
|
+
// init may fail if already initialised — that's ok
|
|
369
|
+
const configResult = await getChezmoiConfig()
|
|
370
|
+
if (configResult) {
|
|
371
|
+
return { status: 'skipped', message: 'chezmoi already initialised' }
|
|
372
|
+
}
|
|
373
|
+
return { status: 'failed', hint: 'Run `chezmoi doctor` to diagnose init failure' }
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// Step 6: Save dvmi config
|
|
379
|
+
steps.push({
|
|
380
|
+
id: 'save-dvmi-config',
|
|
381
|
+
label: 'Enable dotfiles management in dvmi config',
|
|
382
|
+
toolId: 'chezmoi',
|
|
383
|
+
type: 'configure',
|
|
384
|
+
requiresConfirmation: false,
|
|
385
|
+
run: async () => {
|
|
386
|
+
try {
|
|
387
|
+
const config = await loadConfig()
|
|
388
|
+
config.dotfiles = { ...config.dotfiles, enabled: true }
|
|
389
|
+
await saveConfig(config)
|
|
390
|
+
return { status: 'success', message: 'dvmi config updated: dotfiles.enabled = true' }
|
|
391
|
+
} catch (err) {
|
|
392
|
+
return {
|
|
393
|
+
status: 'failed',
|
|
394
|
+
hint: `Failed to update dvmi config: ${err instanceof Error ? err.message : String(err)}`,
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
return steps
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// T018: setupChezmoiInline (for dvmi init integration)
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Minimal chezmoi setup flow suitable for embedding in `dvmi init`.
|
|
409
|
+
* Does NOT do file tracking or remote setup.
|
|
410
|
+
* @param {Platform} platform
|
|
411
|
+
* @returns {Promise<DotfilesSetupResult>}
|
|
412
|
+
*/
|
|
413
|
+
export async function setupChezmoiInline(platform) {
|
|
414
|
+
const ageKeyPath = join(homedir(), '.config', 'chezmoi', 'key.txt')
|
|
415
|
+
const chezmoiConfigDir = join(homedir(), '.config', 'chezmoi')
|
|
416
|
+
|
|
417
|
+
const chezmoiInstalled = await isChezmoiInstalled()
|
|
418
|
+
if (!chezmoiInstalled) {
|
|
419
|
+
return {
|
|
420
|
+
platform,
|
|
421
|
+
chezmoiInstalled: false,
|
|
422
|
+
encryptionConfigured: false,
|
|
423
|
+
sourceDir: null,
|
|
424
|
+
publicKey: null,
|
|
425
|
+
status: 'skipped',
|
|
426
|
+
message: 'chezmoi not installed — run `dvmi dotfiles setup` after installing chezmoi',
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
try {
|
|
431
|
+
// Generate key if missing
|
|
432
|
+
if (!existsSync(ageKeyPath)) {
|
|
433
|
+
try {
|
|
434
|
+
await execOrThrow('chezmoi', ['age', 'keygen', '-o', ageKeyPath])
|
|
435
|
+
} catch {
|
|
436
|
+
await execOrThrow('age-keygen', ['-o', ageKeyPath])
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Extract public key
|
|
441
|
+
const keyResult = await exec('cat', [ageKeyPath])
|
|
442
|
+
const pubKeyMatch = keyResult.stdout.match(/# public key: (age1[a-z0-9]+)/i)
|
|
443
|
+
const publicKey = pubKeyMatch?.[1] ?? null
|
|
444
|
+
|
|
445
|
+
// Write chezmoi.toml
|
|
446
|
+
const configPath = join(chezmoiConfigDir, 'chezmoi.toml')
|
|
447
|
+
const tomlContent = [
|
|
448
|
+
'[age]',
|
|
449
|
+
` identity = "${ageKeyPath}"`,
|
|
450
|
+
publicKey ? ` recipients = ["${publicKey}"]` : '',
|
|
451
|
+
'',
|
|
452
|
+
'[encryption]',
|
|
453
|
+
' tool = "age"',
|
|
454
|
+
'',
|
|
455
|
+
]
|
|
456
|
+
.filter((l) => l !== undefined)
|
|
457
|
+
.join('\n')
|
|
458
|
+
|
|
459
|
+
const { writeFile, mkdir } = await import('node:fs/promises')
|
|
460
|
+
await mkdir(chezmoiConfigDir, { recursive: true })
|
|
461
|
+
await writeFile(configPath, tomlContent, 'utf8')
|
|
462
|
+
|
|
463
|
+
// Init chezmoi
|
|
464
|
+
await exec('chezmoi', ['init']).catch(() => null)
|
|
465
|
+
|
|
466
|
+
// Get source dir
|
|
467
|
+
const chezmoiConfig = await getChezmoiConfig()
|
|
468
|
+
const sourceDir = chezmoiConfig?.sourceDir ?? chezmoiConfig?.sourcePath ?? null
|
|
469
|
+
|
|
470
|
+
// Save dvmi config
|
|
471
|
+
const dvmiConfig = await loadConfig()
|
|
472
|
+
dvmiConfig.dotfiles = { ...(dvmiConfig.dotfiles ?? {}), enabled: true }
|
|
473
|
+
await saveConfig(dvmiConfig)
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
platform,
|
|
477
|
+
chezmoiInstalled: true,
|
|
478
|
+
encryptionConfigured: true,
|
|
479
|
+
sourceDir,
|
|
480
|
+
publicKey,
|
|
481
|
+
status: 'success',
|
|
482
|
+
message: 'Chezmoi configured with age encryption',
|
|
483
|
+
}
|
|
484
|
+
} catch (err) {
|
|
485
|
+
return {
|
|
486
|
+
platform,
|
|
487
|
+
chezmoiInstalled: true,
|
|
488
|
+
encryptionConfigured: false,
|
|
489
|
+
sourceDir: null,
|
|
490
|
+
publicKey: null,
|
|
491
|
+
status: 'failed',
|
|
492
|
+
message: err instanceof Error ? err.message : String(err),
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
// T025: buildAddSteps
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Build steps to add files to chezmoi management.
|
|
503
|
+
* @param {{ path: string, encrypt: boolean }[]} files - Files to add with explicit encryption flag
|
|
504
|
+
* @param {Platform} platform
|
|
505
|
+
* @returns {SetupStep[]}
|
|
506
|
+
*/
|
|
507
|
+
export function buildAddSteps(files, platform) {
|
|
508
|
+
/** @type {SetupStep[]} */
|
|
509
|
+
const steps = []
|
|
510
|
+
|
|
511
|
+
for (const file of files) {
|
|
512
|
+
const absPath = expandTilde(file.path)
|
|
513
|
+
steps.push({
|
|
514
|
+
id: `add-${file.path.replace(/[^a-z0-9]/gi, '-')}`,
|
|
515
|
+
label: `Add ${file.path}${file.encrypt ? ' (encrypted)' : ''}`,
|
|
516
|
+
toolId: 'chezmoi',
|
|
517
|
+
type: 'configure',
|
|
518
|
+
requiresConfirmation: false,
|
|
519
|
+
run: async () => {
|
|
520
|
+
// V-001: file must exist
|
|
521
|
+
if (!existsSync(absPath)) {
|
|
522
|
+
return { status: 'skipped', message: `${file.path}: file not found` }
|
|
523
|
+
}
|
|
524
|
+
// V-002: WSL2 Windows path rejection
|
|
525
|
+
if (platform === 'wsl2' && isWSLWindowsPath(absPath)) {
|
|
526
|
+
return { status: 'failed', hint: `${file.path}: Windows filesystem paths not supported on WSL2. Use Linux-native paths (~/) instead.` }
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
const args = ['add']
|
|
530
|
+
if (file.encrypt) args.push('--encrypt')
|
|
531
|
+
args.push(absPath)
|
|
532
|
+
await execOrThrow('chezmoi', args)
|
|
533
|
+
return { status: 'success', message: `${file.path} added${file.encrypt ? ' (encrypted)' : ''}` }
|
|
534
|
+
} catch {
|
|
535
|
+
return {
|
|
536
|
+
status: 'failed',
|
|
537
|
+
hint: `Failed to add ${file.path} to chezmoi. Run \`chezmoi doctor\` to verify your setup.`,
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return steps
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
// T036: getChezmoiRemote
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Read the git remote URL configured in chezmoi's source directory.
|
|
553
|
+
* @returns {Promise<string|null>}
|
|
554
|
+
*/
|
|
555
|
+
export async function getChezmoiRemote() {
|
|
556
|
+
const result = await exec('chezmoi', ['git', '--', 'remote', 'get-url', 'origin'])
|
|
557
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) return null
|
|
558
|
+
return result.stdout.trim()
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
// T037: hasLocalChanges
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Check whether there are uncommitted changes in the chezmoi source directory.
|
|
567
|
+
* @returns {Promise<boolean>}
|
|
568
|
+
*/
|
|
569
|
+
export async function hasLocalChanges() {
|
|
570
|
+
const result = await exec('chezmoi', ['git', '--', 'status', '--porcelain'])
|
|
571
|
+
if (result.exitCode !== 0) return false
|
|
572
|
+
return result.stdout.trim().length > 0
|
|
573
|
+
}
|