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

stacklok / toolhive-studio / 21260606032

22 Jan 2026 06:42PM UTC coverage: 52.783% (-0.5%) from 53.264%
21260606032

Pull #1513

github

web-flow
Merge ea2e08afa into 408218d23
Pull Request #1513: feat(cli): add CLI alignment flow

2257 of 4486 branches covered (50.31%)

Branch coverage included in aggregate %.

204 of 417 new or added lines in 9 files covered. (48.92%)

203 existing lines in 4 files now uncovered.

3557 of 6529 relevant lines covered (54.48%)

122.36 hits per line

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

66.49
/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 {
22
  showExternalCliDialog,
23
  showSymlinkBrokenDialog,
24
  showSymlinkTamperedDialog,
25
} from './dialogs'
26
import { getDesktopCliPath } from './constants'
27
import { deleteMarkerFile } from './marker-file'
28
import type { ValidationResult, CliAlignmentStatus, Platform } from './types'
29
import log from '../logger'
30

31
export async function validateCliAlignment(
32
  platform: Platform = process.platform as Platform
6✔
33
): Promise<ValidationResult> {
34
  log.info('Starting CLI alignment validation...')
6✔
35

36
  const external = await detectExternalCli(platform)
6✔
37
  if (external) {
6✔
38
    log.warn(`External CLI found at: ${external.path}`)
1✔
39
    return { status: 'external-cli-found', cli: external }
1✔
40
  }
41

42
  const marker = readMarkerFile()
5✔
43

44
  if (!marker) {
5✔
45
    log.info('No marker file found, treating as fresh install')
1✔
46
    return { status: 'fresh-install' }
1✔
47
  }
48

49
  const symlink = checkSymlink(platform)
4✔
50

51
  if (!symlink.exists) {
4✔
52
    log.warn('CLI alignment issue: symlink-missing')
1✔
53
    return { status: 'symlink-missing' }
1✔
54
  }
55

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

61
  if (!symlink.isOurBinary) {
2✔
62
    log.warn('CLI alignment issue: symlink-tampered')
1✔
63
    return { status: 'symlink-tampered', target: symlink.target ?? 'unknown' }
1!
64
  }
65

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

78
  log.info('CLI alignment validation passed')
1✔
79
  return { status: 'valid' }
1✔
80
}
81

