/ utils / plugins / officialMarketplaceStartupCheck.ts
officialMarketplaceStartupCheck.ts
  1  /**
  2   * Auto-install logic for the official Anthropic marketplace.
  3   *
  4   * This module handles automatically installing the official marketplace
  5   * on startup for new users, with appropriate checks for:
  6   * - Enterprise policy restrictions
  7   * - Git availability
  8   * - Previous installation attempts
  9   */
 10  
 11  import { join } from 'path'
 12  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
 13  import { logEvent } from '../../services/analytics/index.js'
 14  import { getGlobalConfig, saveGlobalConfig } from '../config.js'
 15  import { logForDebugging } from '../debug.js'
 16  import { isEnvTruthy } from '../envUtils.js'
 17  import { toError } from '../errors.js'
 18  import { logError } from '../log.js'
 19  import { checkGitAvailable, markGitUnavailable } from './gitAvailability.js'
 20  import { isSourceAllowedByPolicy } from './marketplaceHelpers.js'
 21  import {
 22    addMarketplaceSource,
 23    getMarketplacesCacheDir,
 24    loadKnownMarketplacesConfig,
 25    saveKnownMarketplacesConfig,
 26  } from './marketplaceManager.js'
 27  import {
 28    OFFICIAL_MARKETPLACE_NAME,
 29    OFFICIAL_MARKETPLACE_SOURCE,
 30  } from './officialMarketplace.js'
 31  import { fetchOfficialMarketplaceFromGcs } from './officialMarketplaceGcs.js'
 32  
 33  /**
 34   * Reason why the official marketplace was not installed
 35   */
 36  export type OfficialMarketplaceSkipReason =
 37    | 'already_attempted'
 38    | 'already_installed'
 39    | 'policy_blocked'
 40    | 'git_unavailable'
 41    | 'gcs_unavailable'
 42    | 'unknown'
 43  
 44  /**
 45   * Check if official marketplace auto-install is disabled via environment variable.
 46   */
 47  export function isOfficialMarketplaceAutoInstallDisabled(): boolean {
 48    return isEnvTruthy(
 49      process.env.CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL,
 50    )
 51  }
 52  
 53  /**
 54   * Configuration for retry logic
 55   */
 56  export const RETRY_CONFIG = {
 57    MAX_ATTEMPTS: 10,
 58    INITIAL_DELAY_MS: 60 * 60 * 1000, // 1 hour
 59    BACKOFF_MULTIPLIER: 2,
 60    MAX_DELAY_MS: 7 * 24 * 60 * 60 * 1000, // 1 week
 61  }
 62  
 63  /**
 64   * Calculate next retry delay using exponential backoff
 65   */
 66  function calculateNextRetryDelay(retryCount: number): number {
 67    const delay =
 68      RETRY_CONFIG.INITIAL_DELAY_MS *
 69      Math.pow(RETRY_CONFIG.BACKOFF_MULTIPLIER, retryCount)
 70    return Math.min(delay, RETRY_CONFIG.MAX_DELAY_MS)
 71  }
 72  
 73  /**
 74   * Determine if installation should be retried based on failure reason and retry state
 75   */
 76  function shouldRetryInstallation(
 77    config: ReturnType<typeof getGlobalConfig>,
 78  ): boolean {
 79    // If never attempted, should try
 80    if (!config.officialMarketplaceAutoInstallAttempted) {
 81      return true
 82    }
 83  
 84    // If already installed successfully, don't retry
 85    if (config.officialMarketplaceAutoInstalled) {
 86      return false
 87    }
 88  
 89    const failReason = config.officialMarketplaceAutoInstallFailReason
 90    const retryCount = config.officialMarketplaceAutoInstallRetryCount || 0
 91    const nextRetryTime = config.officialMarketplaceAutoInstallNextRetryTime
 92    const now = Date.now()
 93  
 94    // Check if we've exceeded max attempts
 95    if (retryCount >= RETRY_CONFIG.MAX_ATTEMPTS) {
 96      return false
 97    }
 98  
 99    // Permanent failures - don't retry
100    if (failReason === 'policy_blocked') {
101      return false
102    }
103  
104    // Check if enough time has passed for next retry
105    if (nextRetryTime && now < nextRetryTime) {
106      return false
107    }
108  
109    // Retry for temporary failures (unknown), semi-permanent (git_unavailable),
110    // and legacy state (undefined failReason from before retry logic existed)
111    return (
112      failReason === 'unknown' ||
113      failReason === 'git_unavailable' ||
114      failReason === 'gcs_unavailable' ||
115      failReason === undefined
116    )
117  }
118  
119  /**
120   * Result of the auto-install check
121   */
122  export type OfficialMarketplaceCheckResult = {
123    /** Whether the marketplace was successfully installed */
124    installed: boolean
125    /** Whether the installation was skipped (and why) */
126    skipped: boolean
127    /** Reason for skipping, if applicable */
128    reason?: OfficialMarketplaceSkipReason
129    /** Whether saving retry metadata to config failed */
130    configSaveFailed?: boolean
131  }
132  
133  /**
134   * Check and install the official marketplace on startup.
135   *
136   * This function is designed to be called as a fire-and-forget operation
137   * during startup. It will:
138   * 1. Check if installation was already attempted
139   * 2. Check if marketplace is already installed
140   * 3. Check enterprise policy restrictions
141   * 4. Check git availability
142   * 5. Attempt installation
143   * 6. Record the result in GlobalConfig
144   *
145   * @returns Result indicating whether installation succeeded or was skipped
146   */
147  export async function checkAndInstallOfficialMarketplace(): Promise<OfficialMarketplaceCheckResult> {
148    const config = getGlobalConfig()
149  
150    // Check if we should retry installation
151    if (!shouldRetryInstallation(config)) {
152      const reason: OfficialMarketplaceSkipReason =
153        config.officialMarketplaceAutoInstallFailReason ?? 'already_attempted'
154      logForDebugging(`Official marketplace auto-install skipped: ${reason}`)
155      return {
156        installed: false,
157        skipped: true,
158        reason,
159      }
160    }
161  
162    try {
163      // Check if auto-install is disabled via env var
164      if (isOfficialMarketplaceAutoInstallDisabled()) {
165        logForDebugging(
166          'Official marketplace auto-install disabled via env var, skipping',
167        )
168        saveGlobalConfig(current => ({
169          ...current,
170          officialMarketplaceAutoInstallAttempted: true,
171          officialMarketplaceAutoInstalled: false,
172          officialMarketplaceAutoInstallFailReason: 'policy_blocked',
173        }))
174        logEvent('tengu_official_marketplace_auto_install', {
175          installed: false,
176          skipped: true,
177          policy_blocked: true,
178        })
179        return { installed: false, skipped: true, reason: 'policy_blocked' }
180      }
181  
182      // Check if marketplace is already installed
183      const knownMarketplaces = await loadKnownMarketplacesConfig()
184      if (knownMarketplaces[OFFICIAL_MARKETPLACE_NAME]) {
185        logForDebugging(
186          `Official marketplace '${OFFICIAL_MARKETPLACE_NAME}' already installed, skipping`,
187        )
188        // Mark as attempted so we don't check again
189        saveGlobalConfig(current => ({
190          ...current,
191          officialMarketplaceAutoInstallAttempted: true,
192          officialMarketplaceAutoInstalled: true,
193        }))
194        return { installed: false, skipped: true, reason: 'already_installed' }
195      }
196  
197      // Check enterprise policy restrictions
198      if (!isSourceAllowedByPolicy(OFFICIAL_MARKETPLACE_SOURCE)) {
199        logForDebugging(
200          'Official marketplace blocked by enterprise policy, skipping',
201        )
202        saveGlobalConfig(current => ({
203          ...current,
204          officialMarketplaceAutoInstallAttempted: true,
205          officialMarketplaceAutoInstalled: false,
206          officialMarketplaceAutoInstallFailReason: 'policy_blocked',
207        }))
208        logEvent('tengu_official_marketplace_auto_install', {
209          installed: false,
210          skipped: true,
211          policy_blocked: true,
212        })
213        return { installed: false, skipped: true, reason: 'policy_blocked' }
214      }
215  
216      // inc-5046: try GCS mirror first — doesn't need git, doesn't hit GitHub.
217      // Backend (anthropic#317037) publishes a marketplace zip to the same
218      // bucket as the native binary. If GCS succeeds, register the marketplace
219      // with source:'github' (still true — GCS is a mirror) and skip git
220      // entirely.
221      const cacheDir = getMarketplacesCacheDir()
222      const installLocation = join(cacheDir, OFFICIAL_MARKETPLACE_NAME)
223      const gcsSha = await fetchOfficialMarketplaceFromGcs(
224        installLocation,
225        cacheDir,
226      )
227      if (gcsSha !== null) {
228        const known = await loadKnownMarketplacesConfig()
229        known[OFFICIAL_MARKETPLACE_NAME] = {
230          source: OFFICIAL_MARKETPLACE_SOURCE,
231          installLocation,
232          lastUpdated: new Date().toISOString(),
233        }
234        await saveKnownMarketplacesConfig(known)
235  
236        saveGlobalConfig(current => ({
237          ...current,
238          officialMarketplaceAutoInstallAttempted: true,
239          officialMarketplaceAutoInstalled: true,
240          officialMarketplaceAutoInstallFailReason: undefined,
241          officialMarketplaceAutoInstallRetryCount: undefined,
242          officialMarketplaceAutoInstallLastAttemptTime: undefined,
243          officialMarketplaceAutoInstallNextRetryTime: undefined,
244        }))
245        logEvent('tengu_official_marketplace_auto_install', {
246          installed: true,
247          skipped: false,
248          via_gcs: true,
249        })
250        return { installed: true, skipped: false }
251      }
252      // GCS failed (404 until backend writes, or network). Fall through to git
253      // ONLY if the kill-switch allows — same gate as refreshMarketplace().
254      if (
255        !getFeatureValue_CACHED_MAY_BE_STALE(
256          'tengu_plugin_official_mkt_git_fallback',
257          true,
258        )
259      ) {
260        logForDebugging(
261          'Official marketplace GCS failed; git fallback disabled by flag — skipping install',
262        )
263        // Same retry-with-backoff metadata as git_unavailable below — transient
264        // GCS failures should retry with exponential backoff, not give up.
265        const retryCount =
266          (config.officialMarketplaceAutoInstallRetryCount || 0) + 1
267        const now = Date.now()
268        const nextRetryTime = now + calculateNextRetryDelay(retryCount)
269        saveGlobalConfig(current => ({
270          ...current,
271          officialMarketplaceAutoInstallAttempted: true,
272          officialMarketplaceAutoInstalled: false,
273          officialMarketplaceAutoInstallFailReason: 'gcs_unavailable',
274          officialMarketplaceAutoInstallRetryCount: retryCount,
275          officialMarketplaceAutoInstallLastAttemptTime: now,
276          officialMarketplaceAutoInstallNextRetryTime: nextRetryTime,
277        }))
278        logEvent('tengu_official_marketplace_auto_install', {
279          installed: false,
280          skipped: true,
281          gcs_unavailable: true,
282          retry_count: retryCount,
283        })
284        return { installed: false, skipped: true, reason: 'gcs_unavailable' }
285      }
286  
287      // Check git availability
288      const gitAvailable = await checkGitAvailable()
289      if (!gitAvailable) {
290        logForDebugging(
291          'Git not available, skipping official marketplace auto-install',
292        )
293        const retryCount =
294          (config.officialMarketplaceAutoInstallRetryCount || 0) + 1
295        const now = Date.now()
296        const nextRetryDelay = calculateNextRetryDelay(retryCount)
297        const nextRetryTime = now + nextRetryDelay
298  
299        let configSaveFailed = false
300        try {
301          saveGlobalConfig(current => ({
302            ...current,
303            officialMarketplaceAutoInstallAttempted: true,
304            officialMarketplaceAutoInstalled: false,
305            officialMarketplaceAutoInstallFailReason: 'git_unavailable',
306            officialMarketplaceAutoInstallRetryCount: retryCount,
307            officialMarketplaceAutoInstallLastAttemptTime: now,
308            officialMarketplaceAutoInstallNextRetryTime: nextRetryTime,
309          }))
310        } catch (saveError) {
311          configSaveFailed = true
312          // Log the error properly so it gets tracked
313          const configError = toError(saveError)
314          logError(configError)
315  
316          logForDebugging(
317            `Failed to save marketplace auto-install git_unavailable state: ${saveError}`,
318            { level: 'error' },
319          )
320        }
321        logEvent('tengu_official_marketplace_auto_install', {
322          installed: false,
323          skipped: true,
324          git_unavailable: true,
325          retry_count: retryCount,
326        })
327        return {
328          installed: false,
329          skipped: true,
330          reason: 'git_unavailable',
331          configSaveFailed,
332        }
333      }
334  
335      // Attempt installation
336      logForDebugging('Attempting to auto-install official marketplace')
337      await addMarketplaceSource(OFFICIAL_MARKETPLACE_SOURCE)
338  
339      // Success
340      logForDebugging('Successfully auto-installed official marketplace')
341      const previousRetryCount =
342        config.officialMarketplaceAutoInstallRetryCount || 0
343      saveGlobalConfig(current => ({
344        ...current,
345        officialMarketplaceAutoInstallAttempted: true,
346        officialMarketplaceAutoInstalled: true,
347        // Clear retry metadata on success
348        officialMarketplaceAutoInstallFailReason: undefined,
349        officialMarketplaceAutoInstallRetryCount: undefined,
350        officialMarketplaceAutoInstallLastAttemptTime: undefined,
351        officialMarketplaceAutoInstallNextRetryTime: undefined,
352      }))
353      logEvent('tengu_official_marketplace_auto_install', {
354        installed: true,
355        skipped: false,
356        retry_count: previousRetryCount,
357      })
358      return { installed: true, skipped: false }
359    } catch (error) {
360      // Handle installation failure
361      const errorMessage = error instanceof Error ? error.message : String(error)
362  
363      // On macOS, /usr/bin/git is an xcrun shim that always exists on PATH, so
364      // checkGitAvailable() (which only does `which git`) passes even without
365      // Xcode CLT installed. The shim then fails at clone time with
366      // "xcrun: error: invalid active developer path (...)". Poison the memoized
367      // availability check so other git callers in this session skip cleanly,
368      // then return silently without recording any attempt state — next startup
369      // tries fresh (no backoff machinery for what is effectively "git absent").
370      if (errorMessage.includes('xcrun: error:')) {
371        markGitUnavailable()
372        logForDebugging(
373          'Official marketplace auto-install: git is a non-functional macOS xcrun shim, treating as git_unavailable',
374        )
375        logEvent('tengu_official_marketplace_auto_install', {
376          installed: false,
377          skipped: true,
378          git_unavailable: true,
379          macos_xcrun_shim: true,
380        })
381        return {
382          installed: false,
383          skipped: true,
384          reason: 'git_unavailable',
385        }
386      }
387  
388      logForDebugging(
389        `Failed to auto-install official marketplace: ${errorMessage}`,
390        { level: 'error' },
391      )
392      logError(toError(error))
393  
394      const retryCount =
395        (config.officialMarketplaceAutoInstallRetryCount || 0) + 1
396      const now = Date.now()
397      const nextRetryDelay = calculateNextRetryDelay(retryCount)
398      const nextRetryTime = now + nextRetryDelay
399  
400      let configSaveFailed = false
401      try {
402        saveGlobalConfig(current => ({
403          ...current,
404          officialMarketplaceAutoInstallAttempted: true,
405          officialMarketplaceAutoInstalled: false,
406          officialMarketplaceAutoInstallFailReason: 'unknown',
407          officialMarketplaceAutoInstallRetryCount: retryCount,
408          officialMarketplaceAutoInstallLastAttemptTime: now,
409          officialMarketplaceAutoInstallNextRetryTime: nextRetryTime,
410        }))
411      } catch (saveError) {
412        configSaveFailed = true
413        // Log the error properly so it gets tracked
414        const configError = toError(saveError)
415        logError(configError)
416  
417        logForDebugging(
418          `Failed to save marketplace auto-install failure state: ${saveError}`,
419          { level: 'error' },
420        )
421  
422        // Still return the failure result even if config save failed
423        // This ensures we report the installation failure correctly
424      }
425      logEvent('tengu_official_marketplace_auto_install', {
426        installed: false,
427        skipped: true,
428        failed: true,
429        retry_count: retryCount,
430      })
431  
432      return {
433        installed: false,
434        skipped: true,
435        reason: 'unknown',
436        configSaveFailed,
437      }
438    }
439  }