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,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
+ }
@@ -0,0 +1,103 @@
1
+ import { Command, Flags } from '@oclif/core'
2
+ import ora from 'ora'
3
+ import chalk from 'chalk'
4
+ import { detectPlatform } from '../../services/platform.js'
5
+ import { isChezmoiInstalled, getChezmoiConfig, getManagedFiles, getChezmoiRemote } from '../../services/dotfiles.js'
6
+ import { loadConfig } from '../../services/config.js'
7
+ import { formatDotfilesStatus } from '../../formatters/dotfiles.js'
8
+ import { DvmiError } from '../../utils/errors.js'
9
+
10
+ /** @import { DotfilesStatusResult } from '../../types.js' */
11
+
12
+ export default class DotfilesStatus extends Command {
13
+ static description = 'Show chezmoi dotfiles status: managed files, encryption state, and sync health'
14
+
15
+ static examples = [
16
+ '<%= config.bin %> dotfiles status',
17
+ '<%= config.bin %> dotfiles status --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(DotfilesStatus)
28
+ const isJson = flags.json
29
+
30
+ const platformInfo = await detectPlatform()
31
+ const { platform } = platformInfo
32
+
33
+ const config = await loadConfig()
34
+ const enabled = config.dotfiles?.enabled === true
35
+
36
+ // Check chezmoi installation (even for not-configured state)
37
+ const chezmoiInstalled = await isChezmoiInstalled()
38
+
39
+ // Not configured state — valid, not an error
40
+ if (!enabled) {
41
+ /** @type {DotfilesStatusResult} */
42
+ const notConfiguredResult = {
43
+ platform,
44
+ enabled: false,
45
+ chezmoiInstalled,
46
+ encryptionConfigured: false,
47
+ repo: null,
48
+ sourceDir: null,
49
+ files: [],
50
+ summary: { total: 0, encrypted: 0, plaintext: 0 },
51
+ }
52
+
53
+ if (isJson) return notConfiguredResult
54
+ this.log(formatDotfilesStatus(notConfiguredResult))
55
+ return notConfiguredResult
56
+ }
57
+
58
+ if (!chezmoiInstalled) {
59
+ const hint = platform === 'macos'
60
+ ? 'Run `brew install chezmoi` or visit https://chezmoi.io/install'
61
+ : 'Run `sh -c "$(curl -fsLS get.chezmoi.io)"` or visit https://chezmoi.io/install'
62
+ throw new DvmiError('chezmoi is not installed', hint)
63
+ }
64
+
65
+ // Gather data
66
+ const spinner = isJson ? null : ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Gathering dotfiles status...') }).start()
67
+
68
+ const [chezmoiConfig, files, remote] = await Promise.all([
69
+ getChezmoiConfig(),
70
+ getManagedFiles(),
71
+ getChezmoiRemote(),
72
+ ])
73
+
74
+ spinner?.stop()
75
+
76
+ const encryptionConfigured = chezmoiConfig?.encryption?.tool === 'age' || !!chezmoiConfig?.age?.identity
77
+ const sourceDir = chezmoiConfig?.sourceDir ?? chezmoiConfig?.sourcePath ?? null
78
+ const repo = config.dotfiles?.repo ?? remote
79
+
80
+ const encryptedCount = files.filter((f) => f.encrypted).length
81
+ const plaintextCount = files.length - encryptedCount
82
+
83
+ /** @type {DotfilesStatusResult} */
84
+ const result = {
85
+ platform,
86
+ enabled: true,
87
+ chezmoiInstalled: true,
88
+ encryptionConfigured,
89
+ repo: repo ?? null,
90
+ sourceDir,
91
+ files,
92
+ summary: {
93
+ total: files.length,
94
+ encrypted: encryptedCount,
95
+ plaintext: plaintextCount,
96
+ },
97
+ }
98
+
99
+ if (isJson) return result
100
+ this.log(formatDotfilesStatus(result))
101
+ return result
102
+ }
103
+ }
@@ -0,0 +1,375 @@
1
+ import { Command, Flags, Args } from '@oclif/core'
2
+ import ora from 'ora'
3
+ import chalk from 'chalk'
4
+ import { confirm, input, select } from '@inquirer/prompts'
5
+ import { detectPlatform } from '../../services/platform.js'
6
+ import { isChezmoiInstalled, getChezmoiRemote, hasLocalChanges } from '../../services/dotfiles.js'
7
+ import { loadConfig, saveConfig } from '../../services/config.js'
8
+ import { exec, execOrThrow } from '../../services/shell.js'
9
+ import { formatDotfilesSync } from '../../formatters/dotfiles.js'
10
+ import { DvmiError } from '../../utils/errors.js'
11
+
12
+ /** @import { DotfilesSyncResult } from '../../types.js' */
13
+
14
+ export default class DotfilesSync extends Command {
15
+ static description = 'Sync dotfiles with remote repository: push local changes or pull from remote'
16
+
17
+ static examples = [
18
+ '<%= config.bin %> dotfiles sync',
19
+ '<%= config.bin %> dotfiles sync --push',
20
+ '<%= config.bin %> dotfiles sync --pull',
21
+ '<%= config.bin %> dotfiles sync --pull git@github.com:user/dotfiles.git',
22
+ '<%= config.bin %> dotfiles sync --dry-run --push',
23
+ '<%= config.bin %> dotfiles sync --json',
24
+ ]
25
+
26
+ static enableJsonFlag = true
27
+
28
+ static flags = {
29
+ help: Flags.help({ char: 'h' }),
30
+ push: Flags.boolean({ description: 'Push local changes to remote', default: false }),
31
+ pull: Flags.boolean({ description: 'Pull remote changes and apply', default: false }),
32
+ 'dry-run': Flags.boolean({ description: 'Show what would change without applying', default: false }),
33
+ }
34
+
35
+ static args = {
36
+ repo: Args.string({ description: 'Remote repository URL (for initial remote setup)', required: false }),
37
+ }
38
+
39
+ async run() {
40
+ const { flags, args } = await this.parse(DotfilesSync)
41
+ const isJson = flags.json
42
+ const isPush = flags.push
43
+ const isPull = flags.pull
44
+ const isDryRun = flags['dry-run']
45
+ const repoArg = args.repo
46
+
47
+ // Flag validation: --push and --pull are mutually exclusive
48
+ if (isPush && isPull) {
49
+ throw new DvmiError(
50
+ 'Cannot use --push and --pull together — they are mutually exclusive.',
51
+ 'Use --push to upload local changes or --pull to download remote changes.',
52
+ )
53
+ }
54
+
55
+ // Non-interactive guard
56
+ const isCI = process.env.CI === 'true'
57
+ const isNonInteractive = !process.stdout.isTTY
58
+ if ((isCI || isNonInteractive) && !isJson) {
59
+ this.error(
60
+ 'This command requires an interactive terminal (TTY). Run with --json for a non-interactive sync.',
61
+ { exit: 1 },
62
+ )
63
+ }
64
+
65
+ const config = await loadConfig()
66
+ if (!config.dotfiles?.enabled) {
67
+ throw new DvmiError(
68
+ 'Chezmoi dotfiles management is not configured',
69
+ 'Run `dvmi dotfiles setup` first',
70
+ )
71
+ }
72
+
73
+ const chezmoiInstalled = await isChezmoiInstalled()
74
+ if (!chezmoiInstalled) {
75
+ const platformInfo = await detectPlatform()
76
+ const hint = platformInfo.platform === 'macos'
77
+ ? 'Run `brew install chezmoi` or visit https://chezmoi.io/install'
78
+ : 'Run `sh -c "$(curl -fsLS get.chezmoi.io)"` or visit https://chezmoi.io/install'
79
+ throw new DvmiError('chezmoi is not installed', hint)
80
+ }
81
+
82
+ const remote = config.dotfiles?.repo ?? await getChezmoiRemote()
83
+
84
+ // --json mode: attempt push/pull or report status
85
+ if (isJson) {
86
+ if (!remote && !repoArg) {
87
+ /** @type {DotfilesSyncResult} */
88
+ return {
89
+ action: 'skipped',
90
+ repo: null,
91
+ status: 'skipped',
92
+ message: 'No remote repository configured. Run `dvmi dotfiles sync <repo-url>` to set up remote.',
93
+ conflicts: [],
94
+ }
95
+ }
96
+
97
+ const effectiveRemote = repoArg ?? remote
98
+
99
+ if (isPull) {
100
+ return await this._pull(effectiveRemote, isDryRun, isJson)
101
+ }
102
+
103
+ if (isPush || remote) {
104
+ return await this._push(effectiveRemote, isDryRun, isJson)
105
+ }
106
+
107
+ /** @type {DotfilesSyncResult} */
108
+ return { action: 'skipped', repo: effectiveRemote ?? null, status: 'skipped', message: 'No action specified', conflicts: [] }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Interactive mode
113
+ // ---------------------------------------------------------------------------
114
+
115
+ // No remote — initial setup flow
116
+ if (!remote && !repoArg) {
117
+ return await this._setupRemote(config, isDryRun)
118
+ }
119
+
120
+ const effectiveRemote = repoArg ?? remote
121
+ const localChanges = await hasLocalChanges()
122
+
123
+ if (isPush) {
124
+ const result = await this._push(effectiveRemote, isDryRun, false)
125
+ this.log(formatDotfilesSync(result))
126
+ return result
127
+ }
128
+
129
+ if (isPull) {
130
+ const result = await this._pull(effectiveRemote, isDryRun, false)
131
+ this.log(formatDotfilesSync(result))
132
+ return result
133
+ }
134
+
135
+ // Interactive menu
136
+ this.log(chalk.bold(`\n Remote: ${chalk.cyan(effectiveRemote)}`))
137
+ this.log(chalk.white(` Local changes: ${localChanges ? chalk.yellow('yes') : chalk.dim('none')}`))
138
+ this.log('')
139
+
140
+ const action = await select({
141
+ message: 'What would you like to do?',
142
+ choices: [
143
+ { name: 'Push local changes to remote', value: 'push' },
144
+ { name: 'Pull remote changes and apply', value: 'pull' },
145
+ { name: 'Cancel', value: 'cancel' },
146
+ ],
147
+ })
148
+
149
+ if (action === 'cancel') {
150
+ /** @type {DotfilesSyncResult} */
151
+ const cancelResult = { action: 'skipped', repo: effectiveRemote ?? null, status: 'skipped', message: 'Cancelled by user', conflicts: [] }
152
+ this.log(formatDotfilesSync(cancelResult))
153
+ return cancelResult
154
+ }
155
+
156
+ const result = action === 'push'
157
+ ? await this._push(effectiveRemote, isDryRun, false)
158
+ : await this._pull(effectiveRemote, isDryRun, false)
159
+
160
+ this.log(formatDotfilesSync(result))
161
+ return result
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // _setupRemote — initial remote connection
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * @param {import('../../types.js').CLIConfig} config
170
+ * @param {boolean} isDryRun
171
+ * @returns {Promise<DotfilesSyncResult>}
172
+ */
173
+ async _setupRemote(config, isDryRun) {
174
+ const choice = await select({
175
+ message: 'Connect to an existing dotfiles repository or create a new one?',
176
+ choices: [
177
+ { name: 'Connect to existing repository', value: 'existing' },
178
+ { name: 'Create new repository on GitHub', value: 'new' },
179
+ ],
180
+ })
181
+
182
+ let repoUrl = ''
183
+
184
+ if (choice === 'existing') {
185
+ repoUrl = await input({ message: 'Repository URL (SSH or HTTPS):' })
186
+ } else {
187
+ const repoName = await input({ message: 'Repository name:', default: 'dotfiles' })
188
+ const isPrivate = await confirm({ message: 'Make repository private?', default: true })
189
+
190
+ if (!isDryRun) {
191
+ try {
192
+ const visFlag = isPrivate ? '--private' : '--public'
193
+ await execOrThrow('gh', ['repo', 'create', repoName, visFlag, '--confirm'])
194
+ // Get the SSH URL from the created repo
195
+ const { exec } = await import('../../services/shell.js')
196
+ const result = await exec('gh', ['repo', 'view', repoName, '--json', 'sshUrl', '--jq', '.sshUrl'])
197
+ repoUrl = result.stdout.trim() || `git@github.com:${repoName}.git`
198
+ } catch {
199
+ throw new DvmiError(
200
+ 'Failed to create repository on GitHub',
201
+ 'Verify your GitHub authentication: `gh auth status`',
202
+ )
203
+ }
204
+ } else {
205
+ repoUrl = `git@github.com:<user>/${repoName}.git`
206
+ }
207
+ }
208
+
209
+ if (!isDryRun) {
210
+ try {
211
+ await execOrThrow('chezmoi', ['git', '--', 'remote', 'add', 'origin', repoUrl])
212
+ await execOrThrow('chezmoi', ['git', '--', 'push', '-u', 'origin', 'main'])
213
+ // Save repo to dvmi config
214
+ config.dotfiles = { ...(config.dotfiles ?? { enabled: true }), repo: repoUrl }
215
+ await saveConfig(config)
216
+ } catch (err) {
217
+ /** @type {DotfilesSyncResult} */
218
+ const failResult = {
219
+ action: 'init-remote',
220
+ repo: repoUrl,
221
+ status: 'failed',
222
+ message: err instanceof Error ? err.message : String(err),
223
+ conflicts: [],
224
+ }
225
+ this.log(formatDotfilesSync(failResult))
226
+ return failResult
227
+ }
228
+ }
229
+
230
+ /** @type {DotfilesSyncResult} */
231
+ const result = {
232
+ action: 'init-remote',
233
+ repo: repoUrl,
234
+ status: isDryRun ? 'skipped' : 'success',
235
+ message: isDryRun ? `Would configure remote: ${repoUrl}` : 'Remote repository configured and initial push completed',
236
+ conflicts: [],
237
+ }
238
+ this.log(formatDotfilesSync(result))
239
+ return result
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // _push
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /**
247
+ * @param {string|null|undefined} remote
248
+ * @param {boolean} isDryRun
249
+ * @param {boolean} isJson
250
+ * @returns {Promise<DotfilesSyncResult>}
251
+ */
252
+ async _push(remote, isDryRun, isJson) {
253
+ if (!remote) {
254
+ return {
255
+ action: 'push',
256
+ repo: null,
257
+ status: 'failed',
258
+ message: 'No remote repository configured. Run `dvmi dotfiles sync <repo-url>` to set up remote.',
259
+ conflicts: [],
260
+ }
261
+ }
262
+
263
+ if (isDryRun) {
264
+ const diffResult = await exec('chezmoi', ['git', '--', 'diff', '--cached'])
265
+ return {
266
+ action: 'push',
267
+ repo: remote,
268
+ status: 'skipped',
269
+ message: diffResult.stdout.trim() || 'No staged changes to push',
270
+ conflicts: [],
271
+ }
272
+ }
273
+
274
+ const spinner = isJson ? null : ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Pushing to remote...') }).start()
275
+
276
+ try {
277
+ // Stage all changes
278
+ await execOrThrow('chezmoi', ['git', '--', 'add', '-A'])
279
+ // Commit
280
+ await exec('chezmoi', ['git', '--', 'commit', '-m', 'chore: update dotfiles'])
281
+ // Push
282
+ await execOrThrow('chezmoi', ['git', '--', 'push', 'origin', 'HEAD'])
283
+ spinner?.succeed(chalk.green('Pushed to remote'))
284
+
285
+ return { action: 'push', repo: remote, status: 'success', message: 'Changes pushed to remote', conflicts: [] }
286
+ } catch (err) {
287
+ spinner?.fail(chalk.red('Push failed'))
288
+ return {
289
+ action: 'push',
290
+ repo: remote,
291
+ status: 'failed',
292
+ message: err instanceof Error ? err.message : String(err),
293
+ conflicts: [],
294
+ }
295
+ }
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // _pull (US4 + US5)
300
+ // ---------------------------------------------------------------------------
301
+
302
+ /**
303
+ * @param {string|null|undefined} remote
304
+ * @param {boolean} isDryRun
305
+ * @param {boolean} isJson
306
+ * @returns {Promise<DotfilesSyncResult>}
307
+ */
308
+ async _pull(remote, isDryRun, isJson) {
309
+ if (!remote) {
310
+ return {
311
+ action: 'pull',
312
+ repo: null,
313
+ status: 'failed',
314
+ message: 'No remote repository configured. Run `dvmi dotfiles sync <repo-url>` to set up remote.',
315
+ conflicts: [],
316
+ }
317
+ }
318
+
319
+ if (isDryRun) {
320
+ const dryResult = await exec('chezmoi', ['apply', '--dry-run', '--verbose'])
321
+ return {
322
+ action: 'pull',
323
+ repo: remote,
324
+ status: 'skipped',
325
+ message: dryResult.stdout.trim() || 'Would apply remote changes',
326
+ conflicts: [],
327
+ }
328
+ }
329
+
330
+ const spinner = isJson ? null : ora({ spinner: 'arc', color: false, text: chalk.hex('#FF6B2B')('Pulling from remote...') }).start()
331
+
332
+ try {
333
+ // Check if chezmoi init was done with this remote (first-time pull)
334
+ const currentRemote = await getChezmoiRemote()
335
+ if (!currentRemote) {
336
+ // First time: chezmoi init <repo>
337
+ await execOrThrow('chezmoi', ['init', remote])
338
+ } else {
339
+ // Subsequent: git pull + apply
340
+ await execOrThrow('chezmoi', ['git', '--', 'pull', '--rebase', 'origin', 'HEAD'])
341
+ }
342
+
343
+ // Apply changes
344
+ const applyResult = await exec('chezmoi', ['apply', '--verbose'])
345
+ spinner?.succeed(chalk.green('Applied remote changes'))
346
+
347
+ // Check for conflicts in apply output
348
+ const conflictLines = (applyResult.stdout + applyResult.stderr)
349
+ .split('\n')
350
+ .filter((l) => l.toLowerCase().includes('conflict'))
351
+ .map((l) => l.trim())
352
+
353
+ if (conflictLines.length > 0) {
354
+ return {
355
+ action: 'pull',
356
+ repo: remote,
357
+ status: 'failed',
358
+ message: `Merge conflicts detected in ${conflictLines.length} file(s)`,
359
+ conflicts: conflictLines,
360
+ }
361
+ }
362
+
363
+ return { action: 'pull', repo: remote, status: 'success', message: 'Remote changes applied', conflicts: [] }
364
+ } catch (err) {
365
+ spinner?.fail(chalk.red('Pull failed'))
366
+ return {
367
+ action: 'pull',
368
+ repo: remote,
369
+ status: 'failed',
370
+ message: err instanceof Error ? err.message : String(err),
371
+ conflicts: [],
372
+ }
373
+ }
374
+ }
375
+ }