82
/** Returns true if the app can proceed, false if it should quit. */
83
export async function handleValidationResult(
84
  result: ValidationResult,
85
  platform: Platform = process.platform as Platform
8✔
86
): Promise<boolean> {
87
  switch (result.status) {
8✔
88
    case 'valid': {
89
      log.info('CLI alignment is valid')
1✔
90

91
      // Update marker file if desktop version changed (app was updated) or cli_version is unknown
92
      const marker = readMarkerFile()
1✔
93
      const currentDesktopVersion = app.getVersion()
1✔
94
      const needsUpdate =
95
        marker &&
1✔
96
        (marker.desktop_version !== currentDesktopVersion ||
97
          marker.cli_version === 'unknown')
98

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

113
      return true
1✔
114
    }
115

116
    case 'external-cli-found':
117
      showExternalCliDialog(result.cli)
1✔
118
      app.quit()
1✔
119
      return false
1✔
120

121
    case 'symlink-broken': {
122
      const shouldRepair = showSymlinkBrokenDialog(result.target)
2✔
123
      if (!shouldRepair) {
2✔
124
        app.quit()
1✔
125
        return false
1✔
126
      }
127

128
      const repairResult = repairSymlink(platform)
1✔
129
      if (!repairResult.success) {
1!
NEW
130
        log.error(`Failed to repair symlink: ${repairResult.error}`)
×
NEW
131
        app.quit()
×
NEW
132
        return false
×
133
      }
134

135
      // Update marker file after repair
136
      const bundledPath = getBundledCliPath()
1✔
137
      const cliPath = getDesktopCliPath(platform)
1✔
138
      const cliInfo = await getCliInfo(cliPath)
1✔
139
      createMarkerForDesktopInstall(
1✔
140
        cliInfo.version ?? 'unknown',
1!
141
        platform === 'win32' ? undefined : bundledPath,
1!
142
        repairResult.checksum
143
      )
144

145
      log.info('Symlink repaired successfully')
2✔
146
      return true
2✔
147
    }
148

149
    case 'symlink-tampered': {
150
      const shouldFix = showSymlinkTamperedDialog(result.target)
1✔
151
      if (!shouldFix) {
1!
NEW
152
        app.quit()
×
NEW
153
        return false
×
154
      }
155

156
      const fixResult = repairSymlink(platform)
1✔
157
      if (!fixResult.success) {
1!
NEW
158
        log.error(`Failed to fix symlink: ${fixResult.error}`)
×
NEW
159
        app.quit()
×
NEW
160
        return false
×
161
      }
162

163
      // Update marker file after fix
164
      const bundledPathTampered = getBundledCliPath()
1✔
165
      const cliPathTampered = getDesktopCliPath(platform)
1✔
166
      const cliInfoTampered = await getCliInfo(cliPathTampered)
1✔
167
      createMarkerForDesktopInstall(
1✔
168
        cliInfoTampered.version ?? 'unknown',
1!
169
        platform === 'win32' ? undefined : bundledPathTampered,
1!
170
        fixResult.checksum
171
      )
172

173
      log.info('Symlink fixed successfully')
1✔
174
      return true
1✔
175
    }
176

177
    case 'symlink-missing':
178
    case 'fresh-install': {
179
      log.info('Performing fresh CLI installation...')
3✔
180

181
      const symlinkResult = createSymlink(platform)
3✔
182
      if (!symlinkResult.success) {
3✔
183
        log.error(`Failed to create CLI symlink: ${symlinkResult.error}`)
1✔
184
        app.quit()
1✔
185
        return false
1✔
186
      }
187

188
      const bundledPath = getBundledCliPath()
2✔
189
      const cliPath = getDesktopCliPath(platform)
2✔
190
      const cliInfo = await getCliInfo(cliPath)
2✔
191

192
      createMarkerForDesktopInstall(
2✔
193
        cliInfo.version ?? 'unknown',
2!
194
        platform === 'win32' ? undefined : bundledPath,
2!
195
        symlinkResult.checksum
196
      )
197

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

200
      if (platform !== 'win32') {
3✔
201
        const pathResult = await configureShellPath()
2✔
202
        if (!pathResult.success) {
2!
NEW
203
          log.warn(
×
204
            'Failed to configure shell PATH, user may need to add manually'
205
          )
206
        }
207
      }
208

209
      log.info('Fresh CLI installation completed successfully')
2✔
210
      return true
2✔
211
    }
212
  }
213
}
214

215
export async function getCliAlignmentStatus(
216
  platform: Platform = process.platform as Platform
2✔
217
): Promise<CliAlignmentStatus> {
218
  const cliPath = getDesktopCliPath(platform)
2✔
219
  const marker = readMarkerFile()
2✔
220
  const symlink = checkSymlink(platform)
2✔
221
  const cliInfo = await getCliInfo(cliPath)
2✔
222

223
  return {
2✔
224
    isManaged: marker !== null && symlink.isOurBinary,
3✔
225
    cliPath,
226
    cliVersion: cliInfo.version,
227
    installMethod: marker?.install_method ?? null,
3✔
228
    symlinkTarget: symlink.target,
229
    isValid: symlink.exists && symlink.targetExists && symlink.isOurBinary,
4✔
230
    lastValidated: new Date().toISOString(),
231
  }
232
}
233

234
export async function reinstallCliSymlink(
235
  platform: Platform = process.platform as Platform
×
236
): Promise<{ success: boolean; error?: string }> {
NEW
237
  const result = createSymlink(platform)
×
238

NEW
239
  if (result.success) {
×
NEW
240
    const bundledPath = getBundledCliPath()
×
NEW
241
    const cliInfo = await getCliInfo(bundledPath)
×
NEW
242
    createMarkerForDesktopInstall(
×
243
      cliInfo.version ?? 'unknown',
×
244
      platform === 'win32' ? undefined : bundledPath,
×
245
      result.checksum
246
    )
247
  }
248

NEW
249
  return result
×
250
}
251

252
export async function removeCliInstallation(
253
  platform: Platform = process.platform as Platform
×
254
): Promise<{ success: boolean; error?: string }> {
NEW
255
  const symlinkResult = removeSymlink(platform)
×
NEW
256
  if (!symlinkResult.success) {
×
NEW
257
    return symlinkResult
×
258
  }
259

NEW
260
  deleteMarkerFile()
×
261

NEW
262
  if (platform !== 'win32') {
×
NEW
263
    await removeShellPath()
×
264
  }
265

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