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

stacklok / toolhive-studio / 21600264877

02 Feb 2026 05:23PM UTC coverage: 55.581% (+0.09%) from 55.487%
21600264877

Pull #1544

github

web-flow
Merge b6031a644 into 1a13add34
Pull Request #1544: feat(cli): gate PATH configuration behind feature flag

2448 of 4637 branches covered (52.79%)

Branch coverage included in aggregate %.

49 of 49 new or added lines in 2 files covered. (100.0%)

15 existing lines in 1 file now uncovered.

3906 of 6795 relevant lines covered (57.48%)

117.3 hits per line

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

74.16
/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

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

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

50
      const marker = readMarkerFile()
5✔
51

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

59
      const symlink = checkSymlink(platform)
4✔
60

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

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

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

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

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

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

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

154
          if (needsUpdate) {
1!
UNCOV
155
            log.info(
×
156
              `Updating marker file (desktop: ${marker.desktop_version} -> ${currentDesktopVersion}, cli: ${marker.cli_version})...`
157
            )
UNCOV
158
            span.setAttributes({
×
159
              'cli.marker_updated': true,
160
              'cli.old_desktop_version': marker.desktop_version,
161
              'cli.new_desktop_version': currentDesktopVersion,
162
            })
UNCOV
163
            const bundledPath = getBundledCliPath()
×
UNCOV
164
            const cliPath = getDesktopCliPath(platform)
×
165
            const cliInfo = await getCliInfo(cliPath)
×
166
            createMarkerForDesktopInstall(
×
167
              cliInfo.version ?? 'unknown',
×
168
              platform === 'win32' ? undefined : bundledPath,
×
169
              marker.cli_checksum
170
            )
171
          }
172

173
          span.setAttributes({ 'cli.output_status': 'valid' })
1✔
174
          span.end()
1✔
175
          return { status: 'valid' }
1✔
176
        }
177

178
        // These cases require user interaction - return as-is for renderer to handle
179
        case 'external-cli-found':
180
          log.info('External CLI found - renderer will show issue page')
1✔
181
          span.setAttributes({
1✔
182
            'cli.output_status': 'external-cli-found',
183
            'cli.action_required': 'uninstall_external',
184
          })
185
          span.end()
1✔
186
          return result
1✔
187

188
        case 'symlink-broken':
189
          log.info('Symlink broken - renderer will show issue page')
1✔
190
          span.setAttributes({
1✔
191
            'cli.output_status': 'symlink-broken',
192
            'cli.action_required': 'repair_symlink',
193
          })
194
          span.end()
1✔
195
          return result
1✔
196

197
        case 'symlink-tampered':
198
          log.info('Symlink tampered - renderer will show issue page')
1✔
199
          span.setAttributes({
1✔
200
            'cli.output_status': 'symlink-tampered',
201
            'cli.action_required': 'restore_symlink',
202
          })
203
          span.end()
1✔
204
          return result
1✔
205

206
        // These cases can be auto-fixed without user interaction
207
        case 'symlink-missing':
208
        case 'fresh-install': {
209
          log.info('Performing fresh CLI installation...')
3✔
210

211
          const symlinkResult = createSymlink(platform)
3✔
212
          if (!symlinkResult.success) {
3✔
213
            log.error(`Failed to create CLI symlink: ${symlinkResult.error}`)
1✔
214
            span.setAttributes({
1✔
215
              'cli.output_status': 'error',
216
              'cli.error': symlinkResult.error ?? 'unknown',
1!
217
              'cli.success': false,
218
            })
219
            span.end()
1✔
220
            // Return a special error status - the app can still run
221
            return result
1✔
222
          }
223

224
          const bundledPath = getBundledCliPath()
2✔
225
          const cliPath = getDesktopCliPath(platform)
2✔
226
          const cliInfo = await getCliInfo(cliPath)
2✔
227

228
          createMarkerForDesktopInstall(
2✔
229
            cliInfo.version ?? 'unknown',
2!
230
            platform === 'win32' ? undefined : bundledPath,
2!
231
            symlinkResult.checksum
232
          )
233

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

236
          const pathResult = await configureShellPath()
3✔
237
          if (!pathResult.success) {
2!
UNCOV
238
            log.warn(
×
239
              'Failed to configure shell PATH, user may need to add manually'
240
            )
241
          }
242

243
          log.info('Fresh CLI installation completed successfully')
2✔
244
          span.setAttributes({
2✔
245
            'cli.output_status': 'valid',
246
            'cli.fresh_install': true,
247
            'cli.version': cliInfo.version ?? 'unknown',
2!
248
            'cli.path': cliPath,
249
            'cli.path_configured': pathResult.success,
250
          })
251
          span.end()
3✔
252
          return { status: 'valid' }
3✔
253
        }
254
      }
255
    }
256
  )
257
}
258

