mono/packages/kbot/ref/utils/plugins/installCounts.ts
2026-04-01 01:05:48 +02:00

293 lines
8.1 KiB
TypeScript

/**
* Plugin install counts data layer
*
* This module fetches and caches plugin install counts from the official
* Claude plugins statistics repository. The cache is refreshed if older
* than 24 hours.
*
* Cache location: ~/.claude/plugins/install-counts-cache.json
*/
import axios from 'axios'
import { randomBytes } from 'crypto'
import { readFile, rename, unlink, writeFile } from 'fs/promises'
import { join } from 'path'
import { logForDebugging } from '../debug.js'
import { errorMessage, getErrnoCode } from '../errors.js'
import { getFsImplementation } from '../fsOperations.js'
import { logError } from '../log.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js'
import { getPluginsDirectory } from './pluginDirectories.js'
const INSTALL_COUNTS_CACHE_VERSION = 1
const INSTALL_COUNTS_CACHE_FILENAME = 'install-counts-cache.json'
const INSTALL_COUNTS_URL =
'https://raw.githubusercontent.com/anthropics/claude-plugins-official/refs/heads/stats/stats/plugin-installs.json'
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours in milliseconds
/**
* Structure of the install counts cache file
*/
type InstallCountsCache = {
version: number
fetchedAt: string // ISO timestamp
counts: Array<{
plugin: string // "pluginName@marketplace"
unique_installs: number
}>
}
/**
* Expected structure of the GitHub stats response
*/
type GitHubStatsResponse = {
plugins: Array<{
plugin: string
unique_installs: number
}>
}
/**
* Get the path to the install counts cache file
*/
function getInstallCountsCachePath(): string {
return join(getPluginsDirectory(), INSTALL_COUNTS_CACHE_FILENAME)
}
/**
* Load the install counts cache from disk.
* Returns null if the file doesn't exist, is invalid, or is stale (>24h old).
*/
async function loadInstallCountsCache(): Promise<InstallCountsCache | null> {
const cachePath = getInstallCountsCachePath()
try {
const content = await readFile(cachePath, { encoding: 'utf-8' })
const parsed = jsonParse(content) as unknown
// Validate basic structure
if (
typeof parsed !== 'object' ||
parsed === null ||
!('version' in parsed) ||
!('fetchedAt' in parsed) ||
!('counts' in parsed)
) {
logForDebugging('Install counts cache has invalid structure')
return null
}
const cache = parsed as {
version: unknown
fetchedAt: unknown
counts: unknown
}
// Validate version
if (cache.version !== INSTALL_COUNTS_CACHE_VERSION) {
logForDebugging(
`Install counts cache version mismatch (got ${cache.version}, expected ${INSTALL_COUNTS_CACHE_VERSION})`,
)
return null
}
// Validate fetchedAt and counts
if (typeof cache.fetchedAt !== 'string' || !Array.isArray(cache.counts)) {
logForDebugging('Install counts cache has invalid structure')
return null
}
// Validate fetchedAt is a valid date
const fetchedAt = new Date(cache.fetchedAt).getTime()
if (Number.isNaN(fetchedAt)) {
logForDebugging('Install counts cache has invalid fetchedAt timestamp')
return null
}
// Validate count entries have required fields
const validCounts = cache.counts.every(
(entry): entry is { plugin: string; unique_installs: number } =>
typeof entry === 'object' &&
entry !== null &&
typeof entry.plugin === 'string' &&
typeof entry.unique_installs === 'number',
)
if (!validCounts) {
logForDebugging('Install counts cache has malformed entries')
return null
}
// Check if cache is stale (>24 hours old)
const now = Date.now()
if (now - fetchedAt > CACHE_TTL_MS) {
logForDebugging('Install counts cache is stale (>24h old)')
return null
}
// Return validated cache
return {
version: cache.version as number,
fetchedAt: cache.fetchedAt,
counts: cache.counts,
}
} catch (error) {
const code = getErrnoCode(error)
if (code !== 'ENOENT') {
logForDebugging(
`Failed to load install counts cache: ${errorMessage(error)}`,
)
}
return null
}
}
/**
* Save the install counts cache to disk atomically.
* Uses a temp file + rename pattern to prevent corruption.
*/
async function saveInstallCountsCache(
cache: InstallCountsCache,
): Promise<void> {
const cachePath = getInstallCountsCachePath()
const tempPath = `${cachePath}.${randomBytes(8).toString('hex')}.tmp`
try {
// Ensure the plugins directory exists
const pluginsDir = getPluginsDirectory()
await getFsImplementation().mkdir(pluginsDir)
// Write to temp file
const content = jsonStringify(cache, null, 2)
await writeFile(tempPath, content, {
encoding: 'utf-8',
mode: 0o600,
})
// Atomic rename
await rename(tempPath, cachePath)
logForDebugging('Install counts cache saved successfully')
} catch (error) {
logError(error)
// Clean up temp file if it exists
try {
await unlink(tempPath)
} catch {
// Ignore cleanup errors
}
}
}
/**
* Fetch install counts from GitHub stats repository
*/
async function fetchInstallCountsFromGitHub(): Promise<
Array<{ plugin: string; unique_installs: number }>
> {
logForDebugging(`Fetching install counts from ${INSTALL_COUNTS_URL}`)
const started = performance.now()
try {
const response = await axios.get<GitHubStatsResponse>(INSTALL_COUNTS_URL, {
timeout: 10000,
})
if (!response.data?.plugins || !Array.isArray(response.data.plugins)) {
throw new Error('Invalid response format from install counts API')
}
logPluginFetch(
'install_counts',
INSTALL_COUNTS_URL,
'success',
performance.now() - started,
)
return response.data.plugins
} catch (error) {
logPluginFetch(
'install_counts',
INSTALL_COUNTS_URL,
'failure',
performance.now() - started,
classifyFetchError(error),
)
throw error
}
}
/**
* Get plugin install counts as a Map.
* Uses cached data if available and less than 24 hours old.
* Returns null on errors so UI can hide counts rather than show misleading zeros.
*
* @returns Map of plugin ID (name@marketplace) to install count, or null if unavailable
*/
export async function getInstallCounts(): Promise<Map<string, number> | null> {
// Try to load from cache first
const cache = await loadInstallCountsCache()
if (cache) {
logForDebugging('Using cached install counts')
logPluginFetch('install_counts', INSTALL_COUNTS_URL, 'cache_hit', 0)
const map = new Map<string, number>()
for (const entry of cache.counts) {
map.set(entry.plugin, entry.unique_installs)
}
return map
}
// Cache miss or stale - fetch from GitHub
try {
const counts = await fetchInstallCountsFromGitHub()
// Save to cache
const newCache: InstallCountsCache = {
version: INSTALL_COUNTS_CACHE_VERSION,
fetchedAt: new Date().toISOString(),
counts,
}
await saveInstallCountsCache(newCache)
// Convert to Map
const map = new Map<string, number>()
for (const entry of counts) {
map.set(entry.plugin, entry.unique_installs)
}
return map
} catch (error) {
// Log error and return null so UI can hide counts
logError(error)
logForDebugging(`Failed to fetch install counts: ${errorMessage(error)}`)
return null
}
}
/**
* Format an install count for display.
*
* @param count - The raw install count
* @returns Formatted string:
* - <1000: raw number (e.g., "42")
* - >=1000: K suffix with 1 decimal (e.g., "1.2K", "36.2K")
* - >=1000000: M suffix with 1 decimal (e.g., "1.2M")
*/
export function formatInstallCount(count: number): string {
if (count < 1000) {
return String(count)
}
if (count < 1000000) {
const k = count / 1000
// Use toFixed(1) but remove trailing .0
const formatted = k.toFixed(1)
return formatted.endsWith('.0')
? `${formatted.slice(0, -2)}K`
: `${formatted}K`
}
const m = count / 1000000
const formatted = m.toFixed(1)
return formatted.endsWith('.0')
? `${formatted.slice(0, -2)}M`
: `${formatted}M`
}