forked from xorespesp/claude-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathreleaseNotes.ts
More file actions
379 lines (333 loc) · 12 KB
/
Copy pathreleaseNotes.ts
File metadata and controls
379 lines (333 loc) · 12 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
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 { isUpdateChecksEnabledByDefaultForOSS } from './nonEssentialEgress.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 BotValia, the cached changelog is available immediately
*/
export const CHANGELOG_URL =
'https://github.com/GaruBrothers/botvalia-code/blob/main/CHANGELOG.md'
const RAW_CHANGELOG_URL =
'https://raw.githubusercontent.com/GaruBrothers/botvalia-code/refs/heads/main/CHANGELOG.md'
/**
* Get the path for the cached changelog file.
* The changelog is stored inside the BotValia config-home cache directory.
*/
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<void> {
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<void> {
// Skip in noninteractive mode
if (getIsNonInteractiveSession()) {
return
}
// Skip network requests if nonessential traffic is disabled
if (isEssentialTrafficOnly()) {
return
}
if (!isUpdateChecksEnabledByDefaultForOSS()) {
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<string> {
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<string, string[]> {
try {
if (!content) return {}
// Parse the content
const releaseNotes: Record<string, string[]> = {}
// 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[] }> {
if (!isUpdateChecksEnabledByDefaultForOSS()) {
return {
hasReleaseNotes: false,
releaseNotes: [],
}
}
// 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[] } {
if (!isUpdateChecksEnabledByDefaultForOSS()) {
return {
hasReleaseNotes: false,
releaseNotes: [],
}
}
// 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,
}
}