import axios from 'axios' import { mkdir, readFile, writeFile } from 'fs/promises' import { dirname, join } from 'path' import { coerce } from 'semver' import { getIsNonInteractiveSession } from '../bootstrap/state.js' import { getGlobalConfig, saveGlobalConfig } from './config.js' import { getClaudeConfigHomeDir } from './envUtils.js' import { toError } from './errors.js' import { logError } from './log.js' import { isEssentialTrafficOnly } from './privacyLevel.js' import { gt } from './semver.js' const MAX_RELEASE_NOTES_SHOWN = 5 /** * We fetch the changelog from GitHub instead of bundling it with the build. * * This is necessary because Ink's static rendering makes it difficult to * dynamically update/show components after initial render. By storing the * changelog in config, we ensure it's available on the next startup without * requiring a full re-render of the current UI. * * The flow is: * 1. User updates to a new version * 2. We fetch the changelog in the background and store it in config * 3. Next time the user starts Claude, the cached changelog is available immediately */ export const CHANGELOG_URL = 'https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md' const RAW_CHANGELOG_URL = 'https://raw.githubusercontent.com/anthropics/claude-code/refs/heads/main/CHANGELOG.md' /** * Get the path for the cached changelog file. * The changelog is stored at ~/.claude/cache/changelog.md */ function getChangelogCachePath(): string { return join(getClaudeConfigHomeDir(), 'cache', 'changelog.md') } // In-memory cache populated by async reads. Sync callers (React render, sync // helpers) read from this cache after setup.ts awaits checkForReleaseNotes(). let changelogMemoryCache: string | null = null /** @internal exported for tests */ export function _resetChangelogCacheForTesting(): void { changelogMemoryCache = null } /** * Migrate changelog from old config-based storage to file-based storage. * This should be called once at startup to ensure the migration happens * before any other config saves that might re-add the deprecated field. */ export async function migrateChangelogFromConfig(): Promise { const config = getGlobalConfig() if (!config.cachedChangelog) { return } const cachePath = getChangelogCachePath() // If cache file doesn't exist, create it from old config try { await mkdir(dirname(cachePath), { recursive: true }) await writeFile(cachePath, config.cachedChangelog, { encoding: 'utf-8', flag: 'wx', // Write only if file doesn't exist }) } catch { // File already exists, which is fine - skip silently } // Remove the deprecated field from config saveGlobalConfig(({ cachedChangelog: _, ...rest }) => rest) } /** * Fetch the changelog from GitHub and store it in cache file * This runs in the background and doesn't block the UI */ export async function fetchAndStoreChangelog(): Promise { // Skip in noninteractive mode if (getIsNonInteractiveSession()) { return } // Skip network requests if nonessential traffic is disabled if (isEssentialTrafficOnly()) { return } const response = await axios.get(RAW_CHANGELOG_URL) if (response.status === 200) { const changelogContent = response.data // Skip write if content unchanged — writing Date.now() defeats the // dirty-check in saveGlobalConfig since the timestamp always differs. if (changelogContent === changelogMemoryCache) { return } const cachePath = getChangelogCachePath() // Ensure cache directory exists await mkdir(dirname(cachePath), { recursive: true }) // Write changelog to cache file await writeFile(cachePath, changelogContent, { encoding: 'utf-8' }) changelogMemoryCache = changelogContent // Update timestamp in config const changelogLastFetched = Date.now() saveGlobalConfig(current => ({ ...current, changelogLastFetched, })) } } /** * Get the stored changelog from cache file if available. * Populates the in-memory cache for subsequent sync reads. * @returns The cached changelog content or empty string if not available */ export async function getStoredChangelog(): Promise { if (changelogMemoryCache !== null) { return changelogMemoryCache } const cachePath = getChangelogCachePath() try { const content = await readFile(cachePath, 'utf-8') changelogMemoryCache = content return content } catch { changelogMemoryCache = '' return '' } } /** * Synchronous accessor for the changelog, reading only from the in-memory cache. * Returns empty string if the async getStoredChangelog() hasn't been called yet. * Intended for React render paths where async is not possible; setup.ts ensures * the cache is populated before first render via `await checkForReleaseNotes()`. */ export function getStoredChangelogFromMemory(): string { return changelogMemoryCache ?? '' } /** * Parses a changelog string in markdown format into a structured format * @param content - The changelog content string * @returns Record mapping version numbers to arrays of release notes */ export function parseChangelog(content: string): Record { try { if (!content) return {} // Parse the content const releaseNotes: Record = {} // Split by heading lines (## X.X.X) const sections = content.split(/^## /gm).slice(1) // Skip the first section which is the header for (const section of sections) { const lines = section.trim().split('\n') if (lines.length === 0) continue // Extract version from the first line // Handle both "1.2.3" and "1.2.3 - YYYY-MM-DD" formats const versionLine = lines[0] if (!versionLine) continue // First part before any dash is the version const version = versionLine.split(' - ')[0]?.trim() || '' if (!version) continue // Extract bullet points const notes = lines .slice(1) .filter(line => line.trim().startsWith('- ')) .map(line => line.trim().substring(2).trim()) .filter(Boolean) if (notes.length > 0) { releaseNotes[version] = notes } } return releaseNotes } catch (error) { logError(toError(error)) return {} } } /** * Gets release notes to show based on the previously seen version. * Shows up to MAX_RELEASE_NOTES_SHOWN items total, prioritizing the most recent versions. * * @param currentVersion - The current app version * @param previousVersion - The last version where release notes were seen (or null if first time) * @param readChangelog - Function to read the changelog (defaults to readChangelogFile) * @returns Array of release notes to display */ export function getRecentReleaseNotes( currentVersion: string, previousVersion: string | null | undefined, changelogContent: string = getStoredChangelogFromMemory(), ): string[] { try { const releaseNotes = parseChangelog(changelogContent) // Strip SHA from both versions to compare only the base versions const baseCurrentVersion = coerce(currentVersion) const basePreviousVersion = previousVersion ? coerce(previousVersion) : null if ( !basePreviousVersion || (baseCurrentVersion && gt(baseCurrentVersion.version, basePreviousVersion.version)) ) { // Get all versions that are newer than the last seen version return Object.entries(releaseNotes) .filter( ([version]) => !basePreviousVersion || gt(version, basePreviousVersion.version), ) .sort(([versionA], [versionB]) => (gt(versionA, versionB) ? -1 : 1)) // Sort newest first .flatMap(([_, notes]) => notes) .filter(Boolean) .slice(0, MAX_RELEASE_NOTES_SHOWN) } } catch (error) { logError(toError(error)) return [] } return [] } /** * Gets all release notes as an array of [version, notes] arrays. * Versions are sorted with oldest first. * * @param readChangelog - Function to read the changelog (defaults to readChangelogFile) * @returns Array of [version, notes[]] arrays */ export function getAllReleaseNotes( changelogContent: string = getStoredChangelogFromMemory(), ): Array<[string, string[]]> { try { const releaseNotes = parseChangelog(changelogContent) // Sort versions with oldest first const sortedVersions = Object.keys(releaseNotes).sort((a, b) => gt(a, b) ? 1 : -1, ) // Return array of [version, notes] arrays return sortedVersions .map(version => { const versionNotes = releaseNotes[version] if (!versionNotes || versionNotes.length === 0) return null const notes = versionNotes.filter(Boolean) if (notes.length === 0) return null return [version, notes] as [string, string[]] }) .filter((item): item is [string, string[]] => item !== null) } catch (error) { logError(toError(error)) return [] } } /** * Checks if there are release notes to show based on the last seen version. * Can be used by multiple components to determine whether to display release notes. * Also triggers a fetch of the latest changelog if the version has changed. * * @param lastSeenVersion The last version of release notes the user has seen * @param currentVersion The current application version, defaults to MACRO.VERSION * @returns An object with hasReleaseNotes and the releaseNotes content */ export async function checkForReleaseNotes( lastSeenVersion: string | null | undefined, currentVersion: string = MACRO.VERSION, ): Promise<{ hasReleaseNotes: boolean; releaseNotes: string[] }> { // For Ant builds, use VERSION_CHANGELOG bundled at build time if (process.env.USER_TYPE === 'ant') { const changelog = MACRO.VERSION_CHANGELOG if (changelog) { const commits = changelog.trim().split('\n').filter(Boolean) return { hasReleaseNotes: commits.length > 0, releaseNotes: commits, } } return { hasReleaseNotes: false, releaseNotes: [], } } // Ensure the in-memory cache is populated for subsequent sync reads const cachedChangelog = await getStoredChangelog() // If the version has changed or we don't have a cached changelog, fetch a new one // This happens in the background and doesn't block the UI if (lastSeenVersion !== currentVersion || !cachedChangelog) { fetchAndStoreChangelog().catch(error => logError(toError(error))) } const releaseNotes = getRecentReleaseNotes( currentVersion, lastSeenVersion, cachedChangelog, ) const hasReleaseNotes = releaseNotes.length > 0 return { hasReleaseNotes, releaseNotes, } } /** * Synchronous variant of checkForReleaseNotes for React render paths. * Reads only from the in-memory cache populated by the async version. * setup.ts awaits checkForReleaseNotes() before first render, so this * returns accurate results in component render bodies. */ export function checkForReleaseNotesSync( lastSeenVersion: string | null | undefined, currentVersion: string = MACRO.VERSION, ): { hasReleaseNotes: boolean; releaseNotes: string[] } { // For Ant builds, use VERSION_CHANGELOG bundled at build time if (process.env.USER_TYPE === 'ant') { const changelog = MACRO.VERSION_CHANGELOG if (changelog) { const commits = changelog.trim().split('\n').filter(Boolean) return { hasReleaseNotes: commits.length > 0, releaseNotes: commits, } } return { hasReleaseNotes: false, releaseNotes: [], } } const releaseNotes = getRecentReleaseNotes(currentVersion, lastSeenVersion) return { hasReleaseNotes: releaseNotes.length > 0, releaseNotes, } }