chore: initialize recovered claude workspace
This commit is contained in:
208
src/utils/plugins/pluginFlagging.ts
Normal file
208
src/utils/plugins/pluginFlagging.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Flagged plugin tracking utilities
|
||||
*
|
||||
* Tracks plugins that were auto-removed because they were delisted from
|
||||
* their marketplace. Data is stored in ~/.claude/plugins/flagged-plugins.json.
|
||||
* Flagged plugins appear in a "Flagged" section in /plugins until the user
|
||||
* dismisses them.
|
||||
*
|
||||
* Uses a module-level cache so that getFlaggedPlugins() can be called
|
||||
* synchronously during React render. The cache is populated on the first
|
||||
* async call (loadFlaggedPlugins or addFlaggedPlugin) and kept in sync
|
||||
* with writes.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto'
|
||||
import { readFile, rename, unlink, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { getFsImplementation } from '../fsOperations.js'
|
||||
import { logError } from '../log.js'
|
||||
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
||||
import { getPluginsDirectory } from './pluginDirectories.js'
|
||||
|
||||
const FLAGGED_PLUGINS_FILENAME = 'flagged-plugins.json'
|
||||
|
||||
export type FlaggedPlugin = {
|
||||
flaggedAt: string
|
||||
seenAt?: string
|
||||
}
|
||||
|
||||
const SEEN_EXPIRY_MS = 48 * 60 * 60 * 1000 // 48 hours
|
||||
|
||||
// Module-level cache — populated by loadFlaggedPlugins(), updated by writes.
|
||||
let cache: Record<string, FlaggedPlugin> | null = null
|
||||
|
||||
function getFlaggedPluginsPath(): string {
|
||||
return join(getPluginsDirectory(), FLAGGED_PLUGINS_FILENAME)
|
||||
}
|
||||
|
||||
function parsePluginsData(content: string): Record<string, FlaggedPlugin> {
|
||||
const parsed = jsonParse(content) as unknown
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
!('plugins' in parsed) ||
|
||||
typeof (parsed as { plugins: unknown }).plugins !== 'object' ||
|
||||
(parsed as { plugins: unknown }).plugins === null
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
const plugins = (parsed as { plugins: Record<string, unknown> }).plugins
|
||||
const result: Record<string, FlaggedPlugin> = {}
|
||||
for (const [id, entry] of Object.entries(plugins)) {
|
||||
if (
|
||||
entry &&
|
||||
typeof entry === 'object' &&
|
||||
'flaggedAt' in entry &&
|
||||
typeof (entry as { flaggedAt: unknown }).flaggedAt === 'string'
|
||||
) {
|
||||
const parsed: FlaggedPlugin = {
|
||||
flaggedAt: (entry as { flaggedAt: string }).flaggedAt,
|
||||
}
|
||||
if (
|
||||
'seenAt' in entry &&
|
||||
typeof (entry as { seenAt: unknown }).seenAt === 'string'
|
||||
) {
|
||||
parsed.seenAt = (entry as { seenAt: string }).seenAt
|
||||
}
|
||||
result[id] = parsed
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function readFromDisk(): Promise<Record<string, FlaggedPlugin>> {
|
||||
try {
|
||||
const content = await readFile(getFlaggedPluginsPath(), {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
return parsePluginsData(content)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeToDisk(
|
||||
plugins: Record<string, FlaggedPlugin>,
|
||||
): Promise<void> {
|
||||
const filePath = getFlaggedPluginsPath()
|
||||
const tempPath = `${filePath}.${randomBytes(8).toString('hex')}.tmp`
|
||||
|
||||
try {
|
||||
await getFsImplementation().mkdir(getPluginsDirectory())
|
||||
|
||||
const content = jsonStringify({ plugins }, null, 2)
|
||||
await writeFile(tempPath, content, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
})
|
||||
await rename(tempPath, filePath)
|
||||
cache = plugins
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
try {
|
||||
await unlink(tempPath)
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load flagged plugins from disk into the module cache.
|
||||
* Must be called (and awaited) before getFlaggedPlugins() returns
|
||||
* meaningful data. Called by useManagePlugins during plugin refresh.
|
||||
*/
|
||||
export async function loadFlaggedPlugins(): Promise<void> {
|
||||
const all = await readFromDisk()
|
||||
const now = Date.now()
|
||||
let changed = false
|
||||
|
||||
for (const [id, entry] of Object.entries(all)) {
|
||||
if (
|
||||
entry.seenAt &&
|
||||
now - new Date(entry.seenAt).getTime() >= SEEN_EXPIRY_MS
|
||||
) {
|
||||
delete all[id]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
cache = all
|
||||
if (changed) {
|
||||
await writeToDisk(all)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all flagged plugins from the in-memory cache.
|
||||
* Returns an empty object if loadFlaggedPlugins() has not been called yet.
|
||||
*/
|
||||
export function getFlaggedPlugins(): Record<string, FlaggedPlugin> {
|
||||
return cache ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a plugin to the flagged list.
|
||||
*
|
||||
* @param pluginId "name@marketplace" format
|
||||
*/
|
||||
export async function addFlaggedPlugin(pluginId: string): Promise<void> {
|
||||
if (cache === null) {
|
||||
cache = await readFromDisk()
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...cache,
|
||||
[pluginId]: {
|
||||
flaggedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
await writeToDisk(updated)
|
||||
logForDebugging(`Flagged plugin: ${pluginId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark flagged plugins as seen. Called when the Installed view renders
|
||||
* flagged plugins. Sets seenAt on entries that don't already have it.
|
||||
* After 48 hours from seenAt, entries are auto-cleared on next load.
|
||||
*/
|
||||
export async function markFlaggedPluginsSeen(
|
||||
pluginIds: string[],
|
||||
): Promise<void> {
|
||||
if (cache === null) {
|
||||
cache = await readFromDisk()
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
let changed = false
|
||||
|
||||
const updated = { ...cache }
|
||||
for (const id of pluginIds) {
|
||||
const entry = updated[id]
|
||||
if (entry && !entry.seenAt) {
|
||||
updated[id] = { ...entry, seenAt: now }
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await writeToDisk(updated)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a plugin from the flagged list. Called when the user dismisses
|
||||
* a flagged plugin notification in /plugins.
|
||||
*/
|
||||
export async function removeFlaggedPlugin(pluginId: string): Promise<void> {
|
||||
if (cache === null) {
|
||||
cache = await readFromDisk()
|
||||
}
|
||||
if (!(pluginId in cache)) return
|
||||
|
||||
const { [pluginId]: _, ...rest } = cache
|
||||
cache = rest
|
||||
await writeToDisk(rest)
|
||||
}
|
||||
Reference in New Issue
Block a user