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

stacklok / toolhive-studio / 21369282030

26 Jan 2026 06:31PM UTC coverage: 51.87% (-1.5%) from 53.322%
21369282030

Pull #1513

github

web-flow
Merge d27e5ef09 into 0c7b4231b
Pull Request #1513: feat(cli): add CLI alignment flow

2249 of 4547 branches covered (49.46%)

Branch coverage included in aggregate %.

195 of 522 new or added lines in 12 files covered. (37.36%)

2 existing lines in 2 files now uncovered.

3548 of 6629 relevant lines covered (53.52%)

120.57 hits per line

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

69.87
/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
62
  const pathStatus = await checkPathConfiguration()
1✔
63
  if (!pathStatus.isConfigured) {
1!
NEW
64
    log.info('PATH not configured, configuring now...')
×
NEW
65
    const pathResult = await configureShellPath()
×
NEW
66
    if (!pathResult.success) {
×
NEW
67
      log.warn('Failed to configure PATH, user may need to add manually')
×
68
    }
69
  }
70

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

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

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

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

119
      return { status: 'valid' }
1✔
120
    }
121

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

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

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

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

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

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

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

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

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

166
      log.info('Fresh CLI installation completed successfully')
2✔
167
      return { status: 'valid' }
2✔
168
    }
169
  }
170
}
171

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

181
  const result = repairSymlink(platform)
2✔
182
  if (!result.success) {
2✔
183
    log.error(`Failed to repair symlink: ${result.error}`)
1✔
184
    return result
1✔
185
  }
186

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

197
  log.info('Symlink repaired successfully')
2✔
198
  return { success: true }
2✔
199
}
200

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

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

220
export async function reinstallCliSymlink(
221
  platform: Platform = process.platform as Platform
×
222
): Promise<{ success: boolean; error?: string }> {
NEW
223
  const result = createSymlink(platform)
×
224

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

NEW
235
  return result
×
236
}
237

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

NEW
246
  deleteMarkerFile()
×
247

NEW
248
  await removeShellPath()
×
249

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