source dump of claude code
at main 360 lines 12 kB view raw
1import axios from 'axios' 2import { mkdir, readFile, writeFile } from 'fs/promises' 3import { dirname, join } from 'path' 4import { coerce } from 'semver' 5import { getIsNonInteractiveSession } from '../bootstrap/state.js' 6import { getGlobalConfig, saveGlobalConfig } from './config.js' 7import { getClaudeConfigHomeDir } from './envUtils.js' 8import { toError } from './errors.js' 9import { logError } from './log.js' 10import { isEssentialTrafficOnly } from './privacyLevel.js' 11import { gt } from './semver.js' 12 13const MAX_RELEASE_NOTES_SHOWN = 5 14 15/** 16 * We fetch the changelog from GitHub instead of bundling it with the build. 17 * 18 * This is necessary because Ink's static rendering makes it difficult to 19 * dynamically update/show components after initial render. By storing the 20 * changelog in config, we ensure it's available on the next startup without 21 * requiring a full re-render of the current UI. 22 * 23 * The flow is: 24 * 1. User updates to a new version 25 * 2. We fetch the changelog in the background and store it in config 26 * 3. Next time the user starts Claude, the cached changelog is available immediately 27 */ 28export const CHANGELOG_URL = 29 'https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md' 30const RAW_CHANGELOG_URL = 31 'https://raw.githubusercontent.com/anthropics/claude-code/refs/heads/main/CHANGELOG.md' 32 33/** 34 * Get the path for the cached changelog file. 35 * The changelog is stored at ~/.claude/cache/changelog.md 36 */ 37function getChangelogCachePath(): string { 38 return join(getClaudeConfigHomeDir(), 'cache', 'changelog.md') 39} 40 41// In-memory cache populated by async reads. Sync callers (React render, sync 42// helpers) read from this cache after setup.ts awaits checkForReleaseNotes(). 43let changelogMemoryCache: string | null = null 44 45/** @internal exported for tests */ 46export function _resetChangelogCacheForTesting(): void { 47 changelogMemoryCache = null 48} 49 50/** 51 * Migrate changelog from old config-based storage to file-based storage. 52 * This should be called once at startup to ensure the migration happens 53 * before any other config saves that might re-add the deprecated field. 54 */ 55export async function migrateChangelogFromConfig(): Promise<void> { 56 const config = getGlobalConfig() 57 if (!config.cachedChangelog) { 58 return 59 } 60 61 const cachePath = getChangelogCachePath() 62 63 // If cache file doesn't exist, create it from old config 64 try { 65 await mkdir(dirname(cachePath), { recursive: true }) 66 await writeFile(cachePath, config.cachedChangelog, { 67 encoding: 'utf-8', 68 flag: 'wx', // Write only if file doesn't exist 69 }) 70 } catch { 71 // File already exists, which is fine - skip silently 72 } 73 74 // Remove the deprecated field from config 75 saveGlobalConfig(({ cachedChangelog: _, ...rest }) => rest) 76} 77 78/** 79 * Fetch the changelog from GitHub and store it in cache file 80 * This runs in the background and doesn't block the UI 81 */ 82export async function fetchAndStoreChangelog(): Promise<void> { 83 // Skip in noninteractive mode 84 if (getIsNonInteractiveSession()) { 85 return 86 } 87 88 // Skip network requests if nonessential traffic is disabled 89 if (isEssentialTrafficOnly()) { 90 return 91 } 92 93 const response = await axios.get(RAW_CHANGELOG_URL) 94 if (response.status === 200) { 95 const changelogContent = response.data 96 97 // Skip write if content unchanged — writing Date.now() defeats the 98 // dirty-check in saveGlobalConfig since the timestamp always differs. 99 if (changelogContent === changelogMemoryCache) { 100 return 101 } 102 103 const cachePath = getChangelogCachePath() 104 105 // Ensure cache directory exists 106 await mkdir(dirname(cachePath), { recursive: true }) 107 108 // Write changelog to cache file 109 await writeFile(cachePath, changelogContent, { encoding: 'utf-8' }) 110 changelogMemoryCache = changelogContent 111 112 // Update timestamp in config 113 const changelogLastFetched = Date.now() 114 saveGlobalConfig(current => ({ 115 ...current, 116 changelogLastFetched, 117 })) 118 } 119} 120 121/** 122 * Get the stored changelog from cache file if available. 123 * Populates the in-memory cache for subsequent sync reads. 124 * @returns The cached changelog content or empty string if not available 125 */ 126export async function getStoredChangelog(): Promise<string> { 127 if (changelogMemoryCache !== null) { 128 return changelogMemoryCache 129 } 130 const cachePath = getChangelogCachePath() 131 try { 132 const content = await readFile(cachePath, 'utf-8') 133 changelogMemoryCache = content 134 return content 135 } catch { 136 changelogMemoryCache = '' 137 return '' 138 } 139} 140 141/** 142 * Synchronous accessor for the changelog, reading only from the in-memory cache. 143 * Returns empty string if the async getStoredChangelog() hasn't been called yet. 144 * Intended for React render paths where async is not possible; setup.ts ensures 145 * the cache is populated before first render via `await checkForReleaseNotes()`. 146 */ 147export function getStoredChangelogFromMemory(): string { 148 return changelogMemoryCache ?? '' 149} 150 151/** 152 * Parses a changelog string in markdown format into a structured format 153 * @param content - The changelog content string 154 * @returns Record mapping version numbers to arrays of release notes 155 */ 156export function parseChangelog(content: string): Record<string, string[]> { 157 try { 158 if (!content) return {} 159 160 // Parse the content 161 const releaseNotes: Record<string, string[]> = {} 162 163 // Split by heading lines (## X.X.X) 164 const sections = content.split(/^## /gm).slice(1) // Skip the first section which is the header 165 166 for (const section of sections) { 167 const lines = section.trim().split('\n') 168 if (lines.length === 0) continue 169 170 // Extract version from the first line 171 // Handle both "1.2.3" and "1.2.3 - YYYY-MM-DD" formats 172 const versionLine = lines[0] 173 if (!versionLine) continue 174 175 // First part before any dash is the version 176 const version = versionLine.split(' - ')[0]?.trim() || '' 177 if (!version) continue 178 179 // Extract bullet points 180 const notes = lines 181 .slice(1) 182 .filter(line => line.trim().startsWith('- ')) 183 .map(line => line.trim().substring(2).trim()) 184 .filter(Boolean) 185 186 if (notes.length > 0) { 187 releaseNotes[version] = notes 188 } 189 } 190 191 return releaseNotes 192 } catch (error) { 193 logError(toError(error)) 194 return {} 195 } 196} 197 198/** 199 * Gets release notes to show based on the previously seen version. 200 * Shows up to MAX_RELEASE_NOTES_SHOWN items total, prioritizing the most recent versions. 201 * 202 * @param currentVersion - The current app version 203 * @param previousVersion - The last version where release notes were seen (or null if first time) 204 * @param readChangelog - Function to read the changelog (defaults to readChangelogFile) 205 * @returns Array of release notes to display 206 */ 207export function getRecentReleaseNotes( 208 currentVersion: string, 209 previousVersion: string | null | undefined, 210 changelogContent: string = getStoredChangelogFromMemory(), 211): string[] { 212 try { 213 const releaseNotes = parseChangelog(changelogContent) 214 215 // Strip SHA from both versions to compare only the base versions 216 const baseCurrentVersion = coerce(currentVersion) 217 const basePreviousVersion = previousVersion ? coerce(previousVersion) : null 218 219 if ( 220 !basePreviousVersion || 221 (baseCurrentVersion && 222 gt(baseCurrentVersion.version, basePreviousVersion.version)) 223 ) { 224 // Get all versions that are newer than the last seen version 225 return Object.entries(releaseNotes) 226 .filter( 227 ([version]) => 228 !basePreviousVersion || gt(version, basePreviousVersion.version), 229 ) 230 .sort(([versionA], [versionB]) => (gt(versionA, versionB) ? -1 : 1)) // Sort newest first 231 .flatMap(([_, notes]) => notes) 232 .filter(Boolean) 233 .slice(0, MAX_RELEASE_NOTES_SHOWN) 234 } 235 } catch (error) { 236 logError(toError(error)) 237 return [] 238 } 239 return [] 240} 241 242/** 243 * Gets all release notes as an array of [version, notes] arrays. 244 * Versions are sorted with oldest first. 245 * 246 * @param readChangelog - Function to read the changelog (defaults to readChangelogFile) 247 * @returns Array of [version, notes[]] arrays 248 */ 249export function getAllReleaseNotes( 250 changelogContent: string = getStoredChangelogFromMemory(), 251): Array<[string, string[]]> { 252 try { 253 const releaseNotes = parseChangelog(changelogContent) 254 255 // Sort versions with oldest first 256 const sortedVersions = Object.keys(releaseNotes).sort((a, b) => 257 gt(a, b) ? 1 : -1, 258 ) 259 260 // Return array of [version, notes] arrays 261 return sortedVersions 262 .map(version => { 263 const versionNotes = releaseNotes[version] 264 if (!versionNotes || versionNotes.length === 0) return null 265 266 const notes = versionNotes.filter(Boolean) 267 if (notes.length === 0) return null 268 269 return [version, notes] as [string, string[]] 270 }) 271 .filter((item): item is [string, string[]] => item !== null) 272 } catch (error) { 273 logError(toError(error)) 274 return [] 275 } 276} 277 278/** 279 * Checks if there are release notes to show based on the last seen version. 280 * Can be used by multiple components to determine whether to display release notes. 281 * Also triggers a fetch of the latest changelog if the version has changed. 282 * 283 * @param lastSeenVersion The last version of release notes the user has seen 284 * @param currentVersion The current application version, defaults to MACRO.VERSION 285 * @returns An object with hasReleaseNotes and the releaseNotes content 286 */ 287export async function checkForReleaseNotes( 288 lastSeenVersion: string | null | undefined, 289 currentVersion: string = MACRO.VERSION, 290): Promise<{ hasReleaseNotes: boolean; releaseNotes: string[] }> { 291 // For Ant builds, use VERSION_CHANGELOG bundled at build time 292 if (process.env.USER_TYPE === 'ant') { 293 const changelog = MACRO.VERSION_CHANGELOG 294 if (changelog) { 295 const commits = changelog.trim().split('\n').filter(Boolean) 296 return { 297 hasReleaseNotes: commits.length > 0, 298 releaseNotes: commits, 299 } 300 } 301 return { 302 hasReleaseNotes: false, 303 releaseNotes: [], 304 } 305 } 306 307 // Ensure the in-memory cache is populated for subsequent sync reads 308 const cachedChangelog = await getStoredChangelog() 309 310 // If the version has changed or we don't have a cached changelog, fetch a new one 311 // This happens in the background and doesn't block the UI 312 if (lastSeenVersion !== currentVersion || !cachedChangelog) { 313 fetchAndStoreChangelog().catch(error => logError(toError(error))) 314 } 315 316 const releaseNotes = getRecentReleaseNotes( 317 currentVersion, 318 lastSeenVersion, 319 cachedChangelog, 320 ) 321 const hasReleaseNotes = releaseNotes.length > 0 322 323 return { 324 hasReleaseNotes, 325 releaseNotes, 326 } 327} 328 329/** 330 * Synchronous variant of checkForReleaseNotes for React render paths. 331 * Reads only from the in-memory cache populated by the async version. 332 * setup.ts awaits checkForReleaseNotes() before first render, so this 333 * returns accurate results in component render bodies. 334 */ 335export function checkForReleaseNotesSync( 336 lastSeenVersion: string | null | undefined, 337 currentVersion: string = MACRO.VERSION, 338): { hasReleaseNotes: boolean; releaseNotes: string[] } { 339 // For Ant builds, use VERSION_CHANGELOG bundled at build time 340 if (process.env.USER_TYPE === 'ant') { 341 const changelog = MACRO.VERSION_CHANGELOG 342 if (changelog) { 343 const commits = changelog.trim().split('\n').filter(Boolean) 344 return { 345 hasReleaseNotes: commits.length > 0, 346 releaseNotes: commits, 347 } 348 } 349 return { 350 hasReleaseNotes: false, 351 releaseNotes: [], 352 } 353 } 354 355 const releaseNotes = getRecentReleaseNotes(currentVersion, lastSeenVersion) 356 return { 357 hasReleaseNotes: releaseNotes.length > 0, 358 releaseNotes, 359 } 360}