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

stacklok / toolhive-studio / 21488687879

29 Jan 2026 05:44PM UTC coverage: 55.109% (+1.8%) from 53.322%
21488687879

Pull #1513

github

web-flow
Merge 5adbeef3d into eb9bb5b1f
Pull Request #1513: feat(cli): add CLI alignment flow

2410 of 4590 branches covered (52.51%)

446 of 586 new or added lines in 17 files covered. (76.11%)

2 existing lines in 2 files now uncovered.

3808 of 6693 relevant lines covered (56.9%)

120.06 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, CliAlignmentStatus, Platform } from './types'
19
import log from '../logger'
20

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

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

49
      const marker = readMarkerFile()
5✔
50

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

58
      const symlink = checkSymlink(platform)
4✔
59

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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