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

stacklok / toolhive-studio / 21360309641

26 Jan 2026 01:58PM UTC coverage: 52.192% (-1.1%) from 53.313%
21360309641

Pull #1513

github

web-flow
Merge 801f76bda into ce8d9801b
Pull Request #1513: feat(cli): add CLI alignment flow

2251 of 4527 branches covered (49.72%)

Branch coverage included in aggregate %.

195 of 477 new or added lines in 12 files covered. (40.88%)

2 existing lines in 2 files now uncovered.

3548 of 6584 relevant lines covered (53.89%)

121.54 hits per line

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

69.09
/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 { detectExternalCli, getCliInfo } from './cli-detection'
8
import { readMarkerFile, createMarkerForDesktopInstall } from './marker-file'
9
import {
10
  checkSymlink,
11
  createSymlink,
12
  getBundledCliPath,
13
  repairSymlink,
14
  removeSymlink,
15
} from './symlink-manager'
16
import {
17
  configureShellPath,
18
  removeShellPath,
19
  checkPathConfiguration,
20
} from './path-configurator'
21
import { getDesktopCliPath } from './constants'
22
import { deleteMarkerFile } from './marker-file'
23
import type { ValidationResult, CliAlignmentStatus, Platform } from './types'
24
import log from '../logger'
25

26
export async function validateCliAlignment(
27
  platform: Platform = process.platform as Platform
6✔
28
): Promise<ValidationResult> {
29
  log.info('Starting CLI alignment validation...')
6✔
30

31
  const external = await detectExternalCli(platform)
6✔
32
  if (external) {
6✔
33
    log.warn(`External CLI found at: ${external.path}`)
1✔
34
    return { status: 'external-cli-found', cli: external }
1✔
35
  }
36

37
  const marker = readMarkerFile()
5✔
38

39
  if (!marker) {
5✔
40
    log.info('No marker file found, treating as fresh install')
1✔
41
    return { status: 'fresh-install' }
1✔
42
  }
43

44
  const symlink = checkSymlink(platform)
4✔
45

46
  if (!symlink.exists) {
4✔
47
    log.warn('CLI alignment issue: symlink-missing')
1✔
48
    return { status: 'symlink-missing' }
1✔
49
  }
50

51
  if (!symlink.targetExists) {
3✔
52
    log.warn('CLI alignment issue: symlink-broken')
1✔
53
    return { status: 'symlink-broken', target: symlink.target ?? 'unknown' }
1!
54
  }
55

56
  if (!symlink.isOurBinary) {
2✔
57
    log.warn('CLI alignment issue: symlink-tampered')
1✔
58
    return { status: 'symlink-tampered', target: symlink.target ?? 'unknown' }
1!
59
  }
60

61
  // Check and configure PATH if needed (non-Windows only)
62
  if (platform !== 'win32') {
1!
63
    const pathStatus = await checkPathConfiguration()
1✔
64
    if (!pathStatus.isConfigured) {
1!
NEW
65
      log.info('PATH not configured, configuring now...')
×
NEW
66
      const pathResult = await configureShellPath()
×
NEW
67
      if (!pathResult.success) {
×
NEW
68
        log.warn('Failed to configure PATH, user may need to add manually')
×
69
      }
70
    }
71
  }
72

73
  log.info('CLI alignment validation passed')
1✔
74
  return { status: 'valid' }
1✔
75
}
76

77
/**
78
 * Handles validation results that can be auto-fixed without user interaction.
79
 * Returns the updated validation result after attempting auto-fixes.
80
 *
81
 * Cases handled automatically:
82
 * - valid: Updates marker file if needed
83
 * - fresh-install: Creates symlink and marker
84
 * - symlink-missing: Creates symlink and marker
85
 *
86
 * Cases requiring user interaction (returned as-is for renderer to handle):
87
 * - external-cli-found: User must uninstall external CLI
88
 * - symlink-broken: User must confirm repair
89
 * - symlink-tampered: User must confirm restore
90
 */
