devvami 1.1.2 → 1.2.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,634 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+ import { readFile, appendFile } from 'node:fs/promises'
4
+ import { existsSync } from 'node:fs'
5
+ import { which, exec, execOrThrow } from './shell.js'
6
+
7
+ /** @import { Platform, PlatformInfo, SecurityTool, SecurityToolStatus, SetupStep, StepResult, GpgKey } from '../types.js' */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Tool definitions
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** @type {SecurityTool[]} */
14
+ const TOOL_DEFINITIONS = [
15
+ {
16
+ id: 'aws-vault',
17
+ displayName: 'aws-vault',
18
+ role: 'aws',
19
+ platforms: ['macos', 'linux', 'wsl2'],
20
+ status: 'not-installed',
21
+ version: null,
22
+ hint: null,
23
+ },
24
+ {
25
+ id: 'gpg',
26
+ displayName: 'GPG',
27
+ role: 'dependency',
28
+ platforms: ['linux', 'wsl2'],
29
+ status: 'not-installed',
30
+ version: null,
31
+ hint: null,
32
+ },
33
+ {
34
+ id: 'pass',
35
+ displayName: 'GNU pass',
36
+ role: 'dependency',
37
+ platforms: ['linux', 'wsl2'],
38
+ status: 'not-installed',
39
+ version: null,
40
+ hint: null,
41
+ },
42
+ {
43
+ id: 'osxkeychain',
44
+ displayName: 'macOS Keychain',
45
+ role: 'git',
46
+ platforms: ['macos'],
47
+ status: 'not-installed',
48
+ version: null,
49
+ hint: null,
50
+ },
51
+ {
52
+ id: 'gcm',
53
+ displayName: 'Git Credential Manager',
54
+ role: 'git',
55
+ platforms: ['linux', 'wsl2'],
56
+ status: 'not-installed',
57
+ version: null,
58
+ hint: null,
59
+ },
60
+ ]
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // checkToolStatus
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Check the current status of all security tools for the given platform.
68
+ * @param {Platform} platform
69
+ * @returns {Promise<SecurityToolStatus[]>}
70
+ */
71
+ export async function checkToolStatus(platform) {
72
+ /** @type {SecurityToolStatus[]} */
73
+ const results = []
74
+
75
+ for (const tool of TOOL_DEFINITIONS) {
76
+ if (!tool.platforms.includes(platform)) {
77
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'n/a', version: null, hint: null })
78
+ continue
79
+ }
80
+
81
+ if (tool.id === 'aws-vault') {
82
+ const path = await which('aws-vault')
83
+ if (!path) {
84
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'not-installed', version: null, hint: 'Install aws-vault' })
85
+ continue
86
+ }
87
+ const versionResult = await exec('aws-vault', ['--version'])
88
+ const version = (versionResult.stdout || versionResult.stderr).replace(/^v/, '').trim()
89
+ // On Linux/WSL2 check that AWS_VAULT_BACKEND=pass is configured
90
+ if (platform !== 'macos') {
91
+ const backend = process.env.AWS_VAULT_BACKEND
92
+ if (backend !== 'pass') {
93
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'misconfigured', version: version || null, hint: 'Add export AWS_VAULT_BACKEND=pass to your shell profile' })
94
+ continue
95
+ }
96
+ }
97
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'installed', version: version || null, hint: null })
98
+ continue
99
+ }
100
+
101
+ if (tool.id === 'gpg') {
102
+ const path = await which('gpg')
103
+ if (!path) {
104
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'not-installed', version: null, hint: 'Install gnupg via your package manager' })
105
+ continue
106
+ }
107
+ const versionResult = await exec('gpg', ['--version'])
108
+ const match = versionResult.stdout.match(/gpg \(GnuPG\)\s+([\d.]+)/)
109
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'installed', version: match ? match[1] : null, hint: null })
110
+ continue
111
+ }
112
+
113
+ if (tool.id === 'pass') {
114
+ const path = await which('pass')
115
+ if (!path) {
116
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'not-installed', version: null, hint: 'Install pass via your package manager' })
117
+ continue
118
+ }
119
+ const versionResult = await exec('pass', ['--version'])
120
+ const match = versionResult.stdout.match(/([\d.]+)/)
121
+ // Check if pass is initialized
122
+ const lsResult = await exec('pass', ['ls'])
123
+ if (lsResult.exitCode !== 0) {
124
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'misconfigured', version: match ? match[1] : null, hint: 'Initialize pass with: pass init <gpg-key-id>' })
125
+ continue
126
+ }
127
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'installed', version: match ? match[1] : null, hint: null })
128
+ continue
129
+ }
130
+
131
+ if (tool.id === 'osxkeychain') {
132
+ const result = await exec('git', ['config', '--global', 'credential.helper'])
133
+ if (result.stdout === 'osxkeychain') {
134
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'installed', version: null, hint: null })
135
+ } else {
136
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'not-installed', version: null, hint: 'Run: git config --global credential.helper osxkeychain' })
137
+ }
138
+ continue
139
+ }
140
+
141
+ if (tool.id === 'gcm') {
142
+ const path = await which('git-credential-manager')
143
+ if (!path) {
144
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'not-installed', version: null, hint: 'Install Git Credential Manager' })
145
+ continue
146
+ }
147
+ const versionResult = await exec('git-credential-manager', ['--version'])
148
+ const version = versionResult.stdout.trim() || null
149
+ const storeResult = await exec('git', ['config', '--global', 'credential.credentialStore'])
150
+ if (storeResult.stdout !== 'gpg') {
151
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'misconfigured', version, hint: 'Run: git config --global credential.credentialStore gpg' })
152
+ continue
153
+ }
154
+ results.push({ id: tool.id, displayName: tool.displayName, status: 'installed', version, hint: null })
155
+ continue
156
+ }
157
+ }
158
+
159
+ return results
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // appendToShellProfile
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Append a line to the developer's shell profile if not already present (idempotent).
168
+ * @param {string} line - The line to append (e.g., "export AWS_VAULT_BACKEND=pass")
169
+ * @returns {Promise<void>}
170
+ */
171
+ export async function appendToShellProfile(line) {
172
+ const shell = process.env.SHELL ?? ''
173
+ let profilePath
174
+ if (shell.includes('zsh')) {
175
+ profilePath = join(homedir(), '.zshrc')
176
+ } else {
177
+ profilePath = join(homedir(), '.bashrc')
178
+ }
179
+
180
+ if (existsSync(profilePath)) {
181
+ const contents = await readFile(profilePath, 'utf8')
182
+ if (contents.includes(line)) return
183
+ }
184
+
185
+ await appendFile(profilePath, `\n${line}\n`)
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // listGpgKeys
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * List GPG secret keys available on the system.
194
+ * @returns {Promise<GpgKey[]>}
195
+ */
196
+ export async function listGpgKeys() {
197
+ const result = await exec('gpg', ['--list-secret-keys', '--with-colons'])
198
+ if (result.exitCode !== 0 || !result.stdout.trim()) return []
199
+
200
+ const lines = result.stdout.split('\n')
201
+ /** @type {GpgKey[]} */
202
+ const keys = []
203
+ let current = /** @type {Partial<GpgKey>|null} */ (null)
204
+
205
+ for (const line of lines) {
206
+ const parts = line.split(':')
207
+ const type = parts[0]
208
+
209
+ if (type === 'sec') {
210
+ // Start a new key: fingerprint comes from subsequent 'fpr' line
211
+ current = {
212
+ id: parts[4] ? parts[4].slice(-16) : '',
213
+ fingerprint: '',
214
+ name: '',
215
+ email: '',
216
+ expiry: parts[6] ? new Date(Number(parts[6]) * 1000).toISOString() : null,
217
+ }
218
+ } else if (type === 'fpr' && current && !current.fingerprint) {
219
+ current.fingerprint = parts[9] ?? ''
220
+ } else if (type === 'uid' && current) {
221
+ const uid = parts[9] ?? ''
222
+ const nameMatch = uid.match(/^([^<]+?)\s*</)
223
+ const emailMatch = uid.match(/<([^>]+)>/)
224
+ if (!current.name) current.name = nameMatch ? nameMatch[1].trim() : uid
225
+ if (!current.email) current.email = emailMatch ? emailMatch[1] : ''
226
+
227
+ // Key is complete enough — push it
228
+ if (current.id) {
229
+ keys.push(/** @type {GpgKey} */ (current))
230
+ current = null
231
+ }
232
+ }
233
+ }
234
+
235
+ return keys
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // deriveOverallStatus
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /**
243
+ * Derive an overall status string from a list of tool statuses.
244
+ * @param {SecurityToolStatus[]} tools
245
+ * @returns {'success'|'partial'|'not-configured'}
246
+ */
247
+ export function deriveOverallStatus(tools) {
248
+ const applicable = tools.filter((t) => t.status !== 'n/a')
249
+ if (applicable.length === 0) return 'not-configured'
250
+ const allInstalled = applicable.every((t) => t.status === 'installed')
251
+ if (allInstalled) return 'success'
252
+ const someInstalled = applicable.some((t) => t.status === 'installed')
253
+ if (someInstalled) return 'partial'
254
+ return 'not-configured'
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // buildSteps
259
+ // ---------------------------------------------------------------------------
260
+
261
+ /**
262
+ * Build an ordered list of setup steps for the given platform and selection.
263
+ * This is a pure function — all side-effecting logic is in the `run` closures.
264
+ * @param {PlatformInfo} platformInfo
265
+ * @param {'aws'|'git'|'both'} selection
266
+ * @param {{ gpgId?: string }} [context] - Optional context (e.g. chosen GPG key ID)
267
+ * @returns {SetupStep[]}
268
+ */
269
+ export function buildSteps(platformInfo, selection, context = {}) {
270
+ const { platform } = platformInfo
271
+ const includeAws = selection === 'aws' || selection === 'both'
272
+ const includeGit = selection === 'git' || selection === 'both'
273
+
274
+ /** @type {SetupStep[]} */
275
+ const steps = []
276
+
277
+ if (platform === 'macos') {
278
+ if (includeAws) {
279
+ steps.push({
280
+ id: 'check-brew',
281
+ label: 'Check Homebrew installation',
282
+ toolId: 'aws-vault',
283
+ type: 'check',
284
+ requiresConfirmation: false,
285
+ run: async () => {
286
+ const path = await which('brew')
287
+ if (!path) {
288
+ return {
289
+ status: 'failed',
290
+ hint: 'Homebrew is required. Install it from https://brew.sh',
291
+ hintUrl: 'https://brew.sh',
292
+ }
293
+ }
294
+ return { status: 'success', message: 'Homebrew is available' }
295
+ },
296
+ })
297
+
298
+ steps.push({
299
+ id: 'install-aws-vault',
300
+ label: 'Install aws-vault via Homebrew',
301
+ toolId: 'aws-vault',
302
+ type: 'install',
303
+ requiresConfirmation: true,
304
+ run: async () => {
305
+ const existing = await which('aws-vault')
306
+ if (existing) return { status: 'skipped', message: 'aws-vault already installed' }
307
+ try {
308
+ await execOrThrow('brew', ['install', 'aws-vault'])
309
+ return { status: 'success', message: 'aws-vault installed via Homebrew' }
310
+ } catch {
311
+ return {
312
+ status: 'failed',
313
+ hint: 'Run manually: brew install aws-vault',
314
+ hintUrl: 'https://github.com/99designs/aws-vault',
315
+ }
316
+ }
317
+ },
318
+ })
319
+
320
+ steps.push({
321
+ id: 'verify-aws-vault',
322
+ label: 'Verify aws-vault installation',
323
+ toolId: 'aws-vault',
324
+ type: 'verify',
325
+ requiresConfirmation: false,
326
+ run: async () => {
327
+ const result = await exec('aws-vault', ['--version'])
328
+ if (result.exitCode !== 0) {
329
+ return { status: 'failed', hint: 'aws-vault not found in PATH after install' }
330
+ }
331
+ const version = (result.stdout || result.stderr).trim()
332
+ return { status: 'success', message: `aws-vault ${version}` }
333
+ },
334
+ })
335
+ }
336
+
337
+ if (includeGit) {
338
+ steps.push({
339
+ id: 'configure-osxkeychain',
340
+ label: 'Configure macOS Keychain as Git credential helper',
341
+ toolId: 'osxkeychain',
342
+ type: 'configure',
343
+ requiresConfirmation: true,
344
+ run: async () => {
345
+ try {
346
+ await execOrThrow('git', ['config', '--global', 'credential.helper', 'osxkeychain'])
347
+ return { status: 'success', message: 'Git credential helper set to osxkeychain' }
348
+ } catch {
349
+ return { status: 'failed', hint: 'Run manually: git config --global credential.helper osxkeychain' }
350
+ }
351
+ },
352
+ })
353
+
354
+ steps.push({
355
+ id: 'verify-osxkeychain',
356
+ label: 'Verify macOS Keychain credential helper',
357
+ toolId: 'osxkeychain',
358
+ type: 'verify',
359
+ requiresConfirmation: false,
360
+ run: async () => {
361
+ const result = await exec('git', ['config', '--global', 'credential.helper'])
362
+ if (result.stdout !== 'osxkeychain') {
363
+ return { status: 'failed', hint: 'credential.helper is not set to osxkeychain' }
364
+ }
365
+ return { status: 'success', message: 'osxkeychain is configured' }
366
+ },
367
+ })
368
+ }
369
+ } else {
370
+ // Linux / WSL2
371
+ if (includeAws) {
372
+ steps.push({
373
+ id: 'check-gpg',
374
+ label: 'Check GPG installation',
375
+ toolId: 'gpg',
376
+ type: 'check',
377
+ requiresConfirmation: false,
378
+ run: async () => {
379
+ const path = await which('gpg')
380
+ if (!path) {
381
+ return { status: 'failed', hint: 'GPG not found — will be installed in the next step' }
382
+ }
383
+ const result = await exec('gpg', ['--version'])
384
+ const match = result.stdout.match(/gpg \(GnuPG\)\s+([\d.]+)/)
385
+ return { status: 'success', message: `GPG ${match ? match[1] : 'found'}` }
386
+ },
387
+ })
388
+
389
+ steps.push({
390
+ id: 'install-gpg',
391
+ label: 'Install GPG (gnupg)',
392
+ toolId: 'gpg',
393
+ type: 'install',
394
+ requiresConfirmation: true,
395
+ skippable: true,
396
+ run: async () => {
397
+ const path = await which('gpg')
398
+ if (path) return { status: 'skipped', message: 'GPG already installed' }
399
+ try {
400
+ await execOrThrow('sudo', ['apt-get', 'install', '-y', 'gnupg'])
401
+ return { status: 'success', message: 'GPG installed' }
402
+ } catch {
403
+ return { status: 'failed', hint: 'Run manually: sudo apt-get install -y gnupg' }
404
+ }
405
+ },
406
+ })
407
+
408
+ steps.push({
409
+ id: 'create-gpg-key',
410
+ label: 'Create or select a GPG key for pass and GCM',
411
+ toolId: 'gpg',
412
+ type: 'configure',
413
+ requiresConfirmation: true,
414
+ gpgInteractive: true,
415
+ run: async () => {
416
+ const gpgId = context.gpgId
417
+ if (gpgId) return { status: 'skipped', message: `Using existing GPG key ${gpgId}` }
418
+ // When gpgInteractive=true, the command layer stops the spinner and spawns
419
+ // gpg --full-generate-key with stdio:inherit so the user sets a strong passphrase.
420
+ // We never generate a key with an empty passphrase — that would leave the private
421
+ // key unprotected at rest and defeat the purpose of this setup wizard.
422
+ // If we reach this non-interactive fallback, ask the user to do it manually.
423
+ return {
424
+ status: 'failed',
425
+ hint: 'Create a GPG key manually with a strong passphrase: gpg --full-generate-key',
426
+ hintUrl: 'https://www.gnupg.org/gph/en/manual/c14.html',
427
+ }
428
+ },
429
+ })
430
+
431
+ steps.push({
432
+ id: 'install-pass',
433
+ label: 'Install GNU pass',
434
+ toolId: 'pass',
435
+ type: 'install',
436
+ requiresConfirmation: true,
437
+ run: async () => {
438
+ const path = await which('pass')
439
+ if (path) return { status: 'skipped', message: 'pass already installed' }
440
+ try {
441
+ await execOrThrow('sudo', ['apt-get', 'install', '-y', 'pass'])
442
+ return { status: 'success', message: 'pass installed' }
443
+ } catch {
444
+ return { status: 'failed', hint: 'Run manually: sudo apt-get install -y pass' }
445
+ }
446
+ },
447
+ })
448
+
449
+ steps.push({
450
+ id: 'init-pass',
451
+ label: 'Initialize pass with your GPG key',
452
+ toolId: 'pass',
453
+ type: 'configure',
454
+ requiresConfirmation: true,
455
+ run: async () => {
456
+ // Skip if pass is already initialized
457
+ const lsResult = await exec('pass', ['ls'])
458
+ if (lsResult.exitCode === 0) {
459
+ return { status: 'skipped', message: 'pass store already initialized' }
460
+ }
461
+ const gpgId = context.gpgId
462
+ if (!gpgId) {
463
+ return { status: 'failed', hint: 'No GPG key ID available — complete the create-gpg-key step first' }
464
+ }
465
+ try {
466
+ await execOrThrow('pass', ['init', gpgId])
467
+ return { status: 'success', message: `pass initialized with key ${gpgId}` }
468
+ } catch {
469
+ return { status: 'failed', hint: `Run manually: pass init ${gpgId}` }
470
+ }
471
+ },
472
+ })
473
+
474
+ steps.push({
475
+ id: 'install-aws-vault',
476
+ label: 'Install aws-vault binary',
477
+ toolId: 'aws-vault',
478
+ type: 'install',
479
+ requiresConfirmation: true,
480
+ run: async () => {
481
+ const existing = await which('aws-vault')
482
+ if (existing) return { status: 'skipped', message: 'aws-vault already installed' }
483
+ const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'
484
+ const url = `https://github.com/99designs/aws-vault/releases/latest/download/aws-vault-linux-${arch}`
485
+ try {
486
+ await execOrThrow('sudo', ['sh', '-c', `curl -sSL '${url}' -o /usr/local/bin/aws-vault && chmod +x /usr/local/bin/aws-vault`])
487
+ return { status: 'success', message: 'aws-vault installed to /usr/local/bin/aws-vault' }
488
+ } catch {
489
+ return {
490
+ status: 'failed',
491
+ hint: `Download manually: curl -sSL '${url}' -o /usr/local/bin/aws-vault && chmod +x /usr/local/bin/aws-vault`,
492
+ hintUrl: 'https://github.com/99designs/aws-vault/releases',
493
+ }
494
+ }
495
+ },
496
+ })
497
+
498
+ steps.push({
499
+ id: 'configure-aws-vault-backend',
500
+ label: 'Configure AWS_VAULT_BACKEND=pass in shell profile',
501
+ toolId: 'aws-vault',
502
+ type: 'configure',
503
+ requiresConfirmation: true,
504
+ run: async () => {
505
+ try {
506
+ await appendToShellProfile('export AWS_VAULT_BACKEND=pass')
507
+ await appendToShellProfile('export GPG_TTY=$(tty)')
508
+ return { status: 'success', message: 'AWS_VAULT_BACKEND=pass and GPG_TTY added to shell profile' }
509
+ } catch {
510
+ return { status: 'failed', hint: 'Add manually to ~/.bashrc or ~/.zshrc: export AWS_VAULT_BACKEND=pass' }
511
+ }
512
+ },
513
+ })
514
+
515
+ steps.push({
516
+ id: 'verify-aws-vault',
517
+ label: 'Verify aws-vault installation',
518
+ toolId: 'aws-vault',
519
+ type: 'verify',
520
+ requiresConfirmation: false,
521
+ run: async () => {
522
+ const result = await exec('aws-vault', ['--version'])
523
+ if (result.exitCode !== 0) {
524
+ return { status: 'failed', hint: 'aws-vault not found in PATH after install' }
525
+ }
526
+ const version = (result.stdout || result.stderr).trim()
527
+ return { status: 'success', message: `aws-vault ${version}` }
528
+ },
529
+ })
530
+ }
531
+
532
+ if (includeGit) {
533
+ // On WSL2, first check for Windows-side GCM bridge
534
+ if (platform === 'wsl2') {
535
+ steps.push({
536
+ id: 'check-gcm-bridge',
537
+ label: 'Check for Windows Git Credential Manager bridge',
538
+ toolId: 'gcm',
539
+ type: 'check',
540
+ requiresConfirmation: false,
541
+ run: async () => {
542
+ const bridgePath = '/mnt/c/Program Files/Git/mingw64/bin/git-credential-manager.exe'
543
+ if (existsSync(bridgePath)) {
544
+ return { status: 'success', message: 'Windows GCM bridge found — using Windows Credential Manager' }
545
+ }
546
+ return { status: 'skipped', message: 'Windows GCM not found — will install native Linux GCM' }
547
+ },
548
+ })
549
+ }
550
+
551
+ steps.push({
552
+ id: 'install-gcm',
553
+ label: 'Install Git Credential Manager',
554
+ toolId: 'gcm',
555
+ type: 'install',
556
+ requiresConfirmation: true,
557
+ run: async () => {
558
+ const existing = await which('git-credential-manager')
559
+ if (existing) return { status: 'skipped', message: 'Git Credential Manager already installed' }
560
+ try {
561
+ const latestResult = await exec('sh', ['-c', "curl -sSL https://api.github.com/repos/git-ecosystem/git-credential-manager/releases/latest | grep 'browser_download_url.*gcm.*linux.*amd64.*deb' | head -1 | cut -d '\"' -f 4"])
562
+ const debUrl = latestResult.stdout.trim()
563
+ if (!debUrl) throw new Error('Could not find GCM deb package URL')
564
+ // Security: validate debUrl is a legitimate GitHub release asset URL before using it
565
+ // This prevents command injection if the GitHub API response were tampered with (MITM / supply-chain)
566
+ const SAFE_DEB_URL = /^https:\/\/github\.com\/git-ecosystem\/git-credential-manager\/releases\/download\/[a-zA-Z0-9._\-/]+\.deb$/
567
+ if (!SAFE_DEB_URL.test(debUrl)) {
568
+ throw new Error(`Unexpected GCM package URL format: "${debUrl}"`)
569
+ }
570
+ // Use array args — no shell interpolation of the URL
571
+ await execOrThrow('curl', ['-sSL', debUrl, '-o', '/tmp/gcm.deb'])
572
+ await execOrThrow('sudo', ['dpkg', '-i', '/tmp/gcm.deb'])
573
+ return { status: 'success', message: 'Git Credential Manager installed' }
574
+ } catch {
575
+ return {
576
+ status: 'failed',
577
+ hint: 'Install manually from https://github.com/git-ecosystem/git-credential-manager/releases',
578
+ hintUrl: 'https://github.com/git-ecosystem/git-credential-manager/releases',
579
+ }
580
+ }
581
+ },
582
+ })
583
+
584
+ steps.push({
585
+ id: 'configure-gcm',
586
+ label: 'Configure Git Credential Manager',
587
+ toolId: 'gcm',
588
+ type: 'configure',
589
+ requiresConfirmation: true,
590
+ run: async () => {
591
+ try {
592
+ await execOrThrow('git-credential-manager', ['configure'])
593
+ return { status: 'success', message: 'Git Credential Manager configured' }
594
+ } catch {
595
+ return { status: 'failed', hint: 'Run manually: git-credential-manager configure' }
596
+ }
597
+ },
598
+ })
599
+
600
+ steps.push({
601
+ id: 'configure-gcm-store',
602
+ label: 'Set GCM credential store to gpg',
603
+ toolId: 'gcm',
604
+ type: 'configure',
605
+ requiresConfirmation: true,
606
+ run: async () => {
607
+ try {
608
+ await execOrThrow('git', ['config', '--global', 'credential.credentialStore', 'gpg'])
609
+ return { status: 'success', message: 'GCM credential store set to gpg' }
610
+ } catch {
611
+ return { status: 'failed', hint: 'Run manually: git config --global credential.credentialStore gpg' }
612
+ }
613
+ },
614
+ })
615
+
616
+ steps.push({
617
+ id: 'verify-gcm',
618
+ label: 'Verify Git Credential Manager',
619
+ toolId: 'gcm',
620
+ type: 'verify',
621
+ requiresConfirmation: false,
622
+ run: async () => {
623
+ const result = await exec('git-credential-manager', ['--version'])
624
+ if (result.exitCode !== 0) {
625
+ return { status: 'failed', hint: 'git-credential-manager not found in PATH' }
626
+ }
627
+ return { status: 'success', message: `GCM ${result.stdout.trim()}` }
628
+ },
629
+ })
630
+ }
631
+ }
632
+
633
+ return steps
634
+ }