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

stacklok / toolhive-studio / 21486263419

29 Jan 2026 04:29PM UTC coverage: 54.933% (+1.6%) from 53.322%
21486263419

Pull #1513

github

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

2389 of 4558 branches covered (52.41%)

397 of 525 new or added lines in 17 files covered. (75.62%)

2 existing lines in 2 files now uncovered.

3758 of 6632 relevant lines covered (56.66%)

121.12 hits per line

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

74.15
/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
} from './symlink-manager'
15
import { configureShellPath, checkPathConfiguration } from './path-configurator'
16
import { getDesktopCliPath } from './constants'
17
import type { ValidationResult, CliAlignmentStatus, Platform } from './types'
18
import log from '../logger'
19

20
export async function validateCliAlignment(
21
  platform: Platform = process.platform as Platform
6✔
22
): Promise<ValidationResult> {
23
  log.info('Starting CLI alignment validation...')
6✔
24

25
  const external = await detectExternalCli(platform)
6✔
26
  if (external) {
6✔
27
    log.warn(`External CLI found at: ${external.path}`)
1✔
28
    return { status: 'external-cli-found', cli: external }
1✔
29
  }
30

31
  const marker = readMarkerFile()
5✔
32

33
  if (!marker) {
5✔
34
    log.info('No marker file found, treating as fresh install')
1✔
35
    return { status: 'fresh-install' }
1✔
36
  }
37

38
  const symlink = checkSymlink(platform)
4✔
39

40
  if (!symlink.exists) {
4✔
41
    log.warn('CLI alignment issue: symlink-missing')
1✔
42
    return { status: 'symlink-missing' }
1✔
43
  }
44

45
  if (!symlink.targetExists) {
3✔
46
    log.warn('CLI alignment issue: symlink-broken')
1✔
47
    return { status: 'symlink-broken', target: symlink.target ?? 'unknown' }
1!
48
  }
49

50
  if (!symlink.isOurBinary) {
2✔
51
    log.warn('CLI alignment issue: symlink-tampered')
1✔
52
    return { status: 'symlink-tampered', target: symlink.target ?? 'unknown' }
1!
53
  }
54

55
  // Check and configure PATH if needed
56
  const pathStatus = await checkPathConfiguration()
1✔
57
  if (!pathStatus.isConfigured) {
1!
NEW
58
    log.info('PATH not configured, configuring now...')
×
NEW
59
    const pathResult = await configureShellPath()
×
NEW
60
    if (!pathResult.success) {
×
NEW
61
      log.warn('Failed to configure PATH, user may need to add manually')
×
62
    }
63
  }
64

65
  log.info('CLI alignment validation passed')
1✔
66
  return { status: 'valid' }
1✔
67
}
68

69
/**
70
 * Handles validation results that can be auto-fixed without user interaction.
71
 * Returns the updated validation result after attempting auto-fixes.
72
 *
73
 * Cases handled automatically:
74
 * - valid: Updates marker file if needed
75
 * - fresh-install: Creates symlink and marker
76
 * - symlink-missing: Creates symlink and marker
77
 *
78
 * Cases requiring user interaction (returned as-is for renderer to handle):
79
 * - external-cli-found: User must uninstall external CLI
80
 * - symlink-broken: User must confirm repair
81
 * - symlink-tampered: User must confirm restore
82
 */
83
export async function handleValidationResult(
84
  result: ValidationResult,
85
  platform: Platform = process.platform as Platform
7✔
86
): Promise<ValidationResult> {
87
  switch (result.status) {
7✔
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 { status: 'valid' }
1✔
114
    }
115

116
    // These cases require user interaction - return as-is for renderer to handle
117
    case 'external-cli-found':
118
      log.info('External CLI found - renderer will show issue page')
1✔
119
      return result
1✔
120

121
    case 'symlink-broken':
122
      log.info('Symlink broken - renderer will show issue page')
1✔
123
      return result
1✔
124

125
    case 'symlink-tampered':
126
      log.info('Symlink tampered - renderer will show issue page')
1✔
127
      return result
1✔
128

129
    // These cases can be auto-fixed without user interaction
130
    case 'symlink-missing':
131
    case 'fresh-install': {
132
      log.info('Performing fresh CLI installation...')
3✔
133

134
      const symlinkResult = createSymlink(platform)
3✔
135
      if (!symlinkResult.success) {
3✔
136
        log.error(`Failed to create CLI symlink: ${symlinkResult.error}`)
1✔
137
        // Return a special error status - the app can still run
138
        return result
1✔
139
      }
140

141
      const bundledPath = getBundledCliPath()
2✔
142
      const cliPath = getDesktopCliPath(platform)
2✔
143
      const cliInfo = await getCliInfo(cliPath)
2✔
144

145
      createMarkerForDesktopInstall(
2✔
146
        cliInfo.version ?? 'unknown',
2!
147
        platform === 'win32' ? undefined : bundledPath,
2!
148
        symlinkResult.checksum
149
      )
150

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

153
      const pathResult = await configureShellPath()
3✔
154
      if (!pathResult.success) {
2!
NEW
155
        log.warn(
×
156
          'Failed to configure shell PATH, user may need to add manually'
157
        )
158
      }
159

160
      log.info('Fresh CLI installation completed successfully')
2✔
161
      return { status: 'valid' }
2✔
162
    }
163
  }
164
}
165

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

175
  const result = repairSymlink(platform)
2✔
176
  if (!result.success) {
2✔
177
    log.error(`Failed to repair symlink: ${result.error}`)
1✔
178
    return result
1✔
179
  }
180

181
  // Update marker file after repair
182
  const bundledPath = getBundledCliPath()
1✔
183
  const cliPath = getDesktopCliPath(platform)
1✔
184
  const cliInfo = await getCliInfo(cliPath)
1✔
185
  createMarkerForDesktopInstall(
1✔
186
    cliInfo.version ?? 'unknown',
1!
187
    platform === 'win32' ? undefined : bundledPath,
1!
188
    result.checksum
189
  )
190

191
  log.info('Symlink repaired successfully')
2✔
192
  return { success: true }
2✔
193
}
194

195
export async function getCliAlignmentStatus(
196
  platform: Platform = process.platform as Platform
2✔
197
): Promise<CliAlignmentStatus> {
198
  const cliPath = getDesktopCliPath(platform)
2✔
199
  const marker = readMarkerFile()
2✔
200
  const symlink = checkSymlink(platform)
2✔
201
  const cliInfo = await getCliInfo(cliPath)
2✔
202

203
  return {
2✔
204
    isManaged: marker !== null && symlink.isOurBinary,
3✔
205
    cliPath,
206
    cliVersion: cliInfo.version,
207
    installMethod: marker?.install_method ?? null,
3✔
208
    symlinkTarget: symlink.target,
209
    isValid: symlink.exists && symlink.targetExists && symlink.isOurBinary,
4✔
210
    lastValidated: new Date().toISOString(),
211
  }
212
}
213

214
export async function reinstallCliSymlink(
215
  platform: Platform = process.platform as Platform
×
216
): Promise<{ success: boolean; error?: string }> {
NEW
217
  const result = createSymlink(platform)
×
218

NEW
219
  if (result.success) {
×
NEW
220
    const bundledPath = getBundledCliPath()
×
NEW
221
    const cliInfo = await getCliInfo(bundledPath)
×
NEW
222
    createMarkerForDesktopInstall(
×
223
      cliInfo.version ?? 'unknown',
×
224
      platform === 'win32' ? undefined : bundledPath,
×
225
      result.checksum
226
    )
227
  }
228

NEW
229
  return result
×
230
}
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