/ utils / plugins / pluginFlagging.ts
pluginFlagging.ts
  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  
 15  import { randomBytes } from 'crypto'
 16  import { readFile, rename, unlink, writeFile } from 'fs/promises'
 17  import { join } from 'path'
 18  import { logForDebugging } from '../debug.js'
 19  import { getFsImplementation } from '../fsOperations.js'
 20  import { logError } from '../log.js'
 21  import { jsonParse, jsonStringify } from '../slowOperations.js'
 22  import { getPluginsDirectory } from './pluginDirectories.js'
 23  
 24  const FLAGGED_PLUGINS_FILENAME = 'flagged-plugins.json'
 25  
 26  export type FlaggedPlugin = {
 27    flaggedAt: string
 28    seenAt?: string
 29  }
 30  
 31  const SEEN_EXPIRY_MS = 48 * 60 * 60 * 1000 // 48 hours
 32  
 33  // Module-level cache — populated by loadFlaggedPlugins(), updated by writes.
 34  let cache: Record<string, FlaggedPlugin> | null = null
 35  
 36  function getFlaggedPluginsPath(): string {
 37    return join(getPluginsDirectory(), FLAGGED_PLUGINS_FILENAME)
 38  }
 39  
 40  function 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  
 75  async 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  
 86  async 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   */
117  export 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   */
142  export 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   */
151  export 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   */
172  export 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   */
199  export 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  }