Download src/services/api/grove.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 11.5 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/services/api/grove.ts
- Command line
-
hf download hf://chenbhao/codev/src/services/api/grove.ts
-
curl -L -o grove.ts https://huggingface.co/chenbhao/codev/resolve/main/src/services/api/grove.ts
11.5 kB
| import axios from 'axios' | |
| import memoize from 'lodash-es/memoize.js' | |
| import { | |
| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| logEvent, | |
| } from 'src/services/analytics/index.js' | |
| import { getOauthAccountInfo, isConsumerSubscriber } from 'src/utils/auth.js' | |
| import { logForDebugging } from 'src/utils/debug.js' | |
| import { gracefulShutdown } from 'src/utils/gracefulShutdown.js' | |
| import { isEssentialTrafficOnly } from 'src/utils/privacyLevel.js' | |
| import { writeToStderr } from 'src/utils/process.js' | |
| import { getOauthConfig } from '../../constants/oauth.js' | |
| import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' | |
| import { | |
| getAuthHeaders, | |
| getUserAgent, | |
| withOAuth401Retry, | |
| } from '../../utils/http.js' | |
| import { logError } from '../../utils/log.js' | |
| import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' | |
| // Cache expiration: 24 hours | |
| const GROVE_CACHE_EXPIRATION_MS = 24 * 60 * 60 * 1000 | |
| export type AccountSettings = { | |
| grove_enabled: boolean | null | |
| grove_notice_viewed_at: string | null | |
| } | |
| export type GroveConfig = { | |
| grove_enabled: boolean | |
| domain_excluded: boolean | |
| notice_is_grace_period: boolean | |
| notice_reminder_frequency: number | null | |
| } | |
| /** | |
| * Result type that distinguishes between API failure and success. | |
| * - success: true means API call succeeded (data may still contain null fields) | |
| * - success: false means API call failed after retry | |
| */ | |
| export type ApiResult<T> = { success: true; data: T } | { success: false } | |
| /** | |
| * Get the current Grove settings for the user account. | |
| * Returns ApiResult to distinguish between API failure and success. | |
| * Uses existing OAuth 401 retry, then returns failure if that doesn't help. | |
| * | |
| * Memoized for the session to avoid redundant per-render requests. | |
| * Cache is invalidated in updateGroveSettings() so post-toggle reads are fresh. | |
| */ | |
| export const getGroveSettings = memoize( | |
| async (): Promise<ApiResult<AccountSettings>> => { | |
| // Grove is a notification feature; during an outage, skipping it is correct. | |
| if (isEssentialTrafficOnly()) { | |
| return { success: false } | |
| } | |
| try { | |
| const response = await withOAuth401Retry(() => { | |
| const authHeaders = getAuthHeaders() | |
| if (authHeaders.error) { | |
| throw new Error(`Failed to get auth headers: ${authHeaders.error}`) | |
| } | |
| return axios.get<AccountSettings>( | |
| `${getOauthConfig().BASE_API_URL}/api/oauth/account/settings`, | |
| { | |
| headers: { | |
| ...authHeaders.headers, | |
| 'User-Agent': getClaudeCodeUserAgent(), | |
| }, | |
| }, | |
| ) | |
| }) | |
| return { success: true, data: response.data } | |
| } catch (err) { | |
| logError(err) | |
| // Don't cache failures — transient network issues would lock the user | |
| // out of privacy settings for the entire session (deadlock: dialog needs | |
| // success to render the toggle, toggle calls updateGroveSettings which | |
| // is the only other place the cache is cleared). | |
| getGroveSettings.cache.clear?.() | |
| return { success: false } | |
| } | |
| }, | |
| ) | |
| /** | |
| * Mark that the Grove notice has been viewed by the user | |
| */ | |
| export async function markGroveNoticeViewed(): Promise<void> { | |
| try { | |
| await withOAuth401Retry(() => { | |
| const authHeaders = getAuthHeaders() | |
| if (authHeaders.error) { | |
| throw new Error(`Failed to get auth headers: ${authHeaders.error}`) | |
| } | |
| return axios.post( | |
| `${getOauthConfig().BASE_API_URL}/api/oauth/account/grove_notice_viewed`, | |
| {}, | |
| { | |
| headers: { | |
| ...authHeaders.headers, | |
| 'User-Agent': getClaudeCodeUserAgent(), | |
| }, | |
| }, | |
| ) | |
| }) | |
| // This mutates grove_notice_viewed_at server-side — Grove.tsx:87 reads it | |
| // to decide whether to show the dialog. Without invalidation a same-session | |
| // remount would read stale viewed_at:null and re-show the dialog. | |
| getGroveSettings.cache.clear?.() | |
| } catch (err) { | |
| logError(err) | |
| } | |
| } | |
| /** | |
| * Update Grove settings for the user account | |
| */ | |
| export async function updateGroveSettings( | |
| groveEnabled: boolean, | |
| ): Promise<void> { | |
| try { | |
| await withOAuth401Retry(() => { | |
| const authHeaders = getAuthHeaders() | |
| if (authHeaders.error) { | |
| throw new Error(`Failed to get auth headers: ${authHeaders.error}`) | |
| } | |
| return axios.patch( | |
| `${getOauthConfig().BASE_API_URL}/api/oauth/account/settings`, | |
| { | |
| grove_enabled: groveEnabled, | |
| }, | |
| { | |
| headers: { | |
| ...authHeaders.headers, | |
| 'User-Agent': getClaudeCodeUserAgent(), | |
| }, | |
| }, | |
| ) | |
| }) | |
| // Invalidate memoized settings so the post-toggle confirmation | |
| // read in privacy-settings.tsx picks up the new value. | |
| getGroveSettings.cache.clear?.() | |
| } catch (err) { | |
| logError(err) | |
| } | |
| } | |
| /** | |
| * Check if user is qualified for Grove (non-blocking, cache-first). | |
| * | |
| * This function never blocks on network - it returns cached data immediately | |
| * and fetches in the background if needed. On cold start (no cache), it returns | |
| * false and the Grove dialog won't show until the next session. | |
| */ | |
| export async function isQualifiedForGrove(): Promise<boolean> { | |
| if (!isConsumerSubscriber()) { | |
| return false | |
| } | |
| const accountId = getOauthAccountInfo()?.accountUuid | |
| if (!accountId) { | |
| return false | |
| } | |
| const globalConfig = getGlobalConfig() | |
| const cachedEntry = globalConfig.groveConfigCache?.[accountId] | |
| const now = Date.now() | |
| // No cache - trigger background fetch and return false (non-blocking) | |
| // The Grove dialog won't show this session, but will next time if eligible | |
| if (!cachedEntry) { | |
| logForDebugging( | |
| 'Grove: No cache, fetching config in background (dialog skipped this session)', | |
| ) | |
| void fetchAndStoreGroveConfig(accountId) | |
| return false | |
| } | |
| // Cache exists but is stale - return cached value and refresh in background | |
| if (now - cachedEntry.timestamp > GROVE_CACHE_EXPIRATION_MS) { | |
| logForDebugging( | |
| 'Grove: Cache stale, returning cached data and refreshing in background', | |
| ) | |
| void fetchAndStoreGroveConfig(accountId) | |
| return cachedEntry.grove_enabled | |
| } | |
| // Cache is fresh - return it immediately | |
| logForDebugging('Grove: Using fresh cached config') | |
| return cachedEntry.grove_enabled | |
| } | |
| /** | |
| * Fetch Grove config from API and store in cache | |
| */ | |
| async function fetchAndStoreGroveConfig(accountId: string): Promise<void> { | |
| try { | |
| const result = await getGroveNoticeConfig() | |
| if (!result.success) { | |
| return | |
| } | |
| const groveEnabled = result.data.grove_enabled | |
| const cachedEntry = getGlobalConfig().groveConfigCache?.[accountId] | |
| if ( | |
| cachedEntry?.grove_enabled === groveEnabled && | |
| Date.now() - cachedEntry.timestamp <= GROVE_CACHE_EXPIRATION_MS | |
| ) { | |
| return | |
| } | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| groveConfigCache: { | |
| ...current.groveConfigCache, | |
| [accountId]: { | |
| grove_enabled: groveEnabled, | |
| timestamp: Date.now(), | |
| }, | |
| }, | |
| })) | |
| } catch (err) { | |
| logForDebugging(`Grove: Failed to fetch and store config: ${err}`) | |
| } | |
| } | |
| /** | |
| * Get Grove Statsig configuration from the API. | |
| * Returns ApiResult to distinguish between API failure and success. | |
| * Uses existing OAuth 401 retry, then returns failure if that doesn't help. | |
| */ | |
| export const getGroveNoticeConfig = memoize( | |
| async (): Promise<ApiResult<GroveConfig>> => { | |
| // Grove is a notification feature; during an outage, skipping it is correct. | |
| if (isEssentialTrafficOnly()) { | |
| return { success: false } | |
| } | |
| try { | |
| const response = await withOAuth401Retry(() => { | |
| const authHeaders = getAuthHeaders() | |
| if (authHeaders.error) { | |
| throw new Error(`Failed to get auth headers: ${authHeaders.error}`) | |
| } | |
| return axios.get<GroveConfig>( | |
| `${getOauthConfig().BASE_API_URL}/api/claude_code_grove`, | |
| { | |
| headers: { | |
| ...authHeaders.headers, | |
| 'User-Agent': getUserAgent(), | |
| }, | |
| timeout: 3000, // Short timeout - if slow, skip Grove dialog | |
| }, | |
| ) | |
| }) | |
| // Map the API response to the GroveConfig type | |
| const { | |
| grove_enabled, | |
| domain_excluded, | |
| notice_is_grace_period, | |
| notice_reminder_frequency, | |
| } = response.data | |
| return { | |
| success: true, | |
| data: { | |
| grove_enabled, | |
| domain_excluded: domain_excluded ?? false, | |
| notice_is_grace_period: notice_is_grace_period ?? true, | |
| notice_reminder_frequency, | |
| }, | |
| } | |
| } catch (err) { | |
| logForDebugging(`Failed to fetch Grove notice config: ${err}`) | |
| return { success: false } | |
| } | |
| }, | |
| ) | |
| /** | |
| * Determines whether the Grove dialog should be shown. | |
| * Returns false if either API call failed (after retry) - we hide the dialog on API failure. | |
| */ | |
| export function calculateShouldShowGrove( | |
| settingsResult: ApiResult<AccountSettings>, | |
| configResult: ApiResult<GroveConfig>, | |
| showIfAlreadyViewed: boolean, | |
| ): boolean { | |
| // Hide dialog on API failure (after retry) | |
| if (!settingsResult.success || !configResult.success) { | |
| return false | |
| } | |
| const settings = settingsResult.data | |
| const config = configResult.data | |
| const hasChosen = settings.grove_enabled !== null | |
| if (hasChosen) { | |
| return false | |
| } | |
| if (showIfAlreadyViewed) { | |
| return true | |
| } | |
| if (!config.notice_is_grace_period) { | |
| return true | |
| } | |
| // Check if we need to remind the user to accept the terms and choose | |
| // whether to help improve Claude. | |
| const reminderFrequency = config.notice_reminder_frequency | |
| if (reminderFrequency !== null && settings.grove_notice_viewed_at) { | |
| const daysSinceViewed = Math.floor( | |
| (Date.now() - new Date(settings.grove_notice_viewed_at).getTime()) / | |
| (1000 * 60 * 60 * 24), | |
| ) | |
| return daysSinceViewed >= reminderFrequency | |
| } else { | |
| // Show if never viewed before | |
| const viewedAt = settings.grove_notice_viewed_at | |
| return viewedAt === null || viewedAt === undefined | |
| } | |
| } | |
| export async function checkGroveForNonInteractive(): Promise<void> { | |
| const [settingsResult, configResult] = await Promise.all([ | |
| getGroveSettings(), | |
| getGroveNoticeConfig(), | |
| ]) | |
| // Check if user hasn't made a choice yet (returns false on API failure) | |
| const shouldShowGrove = calculateShouldShowGrove( | |
| settingsResult, | |
| configResult, | |
| false, | |
| ) | |
| if (shouldShowGrove) { | |
| // shouldShowGrove is only true if both API calls succeeded | |
| const config = configResult.success ? configResult.data : null | |
| logEvent('tengu_grove_print_viewed', { | |
| dismissable: | |
| config?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| }) | |
| if (config === null || config.notice_is_grace_period) { | |
| // Grace period is still active - show informational message and continue | |
| writeToStderr( | |
| '\nAn update to our Consumer Terms and Privacy Policy will take effect on October 8, 2025. Run `claude` to review the updated terms.\n\n', | |
| ) | |
| await markGroveNoticeViewed() | |
| } else { | |
| // Grace period has ended - show error message and exit | |
| writeToStderr( | |
| '\n[ACTION REQUIRED] An update to our Consumer Terms and Privacy Policy has taken effect on October 8, 2025. You must run `claude` to review the updated terms.\n\n', | |
| ) | |
| await gracefulShutdown(1) | |
| } | |
| } | |
| } | |