• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

stacklok / toolhive-studio / 24520210177

16 Apr 2026 03:54PM UTC coverage: 63.361% (+1.5%) from 61.888%
24520210177

Pull #1938

github

web-flow
Merge a115b9048 into 88c1c01a5
Pull Request #1938: feat(ci): add TDD bug-fix agent and triage cron

3542 of 5901 branches covered (60.02%)

5665 of 8630 relevant lines covered (65.64%)

121.32 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.27
/main/src/cli/validation.ts
1
/**
2
 * CLI Alignment Validation
3
 * Every-launch validation logic for CLI alignment (THV-0020)
4
 */
5

6
import { app } from 'electron'
7
import * as Sentry from '@sentry/electron/main'
8
import { detectExternalCli, getCliInfo } from './cli-detection'
9
import { readMarkerFile, createMarkerForDesktopInstall } from './marker-file'
10
import {
11
  checkSymlink,
12
  createSymlink,
13
  getBundledCliPath,
14
  getMarkerTargetPath,
15
  isFlatpak,
16
  repairSymlink,
17
} from './symlink-manager'
18
import {
19
  configureShellPath,
20
  checkPathConfiguration,
21
  cleanupLegacyBashProfile,
22
} from './path-configurator'
23
import { getDesktopCliPath } from './constants'
24
import type { ValidationResult } from '@common/types/cli'
25
import type { CliAlignmentStatus, Platform } from './types'
26
import log from '../logger'
27

28
export async function validateCliAlignment(
29
  platform: Platform = process.platform as Platform
6✔
30
): Promise<ValidationResult> {
31
  return Sentry.startSpanManual(
6✔
32
    {
33
      name: 'CLI alignment validation',
34
      op: 'cli.validation',
35
      attributes: {
36
        'analytics.source': 'tracking',
37
        'analytics.type': 'event',
38
        'cli.platform': platform,
39
      },
40
    },
41
    async (span) => {
42
      log.info('Starting CLI alignment validation...')
6✔
43

44
      const external = await detectExternalCli(platform)
6✔
45
      if (external) {
6✔
46
        log.warn(`External CLI found at: ${external.path}`)
1✔
47
        span.setAttributes({
1✔
48
          'cli.status': 'external-cli-found',
49
          'cli.external_path': external.path,
50
          'cli.external_source': external.source,
51
        })
52
        span.end()
1✔
53
        return { status: 'external-cli-found', cli: external }
1✔
54
      }
55

56
      const marker = readMarkerFile()
5✔
57

58
      if (!marker) {
5✔
59
        log.info('No marker file found, treating as fresh install')
1✔
60
        span.setAttributes({ 'cli.status': 'fresh-install' })
1✔
61
        span.end()
1✔
62
        return { status: 'fresh-install' }
1✔
63
      }
64

65
      const symlink = checkSymlink(platform)
4✔
66

67
      if (!symlink.exists) {
4✔
68
        log.warn('CLI alignment issue: symlink-missing')
1✔
69
        span.setAttributes({ 'cli.status': 'symlink-missing' })
1✔
70
        span.end()
1✔
71
        return { status: 'symlink-missing' }
1✔
72
      }
73

74
      if (!symlink.targetExists) {
3✔
75
        log.warn('CLI alignment issue: symlink-broken')
1✔
76
        span.setAttributes({
1✔
77
          'cli.status': 'symlink-broken',
78
          'cli.symlink_target': symlink.target ?? 'unknown',
1!
79
        })
80
        span.end()
1✔
81
        return { status: 'symlink-broken', target: symlink.target ?? 'unknown' }
1!
82
      }
83

84
      if (!symlink.isOurBinary) {
2✔
85
        log.warn('CLI alignment issue: symlink-tampered')
1✔
86
        span.setAttributes({
1✔
87
          'cli.status': 'symlink-tampered',
88
          'cli.symlink_target': symlink.target ?? 'unknown',
1!
89
        })
90
        span.end()
1✔
91
        return {
1✔
92
          status: 'symlink-tampered',
93
          target: symlink.target ?? 'unknown',
1!
94
        }
95
      }
96

97
      // Always clean up legacy .bash_profile block regardless of PATH status
98
      cleanupLegacyBashProfile()
1✔
99

100
      // Check and configure PATH if needed
101
      const pathStatus = await checkPathConfiguration()
1✔
102
      if (!pathStatus.isConfigured) {
1!
103
        log.info('PATH not configured, configuring now...')
×
104
        const pathResult = await configureShellPath()
×
105
        span.setAttribute('cli.path_configured', pathResult.success)
×
106
        if (!pathResult.success) {
×
107
          log.warn('Failed to configure PATH, user may need to add manually')
×
108
        }
109
      } else {
110
        span.setAttribute('cli.path_configured', true)
1✔
111
      }
112

113
      log.info('CLI alignment validation passed')
1✔
114
      span.setAttributes({ 'cli.status': 'valid' })
1✔
115
      span.end()
1✔
116
      return { status: 'valid' }
1✔
117
    }
118
  )
119
}
120

