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

stacklok / toolhive-studio / 21870117196

10 Feb 2026 03:01PM UTC coverage: 55.588% (-0.3%) from 55.902%
21870117196

Pull #1563

github

web-flow
Merge 41610c3cb into 6820b3c05
Pull Request #1563: feat: rebranding application

2485 of 4691 branches covered (52.97%)

Branch coverage included in aggregate %.

14 of 25 new or added lines in 7 files covered. (56.0%)

278 existing lines in 7 files now uncovered.

3946 of 6878 relevant lines covered (57.37%)

117.07 hits per line

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

79.46
/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
10✔
129
): Promise<ValidationResult> {
130
  return Sentry.startSpanManual(
10✔
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) {
10✔
143
        case 'valid': {
144
          log.info('CLI alignment is valid')
4✔
145

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

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

164
            const bundledPath = getBundledCliPath()
3✔
165
            const cliPath = getDesktopCliPath(platform)
3✔
166

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

202
          span.setAttributes({ 'cli.output_status': 'valid' })
4✔
203
          span.end()
4✔
204
          return { status: 'valid' }
4✔
205
        }
206

207
        // These cases require user interaction - return as-is for renderer to handle
208
        case 'external-cli-found':
209
          log.info('External CLI found - renderer will show issue page')
1✔
210
          span.setAttributes({
1✔
211
            'cli.output_status': 'external-cli-found',
212
            'cli.action_required': 'uninstall_external',
213
          })
214
          span.end()
1✔
215
          return result
1✔
216

217
        case 'symlink-broken':
218
          log.info('Symlink broken - renderer will show issue page')
1✔
219
          span.setAttributes({
1✔
220
            'cli.output_status': 'symlink-broken',
221
            'cli.action_required': 'repair_symlink',
222
          })
223
          span.end()
1✔
224
          return result
1✔
225

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

235
        // These cases can be auto-fixed without user interaction
236
        case 'symlink-missing':
237
        case 'fresh-install': {
238
          log.info('Performing fresh CLI installation...')
3✔
239

240
          const symlinkResult = createSymlink(platform)
3✔
241
          if (!symlinkResult.success) {
3✔
242
            log.error(`Failed to create CLI symlink: ${symlinkResult.error}`)
1✔
243
            span.setAttributes({
1✔
244
              'cli.output_status': 'error',
245
              'cli.error': symlinkResult.error ?? 'unknown',
1!
246
              'cli.success': false,
247
            })
248
            span.end()
1✔
249
            // Return a special error status - the app can still run
250
            return result
1✔
251
          }
252

253
          const bundledPath = getBundledCliPath()
2✔
254
          const cliPath = getDesktopCliPath(platform)
2✔
255
          const cliInfo = await getCliInfo(cliPath)
2✔
256

257
          createMarkerForDesktopInstall(
2✔
258
            cliInfo.version ?? 'unknown',
2!
259
            platform === 'win32' ? undefined : bundledPath,
2!
260
            symlinkResult.checksum
261
          )
262

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

265
          const pathResult = await configureShellPath()
3✔
266
          if (!pathResult.success) {
2!
UNCOV
267
            log.warn(
×
268
              'Failed to configure shell PATH, user may need to add manually'
269
            )
270
          }
271

272
          log.info('Fresh CLI installation completed successfully')
2✔
273
          span.setAttributes({
2✔
274
            'cli.output_status': 'valid',
275
            'cli.fresh_install': true,
276
            'cli.version': cliInfo.version ?? 'unknown',
2!
277
            'cli.path': cliPath,
278
            'cli.path_configured': pathResult.success,
279
          })
280
          span.end()
3✔
281
          return { status: 'valid' }
3✔
282
        }
283
      }
284
    }
285
  )
286
}
287

288
/**
289
 * Repairs a broken or tampered symlink.
290
 * Called from renderer via IPC when user confirms repair.
291
 */
