Download src/utils/plugins/zipCache.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 13.2 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/utils/plugins/zipCache.ts
- Command line
-
hf download hf://chenbhao/codev/src/utils/plugins/zipCache.ts
-
curl -L -o zipCache.ts https://huggingface.co/chenbhao/codev/resolve/main/src/utils/plugins/zipCache.ts
13.2 kB
| /** | |
| * Plugin Zip Cache Module | |
| * | |
| * Manages plugins as ZIP archives in a mounted directory (e.g., Filestore). | |
| * When CLAUDE_CODE_PLUGIN_USE_ZIP_CACHE is enabled and CLAUDE_CODE_PLUGIN_CACHE_DIR | |
| * is set, plugins are stored as ZIPs in that directory and extracted to a | |
| * session-local temp directory at startup. | |
| * | |
| * Limitations: | |
| * - Only headless mode is supported | |
| * - All settings sources are used (same as normal plugin flow) | |
| * - Only github, git, and url marketplace sources are supported | |
| * - Only strict:true marketplace entries are supported | |
| * - Auto-update is non-blocking (background, does not affect current session) | |
| * | |
| * Directory structure of the zip cache: | |
| * /mnt/plugins-cache/ | |
| * ├── known_marketplaces.json | |
| * ├── installed_plugins.json | |
| * ├── marketplaces/ | |
| * │ ├── official-marketplace.json | |
| * │ └── company-marketplace.json | |
| * └── plugins/ | |
| * ├── official-marketplace/ | |
| * │ └── plugin-a/ | |
| * │ └── 1.0.0.zip | |
| * └── company-marketplace/ | |
| * └── plugin-b/ | |
| * └── 2.1.3.zip | |
| */ | |
| import { randomBytes } from 'crypto' | |
| import { | |
| chmod, | |
| lstat, | |
| readdir, | |
| readFile, | |
| rename, | |
| rm, | |
| stat, | |
| writeFile, | |
| } from 'fs/promises' | |
| import { tmpdir } from 'os' | |
| import { basename, dirname, join } from 'path' | |
| import { logForDebugging } from '../debug.js' | |
| import { parseZipModes, unzipFile } from '../dxt/zip.js' | |
| import { isEnvTruthy } from '../envUtils.js' | |
| import { getFsImplementation } from '../fsOperations.js' | |
| import { expandTilde } from '../permissions/pathValidation.js' | |
| import type { MarketplaceSource } from './schemas.js' | |
| /** | |
| * Check if the plugin zip cache mode is enabled. | |
| */ | |
| export function isPluginZipCacheEnabled(): boolean { | |
| return isEnvTruthy(process.env.CLAUDE_CODE_PLUGIN_USE_ZIP_CACHE) | |
| } | |
| /** | |
| * Get the path to the zip cache directory. | |
| * Requires CLAUDE_CODE_PLUGIN_CACHE_DIR to be set. | |
| * Returns undefined if zip cache is not enabled. | |
| */ | |
| export function getPluginZipCachePath(): string | undefined { | |
| if (!isPluginZipCacheEnabled()) { | |
| return undefined | |
| } | |
| const dir = process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR | |
| return dir ? expandTilde(dir) : undefined | |
| } | |
| /** | |
| * Get the path to known_marketplaces.json in the zip cache. | |
| */ | |
| export function getZipCacheKnownMarketplacesPath(): string { | |
| const cachePath = getPluginZipCachePath() | |
| if (!cachePath) { | |
| throw new Error('Plugin zip cache is not enabled') | |
| } | |
| return join(cachePath, 'known_marketplaces.json') | |
| } | |
| /** | |
| * Get the path to installed_plugins.json in the zip cache. | |
| */ | |
| export function getZipCacheInstalledPluginsPath(): string { | |
| const cachePath = getPluginZipCachePath() | |
| if (!cachePath) { | |
| throw new Error('Plugin zip cache is not enabled') | |
| } | |
| return join(cachePath, 'installed_plugins.json') | |
| } | |
| /** | |
| * Get the marketplaces directory within the zip cache. | |
| */ | |
| export function getZipCacheMarketplacesDir(): string { | |
| const cachePath = getPluginZipCachePath() | |
| if (!cachePath) { | |
| throw new Error('Plugin zip cache is not enabled') | |
| } | |
| return join(cachePath, 'marketplaces') | |
| } | |
| /** | |
| * Get the plugins directory within the zip cache. | |
| */ | |
| export function getZipCachePluginsDir(): string { | |
| const cachePath = getPluginZipCachePath() | |
| if (!cachePath) { | |
| throw new Error('Plugin zip cache is not enabled') | |
| } | |
| return join(cachePath, 'plugins') | |
| } | |
| // Session plugin cache: a temp directory on local disk (NOT in the mounted zip cache) | |
| // that holds extracted plugins for the duration of the session. | |
| let sessionPluginCachePath: string | null = null | |
| let sessionPluginCachePromise: Promise<string> | null = null | |
| /** | |
| * Get or create the session plugin cache directory. | |
| * This is a temp directory on local disk where plugins are extracted for the session. | |
| */ | |
| export async function getSessionPluginCachePath(): Promise<string> { | |
| if (sessionPluginCachePath) { | |
| return sessionPluginCachePath | |
| } | |
| if (!sessionPluginCachePromise) { | |
| sessionPluginCachePromise = (async () => { | |
| const suffix = randomBytes(8).toString('hex') | |
| const dir = join(tmpdir(), `claude-plugin-session-${suffix}`) | |
| await getFsImplementation().mkdir(dir) | |
| sessionPluginCachePath = dir | |
| logForDebugging(`Created session plugin cache at ${dir}`) | |
| return dir | |
| })() | |
| } | |
| return sessionPluginCachePromise | |
| } | |
| /** | |
| * Clean up the session plugin cache directory. | |
| * Should be called when the session ends. | |
| */ | |
| export async function cleanupSessionPluginCache(): Promise<void> { | |
| if (!sessionPluginCachePath) { | |
| return | |
| } | |
| try { | |
| await rm(sessionPluginCachePath, { recursive: true, force: true }) | |
| logForDebugging( | |
| `Cleaned up session plugin cache at ${sessionPluginCachePath}`, | |
| ) | |
| } catch (error) { | |
| logForDebugging(`Failed to clean up session plugin cache: ${error}`) | |
| } finally { | |
| sessionPluginCachePath = null | |
| sessionPluginCachePromise = null | |
| } | |
| } | |
| /** | |
| * Reset the session plugin cache path (for testing). | |
| */ | |
| export function resetSessionPluginCache(): void { | |
| sessionPluginCachePath = null | |
| sessionPluginCachePromise = null | |
| } | |
| /** | |
| * Write data to a file in the zip cache atomically. | |
| * Writes to a temp file in the same directory, then renames. | |
| */ | |
| export async function atomicWriteToZipCache( | |
| targetPath: string, | |
| data: string | Uint8Array, | |
| ): Promise<void> { | |
| const dir = dirname(targetPath) | |
| await getFsImplementation().mkdir(dir) | |
| const tmpName = `.${basename(targetPath)}.tmp.${randomBytes(4).toString('hex')}` | |
| const tmpPath = join(dir, tmpName) | |
| try { | |
| if (typeof data === 'string') { | |
| await writeFile(tmpPath, data, { encoding: 'utf-8' }) | |
| } else { | |
| await writeFile(tmpPath, data) | |
| } | |
| await rename(tmpPath, targetPath) | |
| } catch (error) { | |
| // Clean up tmp file on failure | |
| try { | |
| await rm(tmpPath, { force: true }) | |
| } catch { | |
| // ignore cleanup errors | |
| } | |
| throw error | |
| } | |
| } | |
| // fflate's ZippableFile tuple form: [data, opts]. Using the tuple lets us | |
| // store {os, attrs} so parseZipModes can recover exec bits on extraction. | |
| type ZipEntry = [Uint8Array, { os: number; attrs: number }] | |
| /** | |
| * Create a ZIP archive from a directory. | |
| * Resolves symlinks to actual file contents (replaces symlinks with real data). | |
| * Stores Unix mode bits in external_attr so extractZipToDirectory can restore | |
| * +x — otherwise the round-trip (git clone → zip → extract) loses exec bits. | |
| * | |
| * @param sourceDir - Directory to zip | |
| * @returns ZIP file as Uint8Array | |
| */ | |
| export async function createZipFromDirectory( | |
| sourceDir: string, | |
| ): Promise<Uint8Array> { | |
| const files: Record<string, ZipEntry> = {} | |
| const visited = new Set<string>() | |
| await collectFilesForZip(sourceDir, '', files, visited) | |
| const { zipSync } = await import('fflate') | |
| const zipData = zipSync(files, { level: 6 }) | |
| logForDebugging( | |
| `Created ZIP from ${sourceDir}: ${Object.keys(files).length} files, ${zipData.length} bytes`, | |
| ) | |
| return zipData | |
| } | |
| /** | |
| * Recursively collect files from a directory for zipping. | |
| * Uses lstat to detect symlinks and tracks visited inodes for cycle detection. | |
| */ | |
| async function collectFilesForZip( | |
| baseDir: string, | |
| relativePath: string, | |
| files: Record<string, ZipEntry>, | |
| visited: Set<string>, | |
| ): Promise<void> { | |
| const currentDir = relativePath ? join(baseDir, relativePath) : baseDir | |
| let entries: string[] | |
| try { | |
| entries = await readdir(currentDir) | |
| } catch { | |
| return | |
| } | |
| // Track visited directories by dev+ino to detect symlink cycles. | |
| // bigint: true is required — on Windows NTFS, the file index packs a 16-bit | |
| // sequence number into the high bits. Once that sequence exceeds ~32 (very | |
| // common on a busy CI runner that churns through temp files), the value | |
| // exceeds Number.MAX_SAFE_INTEGER and two adjacent directories round to the | |
| // same JS number, causing subdirs to be silently skipped as "cycles". This | |
| // broke the round-trip test on Windows CI when sharding shuffled which tests | |
| // ran first and pushed MFT sequence numbers over the precision cliff. | |
| // See also: markdownConfigLoader.ts getFileIdentity, anthropics/claude-code#13893 | |
| try { | |
| const dirStat = await stat(currentDir, { bigint: true }) | |
| // ReFS (Dev Drive), NFS, some FUSE mounts report dev=0 and ino=0 for | |
| // everything. Fail open: skip cycle detection rather than skip the | |
| // directory. We already skip symlinked directories unconditionally below, | |
| // so the only cycle left here is a bind mount, which we accept. | |
| if (dirStat.dev !== 0n || dirStat.ino !== 0n) { | |
| const key = `${dirStat.dev}:${dirStat.ino}` | |
| if (visited.has(key)) { | |
| logForDebugging(`Skipping symlink cycle at ${currentDir}`) | |
| return | |
| } | |
| visited.add(key) | |
| } | |
| } catch { | |
| return | |
| } | |
| for (const entry of entries) { | |
| // Skip hidden files that are git-related | |
| if (entry === '.git') { | |
| continue | |
| } | |
| const fullPath = join(currentDir, entry) | |
| const relPath = relativePath ? `${relativePath}/${entry}` : entry | |
| let fileStat | |
| try { | |
| fileStat = await lstat(fullPath) | |
| } catch { | |
| continue | |
| } | |
| // Skip symlinked directories (follow symlinked files) | |
| if (fileStat.isSymbolicLink()) { | |
| try { | |
| const targetStat = await stat(fullPath) | |
| if (targetStat.isDirectory()) { | |
| continue | |
| } | |
| // Symlinked file — read its contents below | |
| fileStat = targetStat | |
| } catch { | |
| continue // broken symlink | |
| } | |
| } | |
| if (fileStat.isDirectory()) { | |
| await collectFilesForZip(baseDir, relPath, files, visited) | |
| } else if (fileStat.isFile()) { | |
| try { | |
| const content = await readFile(fullPath) | |
| // os=3 (Unix) + st_mode in high 16 bits of external_attr — this is | |
| // what parseZipModes reads back on extraction. fileStat is already | |
| // in hand from the lstat/stat above, so no extra syscall. | |
| files[relPath] = [ | |
| new Uint8Array(content), | |
| { os: 3, attrs: (fileStat.mode & 0xffff) << 16 }, | |
| ] | |
| } catch (error) { | |
| logForDebugging(`Failed to read file for zip: ${relPath}: ${error}`) | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Extract a ZIP file to a target directory. | |
| * | |
| * @param zipPath - Path to the ZIP file | |
| * @param targetDir - Directory to extract into | |
| */ | |
| export async function extractZipToDirectory( | |
| zipPath: string, | |
| targetDir: string, | |
| ): Promise<void> { | |
| const zipBuf = await getFsImplementation().readFileBytes(zipPath) | |
| const files = await unzipFile(zipBuf) | |
| // fflate doesn't surface external_attr — parse the central directory so | |
| // exec bits survive extraction (hooks/scripts need +x to run via `sh -c`). | |
| const modes = parseZipModes(zipBuf) | |
| await getFsImplementation().mkdir(targetDir) | |
| for (const [relPath, data] of Object.entries(files)) { | |
| // Skip directory entries (trailing slash) | |
| if (relPath.endsWith('/')) { | |
| await getFsImplementation().mkdir(join(targetDir, relPath)) | |
| continue | |
| } | |
| const fullPath = join(targetDir, relPath) | |
| await getFsImplementation().mkdir(dirname(fullPath)) | |
| await writeFile(fullPath, data) | |
| const mode = modes[relPath] | |
| if (mode && mode & 0o111) { | |
| // Swallow EPERM/ENOTSUP (NFS root_squash, some FUSE mounts) — losing +x | |
| // is the pre-PR behavior and better than aborting mid-extraction. | |
| await chmod(fullPath, mode & 0o777).catch(() => {}) | |
| } | |
| } | |
| logForDebugging( | |
| `Extracted ZIP to ${targetDir}: ${Object.keys(files).length} entries`, | |
| ) | |
| } | |
| /** | |
| * Convert a plugin directory to a ZIP in-place: zip → atomic write → delete dir. | |
| * Both call sites (cacheAndRegisterPlugin, copyPluginToVersionedCache) need the | |
| * same sequence; getting it wrong (non-atomic write, forgetting rm) corrupts cache. | |
| */ | |
| export async function convertDirectoryToZipInPlace( | |
| dirPath: string, | |
| zipPath: string, | |
| ): Promise<void> { | |
| const zipData = await createZipFromDirectory(dirPath) | |
| await atomicWriteToZipCache(zipPath, zipData) | |
| await rm(dirPath, { recursive: true, force: true }) | |
| } | |
| /** | |
| * Get the relative path for a marketplace JSON file within the zip cache. | |
| * Format: marketplaces/{marketplace-name}.json | |
| */ | |
| export function getMarketplaceJsonRelativePath( | |
| marketplaceName: string, | |
| ): string { | |
| const sanitized = marketplaceName.replace(/[^a-zA-Z0-9\-_]/g, '-') | |
| return join('marketplaces', `${sanitized}.json`) | |
| } | |
| /** | |
| * Check if a marketplace source type is supported by zip cache mode. | |
| * | |
| * Supported sources write to `join(cacheDir, name)` — syncMarketplacesToZipCache | |
| * reads marketplace.json from that installLocation, source-type-agnostic. | |
| * - github/git/url: clone to temp, rename into cacheDir | |
| * - settings: write synthetic marketplace.json directly to cacheDir (no fetch) | |
| * | |
| * Excluded: file/directory (installLocation is the user's path OUTSIDE cacheDir — | |
| * nonsensical in ephemeral containers), npm (node_modules bloat on Filestore mount). | |
| */ | |
| export function isMarketplaceSourceSupportedByZipCache( | |
| source: MarketplaceSource, | |
| ): boolean { | |
| return ['github', 'git', 'url', 'settings'].includes(source.source) | |
| } | |