/ utils / plugins / zipCacheAdapters.ts
zipCacheAdapters.ts
  1  /**
  2   * Zip Cache Adapters
  3   *
  4   * I/O helpers for the plugin zip cache. These functions handle reading/writing
  5   * zip-cache-local metadata files, extracting ZIPs to session directories,
  6   * and creating ZIPs for newly installed plugins.
  7   *
  8   * The zip cache stores data on a mounted volume (e.g., Filestore) that persists
  9   * across ephemeral container lifetimes. The session cache is a local temp dir
 10   * for extracted plugins used during a single session.
 11   */
 12  
 13  import { readFile } from 'fs/promises'
 14  import { join } from 'path'
 15  import { logForDebugging } from '../debug.js'
 16  import { jsonParse, jsonStringify } from '../slowOperations.js'
 17  import { loadKnownMarketplacesConfigSafe } from './marketplaceManager.js'
 18  import {
 19    type KnownMarketplacesFile,
 20    KnownMarketplacesFileSchema,
 21    type PluginMarketplace,
 22    PluginMarketplaceSchema,
 23  } from './schemas.js'
 24  import {
 25    atomicWriteToZipCache,
 26    getMarketplaceJsonRelativePath,
 27    getPluginZipCachePath,
 28    getZipCacheKnownMarketplacesPath,
 29  } from './zipCache.js'
 30  
 31  // ── Metadata I/O ──
 32  
 33  /**
 34   * Read known_marketplaces.json from the zip cache.
 35   * Returns empty object if file doesn't exist, can't be parsed, or fails schema
 36   * validation (data comes from a shared mounted volume — other containers may write).
 37   */
 38  export async function readZipCacheKnownMarketplaces(): Promise<KnownMarketplacesFile> {
 39    try {
 40      const content = await readFile(getZipCacheKnownMarketplacesPath(), 'utf-8')
 41      const parsed = KnownMarketplacesFileSchema().safeParse(jsonParse(content))
 42      if (!parsed.success) {
 43        logForDebugging(
 44          `Invalid known_marketplaces.json in zip cache: ${parsed.error.message}`,
 45          { level: 'error' },
 46        )
 47        return {}
 48      }
 49      return parsed.data
 50    } catch {
 51      return {}
 52    }
 53  }
 54  
 55  /**
 56   * Write known_marketplaces.json to the zip cache atomically.
 57   */
 58  export async function writeZipCacheKnownMarketplaces(
 59    data: KnownMarketplacesFile,
 60  ): Promise<void> {
 61    await atomicWriteToZipCache(
 62      getZipCacheKnownMarketplacesPath(),
 63      jsonStringify(data, null, 2),
 64    )
 65  }
 66  
 67  // ── Marketplace JSON ──
 68  
 69  /**
 70   * Read a marketplace JSON file from the zip cache.
 71   */
 72  export async function readMarketplaceJson(
 73    marketplaceName: string,
 74  ): Promise<PluginMarketplace | null> {
 75    const zipCachePath = getPluginZipCachePath()
 76    if (!zipCachePath) {
 77      return null
 78    }
 79    const relPath = getMarketplaceJsonRelativePath(marketplaceName)
 80    const fullPath = join(zipCachePath, relPath)
 81    try {
 82      const content = await readFile(fullPath, 'utf-8')
 83      const parsed = jsonParse(content)
 84      const result = PluginMarketplaceSchema().safeParse(parsed)
 85      if (result.success) {
 86        return result.data
 87      }
 88      logForDebugging(
 89        `Invalid marketplace JSON for ${marketplaceName}: ${result.error}`,
 90      )
 91      return null
 92    } catch {
 93      return null
 94    }
 95  }
 96  
 97  /**
 98   * Save a marketplace JSON to the zip cache from its install location.
 99   */
100  export async function saveMarketplaceJsonToZipCache(
101    marketplaceName: string,
102    installLocation: string,
103  ): Promise<void> {
104    const zipCachePath = getPluginZipCachePath()
105    if (!zipCachePath) {
106      return
107    }
108    const content = await readMarketplaceJsonContent(installLocation)
109    if (content !== null) {
110      const relPath = getMarketplaceJsonRelativePath(marketplaceName)
111      await atomicWriteToZipCache(join(zipCachePath, relPath), content)
112    }
113  }
114  
115  /**
116   * Read marketplace.json content from a cloned marketplace directory or file.
117   * For directory sources: checks .claude-plugin/marketplace.json, marketplace.json
118   * For URL sources: the installLocation IS the marketplace JSON file itself.
119   */
120  async function readMarketplaceJsonContent(dir: string): Promise<string | null> {
121    const candidates = [
122      join(dir, '.claude-plugin', 'marketplace.json'),
123      join(dir, 'marketplace.json'),
124      dir, // For URL sources, installLocation IS the marketplace JSON file
125    ]
126    for (const candidate of candidates) {
127      try {
128        return await readFile(candidate, 'utf-8')
129      } catch {
130        // ENOENT (doesn't exist) or EISDIR (directory) — try next
131      }
132    }
133    return null
134  }
135  
136  /**
137   * Sync marketplace data to zip cache for offline access.
138   * Saves marketplace JSONs and merges with previously cached data
139   * so ephemeral containers can access marketplaces without re-cloning.
140   */
141  export async function syncMarketplacesToZipCache(): Promise<void> {
142    // Read-only iteration — Safe variant so a corrupted config doesn't throw.
143    // This runs during startup paths; a throw here cascades to the same
144    // try-block that catches loadAllPlugins failures.
145    const knownMarketplaces = await loadKnownMarketplacesConfigSafe()
146  
147    // Save marketplace JSONs to zip cache
148    for (const [name, entry] of Object.entries(knownMarketplaces)) {
149      if (!entry.installLocation) continue
150      try {
151        await saveMarketplaceJsonToZipCache(name, entry.installLocation)
152      } catch (error) {
153        logForDebugging(`Failed to save marketplace JSON for ${name}: ${error}`)
154      }
155    }
156  
157    // Merge with previously cached data (ephemeral containers lose global config)
158    const zipCacheKnownMarketplaces = await readZipCacheKnownMarketplaces()
159    const mergedKnownMarketplaces: KnownMarketplacesFile = {
160      ...zipCacheKnownMarketplaces,
161      ...knownMarketplaces,
162    }
163    await writeZipCacheKnownMarketplaces(mergedKnownMarketplaces)
164  }