/ utils / swarm / backends / it2Setup.ts
it2Setup.ts
  1  import { homedir } from 'os'
  2  import { getGlobalConfig, saveGlobalConfig } from '../../../utils/config.js'
  3  import { logForDebugging } from '../../../utils/debug.js'
  4  import {
  5    execFileNoThrow,
  6    execFileNoThrowWithCwd,
  7  } from '../../../utils/execFileNoThrow.js'
  8  import { logError } from '../../../utils/log.js'
  9  
 10  /**
 11   * Package manager types for installing it2.
 12   * Listed in order of preference.
 13   */
 14  export type PythonPackageManager = 'uvx' | 'pipx' | 'pip'
 15  
 16  /**
 17   * Result of attempting to install it2.
 18   */
 19  export type It2InstallResult = {
 20    success: boolean
 21    error?: string
 22    packageManager?: PythonPackageManager
 23  }
 24  
 25  /**
 26   * Result of verifying it2 setup.
 27   */
 28  export type It2VerifyResult = {
 29    success: boolean
 30    error?: string
 31    needsPythonApiEnabled?: boolean
 32  }
 33  
 34  /**
 35   * Detects which Python package manager is available on the system.
 36   * Checks in order of preference: uvx, pipx, pip.
 37   *
 38   * @returns The detected package manager, or null if none found
 39   */
 40  export async function detectPythonPackageManager(): Promise<PythonPackageManager | null> {
 41    // Check uv first (preferred for isolated environments)
 42    // We check for 'uv' since 'uv tool install' is the install command
 43    const uvResult = await execFileNoThrow('which', ['uv'])
 44    if (uvResult.code === 0) {
 45      logForDebugging('[it2Setup] Found uv (will use uv tool install)')
 46      return 'uvx' // Keep the type name for compatibility
 47    }
 48  
 49    // Check pipx (good for isolated environments)
 50    const pipxResult = await execFileNoThrow('which', ['pipx'])
 51    if (pipxResult.code === 0) {
 52      logForDebugging('[it2Setup] Found pipx package manager')
 53      return 'pipx'
 54    }
 55  
 56    // Check pip (fallback)
 57    const pipResult = await execFileNoThrow('which', ['pip'])
 58    if (pipResult.code === 0) {
 59      logForDebugging('[it2Setup] Found pip package manager')
 60      return 'pip'
 61    }
 62  
 63    // Also check pip3
 64    const pip3Result = await execFileNoThrow('which', ['pip3'])
 65    if (pip3Result.code === 0) {
 66      logForDebugging('[it2Setup] Found pip3 package manager')
 67      return 'pip'
 68    }
 69  
 70    logForDebugging('[it2Setup] No Python package manager found')
 71    return null
 72  }
 73  
 74  /**
 75   * Checks if the it2 CLI tool is installed and accessible.
 76   *
 77   * @returns true if it2 is available
 78   */
 79  export async function isIt2CliAvailable(): Promise<boolean> {
 80    const result = await execFileNoThrow('which', ['it2'])
 81    return result.code === 0
 82  }
 83  
 84  /**
 85   * Installs the it2 CLI tool using the detected package manager.
 86   *
 87   * @param packageManager - The package manager to use for installation
 88   * @returns Result indicating success or failure
 89   */
 90  export async function installIt2(
 91    packageManager: PythonPackageManager,
 92  ): Promise<It2InstallResult> {
 93    logForDebugging(`[it2Setup] Installing it2 using ${packageManager}`)
 94  
 95    // Run from home directory to avoid reading project-level pip.conf/uv.toml
 96    // which could be maliciously crafted to redirect to an attacker's PyPI server
 97    let result
 98    switch (packageManager) {
 99      case 'uvx':
100        // uv tool install it2 installs it globally in isolated env
101        // (uvx is for running, uv tool install is for installing)
102        result = await execFileNoThrowWithCwd('uv', ['tool', 'install', 'it2'], {
103          cwd: homedir(),
104        })
105        break
106      case 'pipx':
107        result = await execFileNoThrowWithCwd('pipx', ['install', 'it2'], {
108          cwd: homedir(),
109        })
110        break
111      case 'pip':
112        // Use --user to install without sudo
113        result = await execFileNoThrowWithCwd(
114          'pip',
115          ['install', '--user', 'it2'],
116          { cwd: homedir() },
117        )
118        if (result.code !== 0) {
119          // Try pip3 if pip fails
120          result = await execFileNoThrowWithCwd(
121            'pip3',
122            ['install', '--user', 'it2'],
123            { cwd: homedir() },
124          )
125        }
126        break
127    }
128  
129    if (result.code !== 0) {
130      const error = result.stderr || 'Unknown installation error'
131      logError(new Error(`[it2Setup] Failed to install it2: ${error}`))
132      return {
133        success: false,
134        error,
135        packageManager,
136      }
137    }
138  
139    logForDebugging('[it2Setup] it2 installed successfully')
140    return {
141      success: true,
142      packageManager,
143    }
144  }
145  
146  /**
147   * Verifies that it2 is properly configured and can communicate with iTerm2.
148   * This tests the Python API connection by running a simple it2 command.
149   *
150   * @returns Result indicating success or the specific failure reason
151   */
152  export async function verifyIt2Setup(): Promise<It2VerifyResult> {
153    logForDebugging('[it2Setup] Verifying it2 setup...')
154  
155    // First check if it2 is installed
156    const installed = await isIt2CliAvailable()
157    if (!installed) {
158      return {
159        success: false,
160        error: 'it2 CLI is not installed or not in PATH',
161      }
162    }
163  
164    // Try to list sessions - this tests the Python API connection
165    const result = await execFileNoThrow('it2', ['session', 'list'])
166  
167    if (result.code !== 0) {
168      const stderr = result.stderr.toLowerCase()
169  
170      // Check for common Python API errors
171      if (
172        stderr.includes('api') ||
173        stderr.includes('python') ||
174        stderr.includes('connection refused') ||
175        stderr.includes('not enabled')
176      ) {
177        logForDebugging('[it2Setup] Python API not enabled in iTerm2')
178        return {
179          success: false,
180          error: 'Python API not enabled in iTerm2 preferences',
181          needsPythonApiEnabled: true,
182        }
183      }
184  
185      return {
186        success: false,
187        error: result.stderr || 'Failed to communicate with iTerm2',
188      }
189    }
190  
191    logForDebugging('[it2Setup] it2 setup verified successfully')
192    return {
193      success: true,
194    }
195  }
196  
197  /**
198   * Returns instructions for enabling the Python API in iTerm2.
199   */
200  export function getPythonApiInstructions(): string[] {
201    return [
202      'Almost done! Enable the Python API in iTerm2:',
203      '',
204      '  iTerm2 → Settings → General → Magic → Enable Python API',
205      '',
206      'After enabling, you may need to restart iTerm2.',
207    ]
208  }
209  
210  /**
211   * Marks that it2 setup has been completed successfully.
212   * This prevents showing the setup prompt again.
213   */
214  export function markIt2SetupComplete(): void {
215    const config = getGlobalConfig()
216    if (config.iterm2It2SetupComplete !== true) {
217      saveGlobalConfig(current => ({
218        ...current,
219        iterm2It2SetupComplete: true,
220      }))
221      logForDebugging('[it2Setup] Marked it2 setup as complete')
222    }
223  }
224  
225  /**
226   * Marks that the user prefers to use tmux over iTerm2 split panes.
227   * This prevents showing the setup prompt when in iTerm2.
228   */
229  export function setPreferTmuxOverIterm2(prefer: boolean): void {
230    const config = getGlobalConfig()
231    if (config.preferTmuxOverIterm2 !== prefer) {
232      saveGlobalConfig(current => ({
233        ...current,
234        preferTmuxOverIterm2: prefer,
235      }))
236      logForDebugging(`[it2Setup] Set preferTmuxOverIterm2 = ${prefer}`)
237    }
238  }
239  
240  /**
241   * Checks if the user prefers tmux over iTerm2 split panes.
242   */
243  export function getPreferTmuxOverIterm2(): boolean {
244    return getGlobalConfig().preferTmuxOverIterm2 === true
245  }