259
/**
260
 * Repairs a broken or tampered symlink.
261
 * Called from renderer via IPC when user confirms repair.
262
 */
263
export async function repairCliSymlink(
264
  platform: Platform = process.platform as Platform
2✔
265
): Promise<{ success: boolean; error?: string }> {
266
  return Sentry.startSpanManual(
2✔
267
    {
268
      name: 'CLI repair symlink',
269
      op: 'cli.repair',
270
      attributes: {
271
        'analytics.source': 'tracking',
272
        'analytics.type': 'event',
273
        'cli.platform': platform,
274
      },
275
    },
276
    async (span) => {
277
      log.info('Repairing CLI symlink...')
2✔
278

279
      const result = repairSymlink(platform)
2✔
280
      if (!result.success) {
2✔
281
        log.error(`Failed to repair symlink: ${result.error}`)
1✔
282
        span.setAttributes({
1✔
283
          'cli.success': false,
284
          'cli.error': result.error ?? 'unknown',
1!
285
        })
286
        span.end()
1✔
287
        return result
1✔
288
      }
289

290
      // Update marker file after repair
291
      const bundledPath = getBundledCliPath()
1✔
292
      const cliPath = getDesktopCliPath(platform)
1✔
293
      const cliInfo = await getCliInfo(cliPath)
1✔
294
      createMarkerForDesktopInstall(
1✔
295
        cliInfo.version ?? 'unknown',
1!
296
        platform === 'win32' ? undefined : bundledPath,
1!
297
        result.checksum
298
      )
299

300
      log.info('Symlink repaired successfully')
2✔
301
      span.setAttributes({
2✔
302
        'cli.success': true,
303
        'cli.version': cliInfo.version ?? 'unknown',
2!
304
        'cli.path': cliPath,
305
      })
306
      span.end()
2✔
307
      return { success: true }
2✔
308
    }
309
  )
310
}
311

312
export async function getCliAlignmentStatus(
313
  platform: Platform = process.platform as Platform
2✔
314
): Promise<CliAlignmentStatus> {
315
  return Sentry.startSpanManual(
2✔
316
    {
317
      name: 'CLI get alignment status',
318
      op: 'cli.get_status',
319
      attributes: {
320
        'analytics.source': 'tracking',
321
        'analytics.type': 'event',
322
        'cli.platform': platform,
323
      },
324
    },
325
    async (span) => {
326
      const cliPath = getDesktopCliPath(platform)
2✔
327
      const marker = readMarkerFile()
2✔
328
      const symlink = checkSymlink(platform)
2✔
329
      const cliInfo = await getCliInfo(cliPath)
2✔
330

331
      const status = {
2✔
332
        isManaged: marker !== null && symlink.isOurBinary,
3✔
333
        cliPath,
334
        cliVersion: cliInfo.version,
335
        installMethod: marker?.install_method ?? null,
3✔
336
        symlinkTarget: symlink.target,
337
        isValid: symlink.exists && symlink.targetExists && symlink.isOurBinary,
4✔
338
        lastValidated: new Date().toISOString(),
339
      }
340

341
      span.setAttributes({
2✔
342
        'cli.is_managed': status.isManaged,
343
        'cli.is_valid': status.isValid,
344
        'cli.version': status.cliVersion ?? 'unknown',
3✔
345
        'cli.install_method': status.installMethod ?? 'none',
3✔
346
      })
347
      span.end()
2✔
348

349
      return status
2✔
350
    }
351
  )
352
}
353

354
export async function reinstallCliSymlink(
355
  platform: Platform = process.platform as Platform
×
356
): Promise<{ success: boolean; error?: string }> {
UNCOV
357
  return Sentry.startSpanManual(
×
358
    {
359
      name: 'CLI reinstall symlink',
360
      op: 'cli.reinstall',
361
      attributes: {
362
        'analytics.source': 'tracking',
363
        'analytics.type': 'event',
364
        'cli.platform': platform,
365
      },
366
    },
367
    async (span) => {
UNCOV
368
      const result = createSymlink(platform)
×
369

UNCOV
370
      if (result.success) {
×
UNCOV
371
        const bundledPath = getBundledCliPath()
×
UNCOV
372
        const cliInfo = await getCliInfo(bundledPath)
×
UNCOV
373
        createMarkerForDesktopInstall(
×
374
          cliInfo.version ?? 'unknown',
×
375
          platform === 'win32' ? undefined : bundledPath,
×
376
          result.checksum
377
        )
378
        span.setAttributes({
×
379
          'cli.success': true,
380
          'cli.version': cliInfo.version ?? 'unknown',
×
381
        })
382
      } else {
UNCOV
383
        span.setAttributes({
×
384
          'cli.success': false,
385
          'cli.error': result.error ?? 'unknown',
×
386
        })
387
      }
388

389
      span.end()
×
UNCOV
390
      return result
×
391
    }
392
  )
393
}
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