source dump of claude code
at main 208 lines 5.6 kB view raw
1/** 2 * Flagged plugin tracking utilities 3 * 4 * Tracks plugins that were auto-removed because they were delisted from 5 * their marketplace. Data is stored in ~/.claude/plugins/flagged-plugins.json. 6 * Flagged plugins appear in a "Flagged" section in /plugins until the user 7 * dismisses them. 8 * 9 * Uses a module-level cache so that getFlaggedPlugins() can be called 10 * synchronously during React render. The cache is populated on the first 11 * async call (loadFlaggedPlugins or addFlaggedPlugin) and kept in sync 12 * with writes. 13 */ 14 15import { randomBytes } from 'crypto' 16import { readFile, rename, unlink, writeFile } from 'fs/promises' 17import { join } from 'path' 18import { logForDebugging } from '../debug.js' 19import { getFsImplementation } from '../fsOperations.js' 20import { logError } from '../log.js' 21import { jsonParse, jsonStringify } from '../slowOperations.js' 22import { getPluginsDirectory } from './pluginDirectories.js' 23 24const FLAGGED_PLUGINS_FILENAME = 'flagged-plugins.json' 25 26export type FlaggedPlugin = { 27 flaggedAt: string 28 seenAt?: string 29} 30 31const SEEN_EXPIRY_MS = 48 * 60 * 60 * 1000 // 48 hours 32 33// Module-level cache — populated by loadFlaggedPlugins(), updated by writes. 34let cache: Record<string, FlaggedPlugin> | null = null 35 36function getFlaggedPluginsPath(): string { 37 return join(getPluginsDirectory(), FLAGGED_PLUGINS_FILENAME) 38} 39 40function parsePluginsData(content: string): Record<string, FlaggedPlugin> { 41 const parsed = jsonParse(content) as unknown 42 if ( 43 typeof parsed !== 'object' || 44 parsed === null || 45 !('plugins' in parsed) || 46 typeof (parsed as { plugins: unknown }).plugins !== 'object' || 47 (parsed as { plugins: unknown }).plugins === null 48 ) { 49 return {} 50 } 51 const plugins = (parsed as { plugins: Record<string, unknown> }).plugins 52 const result: Record<string, FlaggedPlugin> = {} 53 for (const [id, entry] of Object.entries(plugins)) { 54 if ( 55 entry && 56 typeof entry === 'object' && 57 'flaggedAt' in entry && 58 typeof (entry as { flaggedAt: unknown }).flaggedAt === 'string' 59 ) { 60 const parsed: FlaggedPlugin = { 61 flaggedAt: (entry as { flaggedAt: string }).flaggedAt, 62 } 63 if ( 64 'seenAt' in entry && 65 typeof (entry as { seenAt: unknown }).seenAt === 'string' 66 ) { 67 parsed.seenAt = (entry as { seenAt: string }).seenAt 68 } 69 result[id] = parsed 70 } 71 } 72 return result 73} 74 75async function readFromDisk(): Promise<Record<string, FlaggedPlugin>> { 76 try { 77 const content = await readFile(getFlaggedPluginsPath(), { 78 encoding: 'utf-8', 79 }) 80 return parsePluginsData(content) 81 } catch { 82 return {} 83 } 84} 85 86async function writeToDisk( 87 plugins: Record<string, FlaggedPlugin>, 88): Promise<void> { 89 const filePath = getFlaggedPluginsPath() 90 const tempPath = `${filePath}.${randomBytes(8).toString('hex')}.tmp` 91 92 try { 93 await getFsImplementation().mkdir(getPluginsDirectory()) 94 95 const content = jsonStringify({ plugins }, null, 2) 96 await writeFile(tempPath, content, { 97 encoding: 'utf-8', 98 mode: 0o600, 99 }) 100 await rename(tempPath, filePath) 101 cache = plugins 102 } catch (error) { 103 logError(error) 104 try { 105 await unlink(tempPath) 106 } catch { 107 // Ignore cleanup errors 108 } 109 } 110} 111 112/** 113 * Load flagged plugins from disk into the module cache. 114 * Must be called (and awaited) before getFlaggedPlugins() returns 115 * meaningful data. Called by useManagePlugins during plugin refresh. 116 */ 117export async function loadFlaggedPlugins(): Promise<void> { 118 const all = await readFromDisk() 119 const now = Date.now() 120 let changed = false 121 122 for (const [id, entry] of Object.entries(all)) { 123 if ( 124 entry.seenAt && 125 now - new Date(entry.seenAt).getTime() >= SEEN_EXPIRY_MS 126 ) { 127 delete all[id] 128 changed = true 129 } 130 } 131 132 cache = all 133 if (changed) { 134 await writeToDisk(all) 135 } 136} 137 138/** 139 * Get all flagged plugins from the in-memory cache. 140 * Returns an empty object if loadFlaggedPlugins() has not been called yet. 141 */ 142export function getFlaggedPlugins(): Record<string, FlaggedPlugin> { 143 return cache ?? {} 144} 145 146/** 147 * Add a plugin to the flagged list. 148 * 149 * @param pluginId "name@marketplace" format 150 */ 151export async function addFlaggedPlugin(pluginId: string): Promise<void> { 152 if (cache === null) { 153 cache = await readFromDisk() 154 } 155 156 const updated = { 157 ...cache, 158 [pluginId]: { 159 flaggedAt: new Date().toISOString(), 160 }, 161 } 162 163 await writeToDisk(updated) 164 logForDebugging(`Flagged plugin: ${pluginId}`) 165} 166 167/** 168 * Mark flagged plugins as seen. Called when the Installed view renders 169 * flagged plugins. Sets seenAt on entries that don't already have it. 170 * After 48 hours from seenAt, entries are auto-cleared on next load. 171 */ 172export async function markFlaggedPluginsSeen( 173 pluginIds: string[], 174): Promise<void> { 175 if (cache === null) { 176 cache = await readFromDisk() 177 } 178 const now = new Date().toISOString() 179 let changed = false 180 181 const updated = { ...cache } 182 for (const id of pluginIds) { 183 const entry = updated[id] 184 if (entry && !entry.seenAt) { 185 updated[id] = { ...entry, seenAt: now } 186 changed = true 187 } 188 } 189 190 if (changed) { 191 await writeToDisk(updated) 192 } 193} 194 195/** 196 * Remove a plugin from the flagged list. Called when the user dismisses 197 * a flagged plugin notification in /plugins. 198 */ 199export async function removeFlaggedPlugin(pluginId: string): Promise<void> { 200 if (cache === null) { 201 cache = await readFromDisk() 202 } 203 if (!(pluginId in cache)) return 204 205 const { [pluginId]: _, ...rest } = cache 206 cache = rest 207 await writeToDisk(rest) 208}