init claude-code
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user