/ utils / user.ts
user.ts
  1  import { execa } from 'execa'
  2  import memoize from 'lodash-es/memoize.js'
  3  import { getSessionId } from '../bootstrap/state.js'
  4  import {
  5    getOauthAccountInfo,
  6    getRateLimitTier,
  7    getSubscriptionType,
  8  } from './auth.js'
  9  import { getGlobalConfig, getOrCreateUserID } from './config.js'
 10  import { getCwd } from './cwd.js'
 11  import { type env, getHostPlatformForAnalytics } from './env.js'
 12  import { isEnvTruthy } from './envUtils.js'
 13  
 14  // Cache for email fetched asynchronously at startup
 15  let cachedEmail: string | undefined | null = null // null means not fetched yet
 16  let emailFetchPromise: Promise<string | undefined> | null = null
 17  
 18  /**
 19   * GitHub Actions metadata when running in CI
 20   */
 21  export type GitHubActionsMetadata = {
 22    actor?: string
 23    actorId?: string
 24    repository?: string
 25    repositoryId?: string
 26    repositoryOwner?: string
 27    repositoryOwnerId?: string
 28  }
 29  
 30  /**
 31   * Core user data used as base for all analytics providers.
 32   * This is also the format used by GrowthBook.
 33   */
 34  export type CoreUserData = {
 35    deviceId: string
 36    sessionId: string
 37    email?: string
 38    appVersion: string
 39    platform: typeof env.platform
 40    organizationUuid?: string
 41    accountUuid?: string
 42    userType?: string
 43    subscriptionType?: string
 44    rateLimitTier?: string
 45    firstTokenTime?: number
 46    githubActionsMetadata?: GitHubActionsMetadata
 47  }
 48  
 49  /**
 50   * Initialize user data asynchronously. Should be called early in startup.
 51   * This pre-fetches the email so getUser() can remain synchronous.
 52   */
 53  export async function initUser(): Promise<void> {
 54    if (cachedEmail === null && !emailFetchPromise) {
 55      emailFetchPromise = getEmailAsync()
 56      cachedEmail = await emailFetchPromise
 57      emailFetchPromise = null
 58      // Clear memoization cache so next call picks up the email
 59      getCoreUserData.cache.clear?.()
 60    }
 61  }
 62  
 63  /**
 64   * Reset all user data caches. Call on auth changes (login/logout/account switch)
 65   * so the next getCoreUserData() call picks up fresh credentials and email.
 66   */
 67  export function resetUserCache(): void {
 68    cachedEmail = null
 69    emailFetchPromise = null
 70    getCoreUserData.cache.clear?.()
 71    getGitEmail.cache.clear?.()
 72  }
 73  
 74  /**
 75   * Get core user data.
 76   * This is the base representation that gets transformed for different analytics providers.
 77   */
 78  export const getCoreUserData = memoize(
 79    (includeAnalyticsMetadata?: boolean): CoreUserData => {
 80      const deviceId = getOrCreateUserID()
 81      const config = getGlobalConfig()
 82  
 83      let subscriptionType: string | undefined
 84      let rateLimitTier: string | undefined
 85      let firstTokenTime: number | undefined
 86      if (includeAnalyticsMetadata) {
 87        subscriptionType = getSubscriptionType() ?? undefined
 88        rateLimitTier = getRateLimitTier() ?? undefined
 89        if (subscriptionType && config.claudeCodeFirstTokenDate) {
 90          const configFirstTokenTime = new Date(
 91            config.claudeCodeFirstTokenDate,
 92          ).getTime()
 93          if (!isNaN(configFirstTokenTime)) {
 94            firstTokenTime = configFirstTokenTime
 95          }
 96        }
 97      }
 98  
 99      // Only include OAuth account data when actively using OAuth authentication
100      const oauthAccount = getOauthAccountInfo()
101      const organizationUuid = oauthAccount?.organizationUuid
102      const accountUuid = oauthAccount?.accountUuid
103  
104      return {
105        deviceId,
106        sessionId: getSessionId(),
107        email: getEmail(),
108        appVersion: MACRO.VERSION,
109        platform: getHostPlatformForAnalytics(),
110        organizationUuid,
111        accountUuid,
112        userType: process.env.USER_TYPE,
113        subscriptionType,
114        rateLimitTier,
115        firstTokenTime,
116        ...(isEnvTruthy(process.env.GITHUB_ACTIONS) && {
117          githubActionsMetadata: {
118            actor: process.env.GITHUB_ACTOR,
119            actorId: process.env.GITHUB_ACTOR_ID,
120            repository: process.env.GITHUB_REPOSITORY,
121            repositoryId: process.env.GITHUB_REPOSITORY_ID,
122            repositoryOwner: process.env.GITHUB_REPOSITORY_OWNER,
123            repositoryOwnerId: process.env.GITHUB_REPOSITORY_OWNER_ID,
124          },
125        }),
126      }
127    },
128  )
129  
130  /**
131   * Get user data for GrowthBook (same as core data with analytics metadata).
132   */
133  export function getUserForGrowthBook(): CoreUserData {
134    return getCoreUserData(true)
135  }
136  
137  function getEmail(): string | undefined {
138    // Return cached email if available (from async initialization)
139    if (cachedEmail !== null) {
140      return cachedEmail
141    }
142  
143    // Only include OAuth email when actively using OAuth authentication
144    const oauthAccount = getOauthAccountInfo()
145    if (oauthAccount?.emailAddress) {
146      return oauthAccount.emailAddress
147    }
148  
149    // Ant-only fallbacks below (no execSync)
150    if (process.env.USER_TYPE !== 'ant') {
151      return undefined
152    }
153  
154    if (process.env.COO_CREATOR) {
155      return `${process.env.COO_CREATOR}@anthropic.com`
156    }
157  
158    // If initUser() wasn't called, we return undefined instead of blocking
159    return undefined
160  }
161  
162  async function getEmailAsync(): Promise<string | undefined> {
163    // Only include OAuth email when actively using OAuth authentication
164    const oauthAccount = getOauthAccountInfo()
165    if (oauthAccount?.emailAddress) {
166      return oauthAccount.emailAddress
167    }
168  
169    // Ant-only fallbacks below
170    if (process.env.USER_TYPE !== 'ant') {
171      return undefined
172    }
173  
174    if (process.env.COO_CREATOR) {
175      return `${process.env.COO_CREATOR}@anthropic.com`
176    }
177  
178    return getGitEmail()
179  }
180  
181  /**
182   * Get the user's git email from `git config user.email`.
183   * Memoized so the subprocess only spawns once per process.
184   */
185  export const getGitEmail = memoize(async (): Promise<string | undefined> => {
186    const result = await execa('git config --get user.email', {
187      shell: true,
188      reject: false,
189      cwd: getCwd(),
190    })
191    return result.exitCode === 0 && result.stdout
192      ? result.stdout.trim()
193      : undefined
194  })