devvami 1.3.0 → 1.4.1

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,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
+ }