121
/**
122
 * Handles validation results that can be auto-fixed without user interaction.
123
 * Returns the updated validation result after attempting auto-fixes.
124
 *
125
 * Cases handled automatically:
126
 * - valid: Updates marker file if needed
127
 * - fresh-install: Creates symlink and marker
128
 * - symlink-missing: Creates symlink and marker
129
 *
130
 * Cases requiring user interaction (returned as-is for renderer to handle):
131
 * - external-cli-found: User must uninstall external CLI
132
 * - symlink-broken: User must confirm repair
133
 * - symlink-tampered: User must confirm restore
134
 */
135
export async function handleValidationResult(
136
  result: ValidationResult,
137
  platform: Platform = process.platform as Platform
10✔
138
): Promise<ValidationResult> {
139
  return Sentry.startSpanManual(
10✔
140
    {
141
      name: 'CLI handle validation result',
142
      op: 'cli.handle_result',
143
      attributes: {
144
        'analytics.source': 'tracking',
145
        'analytics.type': 'event',
146
        'cli.input_status': result.status,
147
        'cli.platform': platform,
148
      },
149
    },
150
    async (span) => {
151
      switch (result.status) {
10✔
152
        case 'valid': {
153
          log.info('CLI alignment is valid')
4✔
154

155
          // Update marker file if desktop version changed (app was updated) or cli_version is unknown
156
          const marker = readMarkerFile()
4✔
157
          const currentDesktopVersion = app.getVersion()
4✔
158
          const needsUpdate =
159
            marker &&
4✔
160
            (marker.desktop_version !== currentDesktopVersion ||
161
              marker.cli_version === 'unknown')
162

163
          if (needsUpdate) {
4✔
164
            log.info(
3✔
165
              `Updating marker file (desktop: ${marker.desktop_version} -> ${currentDesktopVersion}, cli: ${marker.cli_version})...`
166
            )
167
            span.setAttributes({
3✔
168
              'cli.marker_updated': true,
169
              'cli.old_desktop_version': marker.desktop_version,
170
              'cli.new_desktop_version': currentDesktopVersion,
171
            })
172

173
            const cliPath = getDesktopCliPath(platform)
3✔
174

175
            // On Windows, we need to recopy the CLI since it's a copy not a symlink
176
            if (platform === 'win32') {
3✔
177
              log.info('Recopying CLI on Windows after app update...')
2✔
178
              const symlinkResult = createSymlink(platform)
2✔
179
              if (symlinkResult.success) {
2✔
180
                span.setAttribute('cli.windows_recopy', true)
1✔
181
                const cliInfo = await getCliInfo(cliPath)
1✔
182
                createMarkerForDesktopInstall({
1✔
183
                  cliVersion: cliInfo.version ?? 'unknown',
1!
184
                  cliChecksum: symlinkResult.checksum,
185
                  platform,
186
                })
187
              } else {
188
                // Don't update marker on failure - next launch will retry
189
                log.error(
1✔
190
                  `Failed to recopy CLI on Windows: ${symlinkResult.error}`
191
                )
192
                span.setAttributes({
1✔
193
                  'cli.windows_recopy': false,
194
                  'cli.windows_recopy_error': symlinkResult.error ?? 'unknown',
1!
195
                })
196
              }
197
            } else {
198
              // macOS/Linux: symlink auto-updates, just update marker
199
              const cliInfo = await getCliInfo(cliPath)
1✔
200
              const targetPath = getMarkerTargetPath()
1✔
201
              createMarkerForDesktopInstall({
1✔
202
                cliVersion: cliInfo.version ?? 'unknown',
1!
203
                symlinkTarget: isFlatpak() ? undefined : targetPath,
1!
204
                cliChecksum: marker.cli_checksum,
205
                platform,
206
                flatpakTarget: isFlatpak() ? targetPath : undefined,
1!
207
              })
208
            }
209
          }
210

211
          span.setAttributes({ 'cli.output_status': 'valid' })
4✔
212
          span.end()
4✔
213
          return { status: 'valid' }
4✔
214
        }
215

216
        // These cases require user interaction - return as-is for renderer to handle
217
        case 'external-cli-found':
218
          log.info('External CLI found - renderer will show issue page')
1✔
219
          span.setAttributes({
1✔
220
            'cli.output_status': 'external-cli-found',
221
            'cli.action_required': 'uninstall_external',
222
          })
223
          span.end()
1✔
224
          return result
1✔
225

226
        case 'symlink-broken':
227
          log.info('Symlink broken - renderer will show issue page')
1✔
228
          span.setAttributes({
1✔
229
            'cli.output_status': 'symlink-broken',
230
            'cli.action_required': 'repair_symlink',
231
          })
232
          span.end()
1✔
233
          return result
1✔
234

235
        case 'symlink-tampered':
236
          log.info('Symlink tampered - renderer will show issue page')
1✔
237
          span.setAttributes({
1✔
238
            'cli.output_status': 'symlink-tampered',
239
            'cli.action_required': 'restore_symlink',
240
          })
241
          span.end()
1✔
242
          return result
1✔
243

244
        // These cases can be auto-fixed without user interaction
245
        case 'symlink-missing':
246
        case 'fresh-install': {
247
          log.info('Performing fresh CLI installation...')
3✔
248

249
          const symlinkResult = createSymlink(platform)
3✔
250
          if (!symlinkResult.success) {
3✔
251
            log.error(`Failed to create CLI symlink: ${symlinkResult.error}`)
1✔
252
            span.setAttributes({
1✔
253
              'cli.output_status': 'error',
254
              'cli.error': symlinkResult.error ?? 'unknown',
1!
255
              'cli.success': false,
256
            })
257
            span.end()
1✔
258
            // Return a special error status - the app can still run
259
            return result
1✔
260
          }
261

262
          const cliPath = getDesktopCliPath(platform)
2✔
263
          const cliInfo = await getCliInfo(cliPath)
2✔
264
          const targetPath = getMarkerTargetPath()
2✔
265

266
          createMarkerForDesktopInstall({
2✔
267
            cliVersion: cliInfo.version ?? 'unknown',
2!
268
            symlinkTarget:
269
              platform === 'win32' || isFlatpak() ? undefined : targetPath,
7!
270
            cliChecksum: symlinkResult.checksum,
271
            flatpakTarget: isFlatpak() ? targetPath : undefined,
2!
272
          })
273

274
          log.info(`CLI installed: version=${cliInfo.version}, path=${cliPath}`)
3✔
275

276
          const pathResult = await configureShellPath()
3✔
277
          if (!pathResult.success) {
2!
278
            log.warn(
×
279
              'Failed to configure shell PATH, user may need to add manually'
280
            )
281
          }
282

283
          log.info('Fresh CLI installation completed successfully')
2✔
284
          span.setAttributes({
2✔
285
            'cli.output_status': 'valid',
286
            'cli.fresh_install': true,
287
            'cli.version': cliInfo.version ?? 'unknown',
2!
288
            'cli.path': cliPath,
289
            'cli.path_configured': pathResult.success,
290
          })
291
          span.end()
3✔
292
          return { status: 'valid' }
3✔
293
        }
294
      }
295
    }
296
  )
297
}
298

