/** * Plugin Version Calculation Module * * Handles version calculation for plugins from various sources. * Versions are used for versioned cache paths and update detection. * * Version sources (in order of preference): * 1. Explicit version from plugin.json * 2. Git commit SHA (for git/github sources) * 3. Fallback timestamp for local sources */ import { createHash } from 'crypto' import { logForDebugging } from '../debug.js' import { getHeadForDir } from '../git/gitFilesystem.js' import type { PluginManifest, PluginSource } from './schemas.js' /** * Calculate the version for a plugin based on its source. * * Version sources (in order of priority): * 1. plugin.json version field (highest priority) * 2. Provided version (typically from marketplace entry) * 3. Git commit SHA from install path * 4. 'unknown' as last resort * * @param pluginId - Plugin identifier (e.g., "plugin@marketplace") * @param source - Plugin source configuration (used for git-subdir path hashing) * @param manifest - Optional plugin manifest with version field * @param installPath - Optional path to installed plugin (for git SHA extraction) * @param providedVersion - Optional version from marketplace entry or caller * @param gitCommitSha - Optional pre-resolved git SHA (for sources like * git-subdir where the clone is discarded and the install path has no .git) * @returns Version string (semver, short SHA, or 'unknown') */ export async function calculatePluginVersion( pluginId: string, source: PluginSource, manifest?: PluginManifest, installPath?: string, providedVersion?: string, gitCommitSha?: string, ): Promise { // 1. Use explicit version from plugin.json if available if (manifest?.version) { logForDebugging( `Using manifest version for ${pluginId}: ${manifest.version}`, ) return manifest.version } // 2. Use provided version (typically from marketplace entry) if (providedVersion) { logForDebugging( `Using provided version for ${pluginId}: ${providedVersion}`, ) return providedVersion } // 3. Use pre-resolved git SHA if caller captured it before discarding the clone if (gitCommitSha) { const shortSha = gitCommitSha.substring(0, 12) if (typeof source === 'object' && source.source === 'git-subdir') { // Encode the subdir path in the version so cache keys differ when // marketplace.json's `path` changes but the monorepo SHA doesn't. // Without this, two plugins at different subdirs of the same commit // collide at cache//

// and serve each other's trees. // // Normalization MUST match the squashfs cron byte-for-byte: // 1. backslash → forward slash // 2. strip one leading `./` // 3. strip all trailing `/` // 4. UTF-8 sha256, first 8 hex chars // See api/…/plugins_official_squashfs/job.py _validate_subdir(). const normPath = source.path .replace(/\\/g, '/') .replace(/^\.\//, '') .replace(/\/+$/, '') const pathHash = createHash('sha256') .update(normPath) .digest('hex') .substring(0, 8) const v = `${shortSha}-${pathHash}` logForDebugging( `Using git-subdir SHA+path version for ${pluginId}: ${v} (path=${normPath})`, ) return v } logForDebugging(`Using pre-resolved git SHA for ${pluginId}: ${shortSha}`) return shortSha } // 4. Try to get git SHA from install path if (installPath) { const sha = await getGitCommitSha(installPath) if (sha) { const shortSha = sha.substring(0, 12) logForDebugging(`Using git SHA for ${pluginId}: ${shortSha}`) return shortSha } } // 5. Return 'unknown' as last resort logForDebugging(`No version found for ${pluginId}, using 'unknown'`) return 'unknown' } /** * Get the git commit SHA for a directory. * * @param dirPath - Path to directory (should be a git repository) * @returns Full commit SHA or null if not a git repo */ export function getGitCommitSha(dirPath: string): Promise { return getHeadForDir(dirPath) } /** * Extract version from a versioned cache path. * * Given a path like `~/.claude/plugins/cache/marketplace/plugin/1.0.0`, * extracts and returns `1.0.0`. * * @param installPath - Full path to plugin installation * @returns Version string from path, or null if not a versioned path */ export function getVersionFromPath(installPath: string): string | null { // Versioned paths have format: .../plugins/cache/marketplace/plugin/version/ const parts = installPath.split('/').filter(Boolean) // Find 'cache' index to determine depth const cacheIndex = parts.findIndex( (part, i) => part === 'cache' && parts[i - 1] === 'plugins', ) if (cacheIndex === -1) { return null } // Versioned path has 3 components after 'cache': marketplace/plugin/version const componentsAfterCache = parts.slice(cacheIndex + 1) if (componentsAfterCache.length >= 3) { return componentsAfterCache[2] || null } return null } /** * Check if a path is a versioned plugin path. * * @param path - Path to check * @returns True if path follows versioned structure */ export function isVersionedPath(path: string): boolean { return getVersionFromPath(path) !== null }