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

stacklok / toolhive-studio / 21719849638

05 Feb 2026 04:33PM UTC coverage: 55.752% (+0.1%) from 55.616%
21719849638

Pull #1562

github

web-flow
Merge 22ed465f6 into b7ab85ae6
Pull Request #1562: fix(cli-validation): update CLI binary on Windows when app version changes

2459 of 4645 branches covered (52.94%)

14 of 14 new or added lines in 1 file covered. (100.0%)

10 existing lines in 1 file now uncovered.

3928 of 6811 relevant lines covered (57.67%)

116.96 hits per line

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

80.69
/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
  repairSymlink,
15
} from './symlink-manager'
16
import { configureShellPath, checkPathConfiguration } from './path-configurator'
17
import { getDesktopCliPath } from './constants'
18
import type { ValidationResult } from '@common/types/cli'
19
import type { CliAlignmentStatus, Platform } from './types'
20
import log from '../logger'
21
import { getFeatureFlag } from '../feature-flags'
22
import { featureFlagKeys } from '../../../utils/feature-flags'
23

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

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

52
      const marker = readMarkerFile()
5✔
53

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

61
      const symlink = checkSymlink(platform)
4✔
62

63
      if (!symlink.exists) {
4✔
64
        log.warn('CLI alignment issue: symlink-missing')
1✔
65
        span.setAttributes({ 'cli.status': 'symlink-missing' })
1✔
66
        span.end()
1✔
67
        return { status: 'symlink-missing' }
1✔
68
      }
69

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

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

93
      // Check and configure PATH if needed
94
      const pathStatus = await checkPathConfiguration()
1✔
95
      if (!pathStatus.isConfigured) {
1!
96
        log.info('PATH not configured, configuring now...')
×
97
        const pathResult = await configureShellPath()
×
98
        span.setAttribute('cli.path_configured', pathResult.success)
×
99
        if (!pathResult.success) {
×
100
          log.warn('Failed to configure PATH, user may need to add manually')
×
101
        }
102
      } else {
103
        span.setAttribute('cli.path_configured', true)
1✔
104
      }
105

106
      log.info('CLI alignment validation passed')
1✔
107
      span.setAttributes({ 'cli.status': 'valid' })
1✔
108
      span.end()
1✔
109
      return { status: 'valid' }
1✔
110
    }
111
  )
112
}
113

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

148
          // Update marker file if desktop version changed (app was updated) or cli_version is unknown
149
          const marker = readMarkerFile()
4✔
150
          const currentDesktopVersion = app.getVersion()
4✔
151
          const needsUpdate =
152
            marker &&
4✔
153
            (marker.desktop_version !== currentDesktopVersion ||
154
              marker.cli_version === 'unknown')
155

156
          if (needsUpdate) {
4✔
157
            log.info(
3✔
158
              `Updating marker file (desktop: ${marker.desktop_version} -> ${currentDesktopVersion}, cli: ${marker.cli_version})...`
159
            )
160
            span.setAttributes({
3✔
161
              'cli.marker_updated': true,
162
              'cli.old_desktop_version': marker.desktop_version,
163
              'cli.new_desktop_version': currentDesktopVersion,
164
            })
165

166
            const bundledPath = getBundledCliPath()
3✔
167
            const cliPath = getDesktopCliPath(platform)
3✔
168
            let newChecksum = marker.cli_checksum
3✔
169

170
            // On Windows, we need to recopy the CLI since it's a copy not a symlink
171
            if (platform === 'win32') {
3✔
172
              log.info('Recopying CLI on Windows after app update...')
2✔
173
              const symlinkResult = createSymlink(platform)
2✔
174
              if (symlinkResult.success) {
2✔
175
                newChecksum = symlinkResult.checksum
1✔
176
                span.setAttribute('cli.windows_recopy', true)
1✔
177
              } else {
178
                log.error(
1✔
179
                  `Failed to recopy CLI on Windows: ${symlinkResult.error}`
180
                )
181
                span.setAttribute(
1✔
182
                  'cli.windows_recopy_error',
183
                  symlinkResult.error ?? 'unknown'
1!
184
                )
185
              }
186
            }
187

188
            const cliInfo = await getCliInfo(cliPath)
3✔
189
            createMarkerForDesktopInstall(
3✔
190
              cliInfo.version ?? 'unknown',
3!
191
              platform === 'win32' ? undefined : bundledPath,
3✔
192
              newChecksum
193
            )
194
          }