292
export async function repairCliSymlink(
293
  platform: Platform = process.platform as Platform
2✔
294
): Promise<{ success: boolean; error?: string }> {
295
  return Sentry.startSpanManual(
2✔
296
    {
297
      name: 'CLI repair symlink',
298
      op: 'cli.repair',
299
      attributes: {
300
        'analytics.source': 'tracking',
301
        'analytics.type': 'event',
302
        'cli.platform': platform,
303
      },
304
    },
305
    async (span) => {
306
      log.info('Repairing CLI symlink...')
2✔
307

308
      const result = repairSymlink(platform)
2✔
309
      if (!result.success) {
2✔
310
        log.error(`Failed to repair symlink: ${result.error}`)
1✔
311
        span.setAttributes({
1✔
312
          'cli.success': false,
313
          'cli.error': result.error ?? 'unknown',
1!
314
        })
315
        span.end()
1✔
316
        return result
1✔
317
      }
318

319
      // Update marker file after repair
320
      const bundledPath = getBundledCliPath()
1✔
321
      const cliPath = getDesktopCliPath(platform)
1✔
322
      const cliInfo = await getCliInfo(cliPath)
1✔
323
      createMarkerForDesktopInstall(
1✔
324
        cliInfo.version ?? 'unknown',
1!
325
        platform === 'win32' ? undefined : bundledPath,
1!
326
        result.checksum
327
      )
328

329
      log.info('Symlink repaired successfully')
2✔
330
      span.setAttributes({
2✔
331
        'cli.success': true,
332
        'cli.version': cliInfo.version ?? 'unknown',
2!
333
        'cli.path': cliPath,
334
      })
335
      span.end()
2✔
336
      return { success: true }
2✔
337
    }
338
  )
339
}
340

341
export async function getCliAlignmentStatus(
342
  platform: Platform = process.platform as Platform
2✔
343
): Promise<CliAlignmentStatus> {
344
  return Sentry.startSpanManual(
2✔
345
    {
346
      name: 'CLI get alignment status',
347
      op: 'cli.get_status',
348
      attributes: {
349
        'analytics.source': 'tracking',
350
        'analytics.type': 'event',
351
        'cli.platform': platform,
352
      },
353
    },
354
    async (span) => {
355
      const cliPath = getDesktopCliPath(platform)
2✔
356
      const marker = readMarkerFile()
2✔
357
      const symlink = checkSymlink(platform)
2✔
358
      const cliInfo = await getCliInfo(cliPath)
2✔
359

360
      const status = {
2✔
361
        isManaged: marker !== null && symlink.isOurBinary,
3✔
362
        cliPath,
363
        cliVersion: cliInfo.version,
364
        installMethod: marker?.install_method ?? null,
3✔
365
        symlinkTarget: symlink.target,
366
        isValid: symlink.exists && symlink.targetExists && symlink.isOurBinary,
4✔
367
        lastValidated: new Date().toISOString(),
368
      }
369

370
      span.setAttributes({
2✔
371
        'cli.is_managed': status.isManaged,
372
        'cli.is_valid': status.isValid,
373
        'cli.version': status.cliVersion ?? 'unknown',
3✔
374
        'cli.install_method': status.installMethod ?? 'none',
3✔
375
      })
376
      span.end()
2✔
377

378
      return status
2✔
379
    }
380
  )
381
}
382

383
export async function reinstallCliSymlink(
384
  platform: Platform = process.platform as Platform
×
385
): Promise<{ success: boolean; error?: string }> {
UNCOV
386
  return Sentry.startSpanManual(
×
387
    {
388
      name: 'CLI reinstall symlink',
389
      op: 'cli.reinstall',
390
      attributes: {
391
        'analytics.source': 'tracking',
392
        'analytics.type': 'event',
393
        'cli.platform': platform,
394
      },
395
    },
396
    async (span) => {
UNCOV
397
      const result = createSymlink(platform)
×
398

UNCOV
399
      if (result.success) {
×
UNCOV
400
        const bundledPath = getBundledCliPath()
×
UNCOV
401
        const cliInfo = await getCliInfo(bundledPath)
×
UNCOV
402
        createMarkerForDesktopInstall(
×
403
          cliInfo.version ?? 'unknown',
×
404
          platform === 'win32' ? undefined : bundledPath,
×
405
          result.checksum
406
        )
407
        span.setAttributes({
×
408
          'cli.success': true,
409
          'cli.version': cliInfo.version ?? 'unknown',
×
410
        })
411
      } else {
UNCOV
412
        span.setAttributes({
×
413
          'cli.success': false,
414
          'cli.error': result.error ?? 'unknown',
×
415
        })
416
      }
417

418
      span.end()
×
UNCOV
419
      return result
×
420
    }
421
  )
422
}
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