299
/**
300
 * Repairs a broken or tampered symlink.
301
 * Called from renderer via IPC when user confirms repair.
302
 */
303
export async function repairCliSymlink(
304
  platform: Platform = process.platform as Platform
2✔
305
): Promise<{ success: boolean; error?: string }> {
306
  return Sentry.startSpanManual(
2✔
307
    {
308
      name: 'CLI repair symlink',
309
      op: 'cli.repair',
310
      attributes: {
311
        'analytics.source': 'tracking',
312
        'analytics.type': 'event',
313
        'cli.platform': platform,
314
      },
315
    },
316
    async (span) => {
317
      log.info('Repairing CLI symlink...')
2✔
318

319
      const result = repairSymlink(platform)
2✔
320
      if (!result.success) {
2✔
321
        log.error(`Failed to repair symlink: ${result.error}`)
1✔
322
        span.setAttributes({
1✔
323
          'cli.success': false,
324
          'cli.error': result.error ?? 'unknown',
1!
325
        })
326
        span.end()
1✔
327
        return result
1✔
328
      }
329

330
      // Update marker file after repair
331
      const cliPath = getDesktopCliPath(platform)
1✔
332
      const cliInfo = await getCliInfo(cliPath)
1✔
333
      const targetPath = getMarkerTargetPath()
1✔
334
      createMarkerForDesktopInstall({
1✔
335
        cliVersion: cliInfo.version ?? 'unknown',
1!
336
        symlinkTarget:
337
          platform === 'win32' || isFlatpak() ? undefined : targetPath,
4!
338
        cliChecksum: result.checksum,
339
        flatpakTarget: isFlatpak() ? targetPath : undefined,
1!
340
      })
341

342
      log.info('Symlink repaired successfully')
2✔
343
      span.setAttributes({
2✔
344
        'cli.success': true,
345
        'cli.version': cliInfo.version ?? 'unknown',
2!
346
        'cli.path': cliPath,
347
      })
348
      span.end()
2✔
349
      return { success: true }
2✔
350
    }
351
  )
352
}
353