195

196
          span.setAttributes({ 'cli.output_status': 'valid' })
4✔
197
          span.end()
4✔
198
          return { status: 'valid' }
4✔
199
        }
200

201
        // These cases require user interaction - return as-is for renderer to handle
202
        case 'external-cli-found':
203
          log.info('External CLI found - renderer will show issue page')
1✔
204
          span.setAttributes({
1✔
205
            'cli.output_status': 'external-cli-found',
206
            'cli.action_required': 'uninstall_external',
207
          })
208
          span.end()
1✔
209
          return result
1✔
210

211
        case 'symlink-broken':
212
          log.info('Symlink broken - renderer will show issue page')
1✔
213
          span.setAttributes({
1✔
214
            'cli.output_status': 'symlink-broken',
215
            'cli.action_required': 'repair_symlink',
216
          })
217
          span.end()
1✔
218
          return result
1✔
219

220
        case 'symlink-tampered':
221
          log.info('Symlink tampered - renderer will show issue page')
1✔
222
          span.setAttributes({
1✔
223
            'cli.output_status': 'symlink-tampered',
224
            'cli.action_required': 'restore_symlink',
225
          })
226
          span.end()
1✔
227
          return result
1✔
228

229
        // These cases can be auto-fixed without user interaction
230
        case 'symlink-missing':
231
        case 'fresh-install': {
232
          // Check if CLI installation is enabled via feature flag
233
          const isCliInstallEnabled = getFeatureFlag(
5✔
234
            featureFlagKeys.CLI_VALIDATION_ENFORCE
235
          )
236

237
          span.setAttribute('cli.feature_flag_enabled', isCliInstallEnabled)
5✔
238

239
          if (!isCliInstallEnabled) {
5✔
240
            log.info(
2✔
241
              'CLI installation disabled by feature flag, skipping symlink and marker creation'
242
            )
243
            span.setAttributes({
2✔
244
              'cli.output_status': 'skipped',
245
              'cli.skipped_reason': 'feature_flag_disabled',
246
            })
247
            span.end()
2✔
248
            return { status: 'valid' }
2✔
249
          }
250

251
          log.info('Performing fresh CLI installation...')
3✔
252

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

266
          const bundledPath = getBundledCliPath()
2✔
267
          const cliPath = getDesktopCliPath(platform)
2✔
268
          const cliInfo = await getCliInfo(cliPath)
2✔
269

270
          createMarkerForDesktopInstall(
2✔
271
            cliInfo.version ?? 'unknown',
2!
272
            platform === 'win32' ? undefined : bundledPath,
2!
273
            symlinkResult.checksum
274
          )
275

276
          log.info(`CLI installed: version=${cliInfo.version}, path=${cliPath}`)
5✔
277

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

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

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

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

332
      // Update marker file after repair
333
      const bundledPath = getBundledCliPath()
1✔
334
      const cliPath = getDesktopCliPath(platform)
1✔
335
      const cliInfo = await getCliInfo(cliPath)
1✔
336
      createMarkerForDesktopInstall(
1✔
337
        cliInfo.version ?? 'unknown',
1!
338
        platform === 'win32' ? undefined : bundledPath,
1!
339
        result.checksum
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 }> {
UNCOV
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) => {
UNCOV
410
      const result = createSymlink(platform)
×
411

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

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

© 2026 Coveralls, Inc