91
export async function handleValidationResult(
92
  result: ValidationResult,
93
  platform: Platform = process.platform as Platform
7✔
94
): Promise<ValidationResult> {
95
  switch (result.status) {
7✔
96
    case 'valid': {
97
      log.info('CLI alignment is valid')
1✔
98

99
      // Update marker file if desktop version changed (app was updated) or cli_version is unknown
100
      const marker = readMarkerFile()
1✔
101
      const currentDesktopVersion = app.getVersion()
1✔
102
      const needsUpdate =
103
        marker &&
1✔
104
        (marker.desktop_version !== currentDesktopVersion ||
105
          marker.cli_version === 'unknown')
106

107
      if (needsUpdate) {
1!
NEW
108
        log.info(
×
109
          `Updating marker file (desktop: ${marker.desktop_version} -> ${currentDesktopVersion}, cli: ${marker.cli_version})...`
110
        )
NEW
111
        const bundledPath = getBundledCliPath()
×
NEW
112
        const cliPath = getDesktopCliPath(platform)
×
NEW
113
        const cliInfo = await getCliInfo(cliPath)
×
NEW
114
        createMarkerForDesktopInstall(
×
115
          cliInfo.version ?? 'unknown',
×
116
          platform === 'win32' ? undefined : bundledPath,
×
117
          marker.cli_checksum
118
        )
119
      }
120

121
      return { status: 'valid' }
1✔
122
    }
123

124
    // These cases require user interaction - return as-is for renderer to handle
125
    case 'external-cli-found':
126
      log.info('External CLI found - renderer will show issue page')
1✔
127
      return result
1✔
128

129
    case 'symlink-broken':
130
      log.info('Symlink broken - renderer will show issue page')
1✔
131
      return result
1✔
132

133
    case 'symlink-tampered':
134
      log.info('Symlink tampered - renderer will show issue page')
1✔
135
      return result
1✔
136

137
    // These cases can be auto-fixed without user interaction
138
    case 'symlink-missing':
139
    case 'fresh-install': {
140
      log.info('Performing fresh CLI installation...')
3✔
141

142
      const symlinkResult = createSymlink(platform)
3✔
143
      if (!symlinkResult.success) {
3✔
144
        log.error(`Failed to create CLI symlink: ${symlinkResult.error}`)
1✔
145
        // Return a special error status - the app can still run
146
        return result
1✔
147
      }
148

149
      const bundledPath = getBundledCliPath()
2✔
150
      const cliPath = getDesktopCliPath(platform)
2✔
151
      const cliInfo = await getCliInfo(cliPath)
2✔
152

153
      createMarkerForDesktopInstall(
2✔
154
        cliInfo.version ?? 'unknown',
2!
155
        platform === 'win32' ? undefined : bundledPath,
2!
156
        symlinkResult.checksum
157
      )
158

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

161
      if (platform !== 'win32') {
3✔
162
        const pathResult = await configureShellPath()
2✔
163
        if (!pathResult.success) {
2!
NEW
164
          log.warn(
×
165
            'Failed to configure shell PATH, user may need to add manually'
166
          )
167
        }
168
      }
169

170
      log.info('Fresh CLI installation completed successfully')
2✔
171
      return { status: 'valid' }
2✔
172
    }
173
  }
174
}
175

176
/**
177
 * Repairs a broken or tampered symlink.
178
 * Called from renderer via IPC when user confirms repair.
179
 */
180
export async function repairCliSymlink(
181
  platform: Platform = process.platform as Platform
2✔
182
): Promise<{ success: boolean; error?: string }> {
183
  log.info('Repairing CLI symlink...')
2✔
184

185
  const result = repairSymlink(platform)
2✔
186
  if (!result.success) {
2✔
187
    log.error(`Failed to repair symlink: ${result.error}`)
1✔
188
    return result
1✔
189
  }
190

191
  // Update marker file after repair
192
  const bundledPath = getBundledCliPath()
1✔
193
  const cliPath = getDesktopCliPath(platform)
1✔
194
  const cliInfo = await getCliInfo(cliPath)
1✔
195
  createMarkerForDesktopInstall(
1✔
196
    cliInfo.version ?? 'unknown',
1!
197
    platform === 'win32' ? undefined : bundledPath,
1!
198
    result.checksum
199
  )
200

201
  log.info('Symlink repaired successfully')
2✔
202
  return { success: true }
2✔
203
}
204

205
export async function getCliAlignmentStatus(
206
  platform: Platform = process.platform as Platform
2✔
207
): Promise<CliAlignmentStatus> {
208
  const cliPath = getDesktopCliPath(platform)
2✔
209
  const marker = readMarkerFile()
2✔
210
  const symlink = checkSymlink(platform)
2✔
211
  const cliInfo = await getCliInfo(cliPath)
2✔
212

213
  return {
2✔
214
    isManaged: marker !== null && symlink.isOurBinary,
3✔
215
    cliPath,
216
    cliVersion: cliInfo.version,
217
    installMethod: marker?.install_method ?? null,
3✔
218
    symlinkTarget: symlink.target,
219
    isValid: symlink.exists && symlink.targetExists && symlink.isOurBinary,
4✔
220
    lastValidated: new Date().toISOString(),
221
  }
222
}
223

224
export async function reinstallCliSymlink(
225
  platform: Platform = process.platform as Platform
×
226
): Promise<{ success: boolean; error?: string }> {
NEW
227
  const result = createSymlink(platform)
×
228

NEW
229
  if (result.success) {
×
NEW
230
    const bundledPath = getBundledCliPath()
×
NEW
231
    const cliInfo = await getCliInfo(bundledPath)
×
NEW
232
    createMarkerForDesktopInstall(
×
233
      cliInfo.version ?? 'unknown',
×
234
      platform === 'win32' ? undefined : bundledPath,
×
235
      result.checksum
236
    )
237
  }
238

NEW
239
  return result
×
240
}
241

242
export async function removeCliInstallation(
243
  platform: Platform = process.platform as Platform
×
244
): Promise<{ success: boolean; error?: string }> {
NEW
245
  const symlinkResult = removeSymlink(platform)
×
NEW
246
  if (!symlinkResult.success) {
×
NEW
247
    return symlinkResult
×
248
  }
249

NEW
250
  deleteMarkerFile()
×
251

NEW
252
  if (platform !== 'win32') {
×
NEW
253
    await removeShellPath()
×
254
  }
255

NEW
256
  return { success: true }
×
257
}
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