/ utils / releaseNotes.ts
releaseNotes.ts
  1  import axios from 'axios'
  2  import { mkdir, readFile, writeFile } from 'fs/promises'
  3  import { dirname, join } from 'path'
  4  import { coerce } from 'semver'
  5  import { getIsNonInteractiveSession } from '../bootstrap/state.js'
  6  import { getGlobalConfig, saveGlobalConfig } from './config.js'
  7  import { getClaudeConfigHomeDir } from './envUtils.js'
  8  import { toError } from './errors.js'
  9  import { logError } from './log.js'
 10  import { isEssentialTrafficOnly } from './privacyLevel.js'
 11  import { gt } from './semver.js'
 12  
 13  const 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   */
 28  export const CHANGELOG_URL =
 29    'https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md'
 30  const 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   */
 37  function 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().
 43  let changelogMemoryCache: string | null = null
 44  
 45  /** @internal exported for tests */
 46  export 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   */
 55  export 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   */
 82  export 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   */
126  export 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   */
147  export 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   */
156  export 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   */
207  export 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   */
249  export 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   */
287  export 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   */
335  export 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  }