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

stacklok / toolhive-studio / 22628825342

03 Mar 2026 02:59PM UTC coverage: 56.468% (+0.1%) from 56.325%
22628825342

Pull #1684

github

web-flow
Merge dcfedd227 into e9de54be8
Pull Request #1684: chore: add skill about deep links

2679 of 4989 branches covered (53.7%)

4345 of 7450 relevant lines covered (58.32%)

113.23 hits per line

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

77.18
/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 { configureShellPath, checkPathConfiguration } from './path-configurator'
19
import { getDesktopCliPath } from './constants'
20
import type { ValidationResult } from '@common/types/cli'
21
import type { CliAlignmentStatus, Platform } from './types'
22
import log from '../logger'
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
10✔
131
): Promise<ValidationResult> {
132
  return Sentry.startSpanManual(
10✔
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) {
10✔
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 cliPath = getDesktopCliPath(platform)
3✔
167

168
            // On Windows, we need to recopy the CLI since it's a copy not a symlink
169
            if (platform === 'win32') {
3✔
170
              log.info('Recopying CLI on Windows after app update...')
2✔
171
              const symlinkResult = createSymlink(platform)
2✔
172
              if (symlinkResult.success) {
2✔
173
                span.setAttribute('cli.windows_recopy', true)
1✔
174
                const cliInfo = await getCliInfo(cliPath)
1✔
175
                createMarkerForDesktopInstall({
1✔
176
                  cliVersion: cliInfo.version ?? 'unknown',
1!
177
                  cliChecksum: 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
              const targetPath = getMarkerTargetPath()
1✔
194
              createMarkerForDesktopInstall({
1✔
195
                cliVersion: cliInfo.version ?? 'unknown',
1!
196
                symlinkTarget: isFlatpak() ? undefined : targetPath,
1!
197
                cliChecksum: marker.cli_checksum,
198
                platform,
199
                flatpakTarget: isFlatpak() ? targetPath : undefined,
1!
200
              })
201
            }
202
          }
203

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

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

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

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

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

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

255
          const cliPath = getDesktopCliPath(platform)
2✔
256
          const cliInfo = await getCliInfo(cliPath)
2✔
257
          const targetPath = getMarkerTargetPath()
2✔
258

259
          createMarkerForDesktopInstall({
2✔
260
            cliVersion: cliInfo.version ?? 'unknown',
2!
261
            symlinkTarget:
262
              platform === 'win32' || isFlatpak() ? undefined : targetPath,
7!
263
            cliChecksum: symlinkResult.checksum,
264
            flatpakTarget: isFlatpak() ? targetPath : undefined,
2!
265
          })
266

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

269
          const pathResult = await configureShellPath()
3✔
270
          if (!pathResult.success) {
2!
271
            log.warn(
×
272
              'Failed to configure shell PATH, user may need to add manually'
273
            )
274
          }
275

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

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

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

323
      // Update marker file after repair
324
      const cliPath = getDesktopCliPath(platform)
1✔
325
      const cliInfo = await getCliInfo(cliPath)
1✔
326
      const targetPath = getMarkerTargetPath()
1✔
327
      createMarkerForDesktopInstall({
1✔
328
        cliVersion: cliInfo.version ?? 'unknown',
1!
329
        symlinkTarget:
330
          platform === 'win32' || isFlatpak() ? undefined : targetPath,
4!
331
        cliChecksum: result.checksum,
332
        flatpakTarget: isFlatpak() ? targetPath : undefined,
1!
333
      })
334

335
      log.info('Symlink repaired successfully')
2✔
336
      span.setAttributes({
2✔
337
        'cli.success': true,
338
        'cli.version': cliInfo.version ?? 'unknown',
2!
339
        'cli.path': cliPath,
340
      })
341
      span.end()
2✔
342
      return { success: true }
2✔
343
    }
344
  )
345
}
346

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

366
      const status = {
2✔
367
        isManaged: marker !== null && symlink.isOurBinary,
3✔
368
        cliPath,
369
        cliVersion: cliInfo.version,
370
        installMethod: marker?.install_method ?? null,
3✔
371
        symlinkTarget: symlink.target,
372
        isValid: symlink.exists && symlink.targetExists && symlink.isOurBinary,
4✔
373
        lastValidated: new Date().toISOString(),
374
      }
375

376
      span.setAttributes({
2✔
377
        'cli.is_managed': status.isManaged,
378
        'cli.is_valid': status.isValid,
379
        'cli.version': status.cliVersion ?? 'unknown',
3✔
380
        'cli.install_method': status.installMethod ?? 'none',
3✔
381
      })
382
      span.end()
2✔
383

384
      return status
2✔
385
    }
386
  )
387
}
388

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

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

427
      span.end()
×
428
      return result
×
429
    }
430
  )
431
}
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