354
export async function getCliAlignmentStatus(
355
  platform: Platform = process.platform as Platform
2✔
356
): Promise<CliAlignmentStatus> {
357
  return Sentry.startSpanManual(
2✔
358
    {
359
      name: 'CLI get alignment status',
360
      op: 'cli.get_status',
361
      attributes: {
362
        'analytics.source': 'tracking',
363
        'analytics.type': 'event',
364
        'cli.platform': platform,
365
      },
366
    },
367
    async (span) => {
368
      const cliPath = getDesktopCliPath(platform)
2✔
369
      const marker = readMarkerFile()
2✔
370
      const symlink = checkSymlink(platform)
2✔
371
      const cliInfo = await getCliInfo(cliPath)
2✔
372

373
      const status = {
2✔
374
        isManaged: marker !== null && symlink.isOurBinary,
3✔
375
        cliPath,
376
        cliVersion: cliInfo.version,
377
        installMethod: marker?.install_method ?? null,
3✔
378
        symlinkTarget: symlink.target,
379
        isValid: symlink.exists && symlink.targetExists && symlink.isOurBinary,
4✔
380
        lastValidated: new Date().toISOString(),
381
      }
382

383
      span.setAttributes({
2✔
384
        'cli.is_managed': status.isManaged,
385
        'cli.is_valid': status.isValid,
386
        'cli.version': status.cliVersion ?? 'unknown',
3✔
387
        'cli.install_method': status.installMethod ?? 'none',
3✔
388
      })
389
      span.end()
2✔
390

391
      return status
2✔
392
    }
393
  )
394
}
395

396
export async function reinstallCliSymlink(
397
  platform: Platform = process.platform as Platform
×
398
): Promise<{ success: boolean; error?: string }> {
399
  return Sentry.startSpanManual(
×
400
    {
401
      name: 'CLI reinstall symlink',
402
      op: 'cli.reinstall',
403
      attributes: {
404
        'analytics.source': 'tracking',
405
        'analytics.type': 'event',
406
        'cli.platform': platform,
407
      },
408
    },
409
    async (span) => {
410
      const result = createSymlink(platform)
×
411

412
      if (result.success) {
×
413
        const bundledPath = getBundledCliPath()
×
414
        const cliInfo = await getCliInfo(bundledPath)
×
415
        const targetPath = getMarkerTargetPath()
×
416
        createMarkerForDesktopInstall({
×
417
          cliVersion: cliInfo.version ?? 'unknown',
×
418
          symlinkTarget:
419
            platform === 'win32' || isFlatpak() ? undefined : targetPath,
×
420
          cliChecksum: result.checksum,
421
          flatpakTarget: isFlatpak() ? targetPath : undefined,
×
422
        })
423
        span.setAttributes({
×
424
          'cli.success': true,
425
          'cli.version': cliInfo.version ?? 'unknown',
×
426
        })
427
      } else {
428
        span.setAttributes({
×
429
          'cli.success': false,
430
          'cli.error': result.error ?? 'unknown',
×
431
        })
432
      }
433

434
      span.end()
×
435
      return result
×
436
    }
437
  )
438
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc