/ utils / background / remote / preconditions.ts
preconditions.ts
  1  import axios from 'axios'
  2  import { getOauthConfig } from 'src/constants/oauth.js'
  3  import { getOrganizationUUID } from 'src/services/oauth/client.js'
  4  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js'
  5  import {
  6    checkAndRefreshOAuthTokenIfNeeded,
  7    getClaudeAIOAuthTokens,
  8    isClaudeAISubscriber,
  9  } from '../../auth.js'
 10  import { getCwd } from '../../cwd.js'
 11  import { logForDebugging } from '../../debug.js'
 12  import { detectCurrentRepository } from '../../detectRepository.js'
 13  import { errorMessage } from '../../errors.js'
 14  import { findGitRoot, getIsClean } from '../../git.js'
 15  import { getOAuthHeaders } from '../../teleport/api.js'
 16  import { fetchEnvironments } from '../../teleport/environments.js'
 17  
 18  /**
 19   * Checks if user needs to log in with Claude.ai
 20   * Extracted from getTeleportErrors() in TeleportError.tsx
 21   * @returns true if login is required, false otherwise
 22   */
 23  export async function checkNeedsClaudeAiLogin(): Promise<boolean> {
 24    if (!isClaudeAISubscriber()) {
 25      return false
 26    }
 27    return checkAndRefreshOAuthTokenIfNeeded()
 28  }
 29  
 30  /**
 31   * Checks if git working directory is clean (no uncommitted changes)
 32   * Ignores untracked files since they won't be lost during branch switching
 33   * Extracted from getTeleportErrors() in TeleportError.tsx
 34   * @returns true if git is clean, false otherwise
 35   */
 36  export async function checkIsGitClean(): Promise<boolean> {
 37    const isClean = await getIsClean({ ignoreUntracked: true })
 38    return isClean
 39  }
 40  
 41  /**
 42   * Checks if user has access to at least one remote environment
 43   * @returns true if user has remote environments, false otherwise
 44   */
 45  export async function checkHasRemoteEnvironment(): Promise<boolean> {
 46    try {
 47      const environments = await fetchEnvironments()
 48      return environments.length > 0
 49    } catch (error) {
 50      logForDebugging(`checkHasRemoteEnvironment failed: ${errorMessage(error)}`)
 51      return false
 52    }
 53  }
 54  
 55  /**
 56   * Checks if current directory is inside a git repository (has .git/).
 57   * Distinct from checkHasGitRemote — a local-only repo passes this but not that.
 58   */
 59  export function checkIsInGitRepo(): boolean {
 60    return findGitRoot(getCwd()) !== null
 61  }
 62  
 63  /**
 64   * Checks if current repository has a GitHub remote configured.
 65   * Returns false for local-only repos (git init with no `origin`).
 66   */
 67  export async function checkHasGitRemote(): Promise<boolean> {
 68    const repository = await detectCurrentRepository()
 69    return repository !== null
 70  }
 71  
 72  /**
 73   * Checks if GitHub app is installed on a specific repository
 74   * @param owner The repository owner (e.g., "anthropics")
 75   * @param repo The repository name (e.g., "claude-cli-internal")
 76   * @returns true if GitHub app is installed, false otherwise
 77   */
 78  export async function checkGithubAppInstalled(
 79    owner: string,
 80    repo: string,
 81    signal?: AbortSignal,
 82  ): Promise<boolean> {
 83    try {
 84      const accessToken = getClaudeAIOAuthTokens()?.accessToken
 85      if (!accessToken) {
 86        logForDebugging(
 87          'checkGithubAppInstalled: No access token found, assuming app not installed',
 88        )
 89        return false
 90      }
 91  
 92      const orgUUID = await getOrganizationUUID()
 93      if (!orgUUID) {
 94        logForDebugging(
 95          'checkGithubAppInstalled: No org UUID found, assuming app not installed',
 96        )
 97        return false
 98      }
 99  
100      const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/code/repos/${owner}/${repo}`
101      const headers = {
102        ...getOAuthHeaders(accessToken),
103        'x-organization-uuid': orgUUID,
104      }
105  
106      logForDebugging(`Checking GitHub app installation for ${owner}/${repo}`)
107  
108      const response = await axios.get<{
109        repo: {
110          name: string
111          owner: { login: string }
112          default_branch: string
113        }
114        status: {
115          app_installed: boolean
116          relay_enabled: boolean
117        } | null
118      }>(url, {
119        headers,
120        timeout: 15000,
121        signal,
122      })
123  
124      if (response.status === 200) {
125        if (response.data.status) {
126          const installed = response.data.status.app_installed
127          logForDebugging(
128            `GitHub app ${installed ? 'is' : 'is not'} installed on ${owner}/${repo}`,
129          )
130          return installed
131        }
132        // status is null - app is not installed on this repo
133        logForDebugging(
134          `GitHub app is not installed on ${owner}/${repo} (status is null)`,
135        )
136        return false
137      }
138  
139      logForDebugging(
140        `checkGithubAppInstalled: Unexpected response status ${response.status}`,
141      )
142      return false
143    } catch (error) {
144      // 4XX errors typically mean app is not installed or repo not accessible
145      if (axios.isAxiosError(error)) {
146        const status = error.response?.status
147        if (status && status >= 400 && status < 500) {
148          logForDebugging(
149            `checkGithubAppInstalled: Got ${status} error, app likely not installed on ${owner}/${repo}`,
150          )
151          return false
152        }
153      }
154  
155      logForDebugging(`checkGithubAppInstalled error: ${errorMessage(error)}`)
156      return false
157    }
158  }
159  
160  /**
161   * Checks if the user has synced their GitHub credentials via /web-setup
162   * @returns true if GitHub token is synced, false otherwise
163   */
164  export async function checkGithubTokenSynced(): Promise<boolean> {
165    try {
166      const accessToken = getClaudeAIOAuthTokens()?.accessToken
167      if (!accessToken) {
168        logForDebugging('checkGithubTokenSynced: No access token found')
169        return false
170      }
171  
172      const orgUUID = await getOrganizationUUID()
173      if (!orgUUID) {
174        logForDebugging('checkGithubTokenSynced: No org UUID found')
175        return false
176      }
177  
178      const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/sync/github/auth`
179      const headers = {
180        ...getOAuthHeaders(accessToken),
181        'x-organization-uuid': orgUUID,
182      }
183  
184      logForDebugging('Checking if GitHub token is synced via web-setup')
185  
186      const response = await axios.get(url, {
187        headers,
188        timeout: 15000,
189      })
190  
191      const synced =
192        response.status === 200 && response.data?.is_authenticated === true
193      logForDebugging(
194        `GitHub token synced: ${synced} (status=${response.status}, data=${JSON.stringify(response.data)})`,
195      )
196      return synced
197    } catch (error) {
198      if (axios.isAxiosError(error)) {
199        const status = error.response?.status
200        if (status && status >= 400 && status < 500) {
201          logForDebugging(
202            `checkGithubTokenSynced: Got ${status}, token not synced`,
203          )
204          return false
205        }
206      }
207  
208      logForDebugging(`checkGithubTokenSynced error: ${errorMessage(error)}`)
209      return false
210    }
211  }
212  
213  type RepoAccessMethod = 'github-app' | 'token-sync' | 'none'
214  
215  /**
216   * Tiered check for whether a GitHub repo is accessible for remote operations.
217   * 1. GitHub App installed on the repo
218   * 2. GitHub token synced via /web-setup
219   * 3. Neither — caller should prompt user to set up access
220   */
221  export async function checkRepoForRemoteAccess(
222    owner: string,
223    repo: string,
224  ): Promise<{ hasAccess: boolean; method: RepoAccessMethod }> {
225    if (await checkGithubAppInstalled(owner, repo)) {
226      return { hasAccess: true, method: 'github-app' }
227    }
228    if (
229      getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_lantern', false) &&
230      (await checkGithubTokenSynced())
231    ) {
232      return { hasAccess: true, method: 'token-sync' }
233    }
234    return { hasAccess: false, method: 'none' }
235  }