/ utils / plugins / pluginVersioning.ts
pluginVersioning.ts
  1  /**
  2   * Plugin Version Calculation Module
  3   *
  4   * Handles version calculation for plugins from various sources.
  5   * Versions are used for versioned cache paths and update detection.
  6   *
  7   * Version sources (in order of preference):
  8   * 1. Explicit version from plugin.json
  9   * 2. Git commit SHA (for git/github sources)
 10   * 3. Fallback timestamp for local sources
 11   */
 12  
 13  import { createHash } from 'crypto'
 14  import { logForDebugging } from '../debug.js'
 15  import { getHeadForDir } from '../git/gitFilesystem.js'
 16  import type { PluginManifest, PluginSource } from './schemas.js'
 17  
 18  /**
 19   * Calculate the version for a plugin based on its source.
 20   *
 21   * Version sources (in order of priority):
 22   * 1. plugin.json version field (highest priority)
 23   * 2. Provided version (typically from marketplace entry)
 24   * 3. Git commit SHA from install path
 25   * 4. 'unknown' as last resort
 26   *
 27   * @param pluginId - Plugin identifier (e.g., "plugin@marketplace")
 28   * @param source - Plugin source configuration (used for git-subdir path hashing)
 29   * @param manifest - Optional plugin manifest with version field
 30   * @param installPath - Optional path to installed plugin (for git SHA extraction)
 31   * @param providedVersion - Optional version from marketplace entry or caller
 32   * @param gitCommitSha - Optional pre-resolved git SHA (for sources like
 33   *   git-subdir where the clone is discarded and the install path has no .git)
 34   * @returns Version string (semver, short SHA, or 'unknown')
 35   */
 36  export async function calculatePluginVersion(
 37    pluginId: string,
 38    source: PluginSource,
 39    manifest?: PluginManifest,
 40    installPath?: string,
 41    providedVersion?: string,
 42    gitCommitSha?: string,
 43  ): Promise<string> {
 44    // 1. Use explicit version from plugin.json if available
 45    if (manifest?.version) {
 46      logForDebugging(
 47        `Using manifest version for ${pluginId}: ${manifest.version}`,
 48      )
 49      return manifest.version
 50    }
 51  
 52    // 2. Use provided version (typically from marketplace entry)
 53    if (providedVersion) {
 54      logForDebugging(
 55        `Using provided version for ${pluginId}: ${providedVersion}`,
 56      )
 57      return providedVersion
 58    }
 59  
 60    // 3. Use pre-resolved git SHA if caller captured it before discarding the clone
 61    if (gitCommitSha) {
 62      const shortSha = gitCommitSha.substring(0, 12)
 63      if (typeof source === 'object' && source.source === 'git-subdir') {
 64        // Encode the subdir path in the version so cache keys differ when
 65        // marketplace.json's `path` changes but the monorepo SHA doesn't.
 66        // Without this, two plugins at different subdirs of the same commit
 67        // collide at cache/<m>/<p>/<sha>/ and serve each other's trees.
 68        //
 69        // Normalization MUST match the squashfs cron byte-for-byte:
 70        //   1. backslash → forward slash
 71        //   2. strip one leading `./`
 72        //   3. strip all trailing `/`
 73        //   4. UTF-8 sha256, first 8 hex chars
 74        // See api/…/plugins_official_squashfs/job.py _validate_subdir().
 75        const normPath = source.path
 76          .replace(/\\/g, '/')
 77          .replace(/^\.\//, '')
 78          .replace(/\/+$/, '')
 79        const pathHash = createHash('sha256')
 80          .update(normPath)
 81          .digest('hex')
 82          .substring(0, 8)
 83        const v = `${shortSha}-${pathHash}`
 84        logForDebugging(
 85          `Using git-subdir SHA+path version for ${pluginId}: ${v} (path=${normPath})`,
 86        )
 87        return v
 88      }
 89      logForDebugging(`Using pre-resolved git SHA for ${pluginId}: ${shortSha}`)
 90      return shortSha
 91    }
 92  
 93    // 4. Try to get git SHA from install path
 94    if (installPath) {
 95      const sha = await getGitCommitSha(installPath)
 96      if (sha) {
 97        const shortSha = sha.substring(0, 12)
 98        logForDebugging(`Using git SHA for ${pluginId}: ${shortSha}`)
 99        return shortSha
100      }
101    }
102  
103    // 5. Return 'unknown' as last resort
104    logForDebugging(`No version found for ${pluginId}, using 'unknown'`)
105    return 'unknown'
106  }
107  
108  /**
109   * Get the git commit SHA for a directory.
110   *
111   * @param dirPath - Path to directory (should be a git repository)
112   * @returns Full commit SHA or null if not a git repo
113   */
114  export function getGitCommitSha(dirPath: string): Promise<string | null> {
115    return getHeadForDir(dirPath)
116  }
117  
118  /**
119   * Extract version from a versioned cache path.
120   *
121   * Given a path like `~/.claude/plugins/cache/marketplace/plugin/1.0.0`,
122   * extracts and returns `1.0.0`.
123   *
124   * @param installPath - Full path to plugin installation
125   * @returns Version string from path, or null if not a versioned path
126   */
127  export function getVersionFromPath(installPath: string): string | null {
128    // Versioned paths have format: .../plugins/cache/marketplace/plugin/version/
129    const parts = installPath.split('/').filter(Boolean)
130  
131    // Find 'cache' index to determine depth
132    const cacheIndex = parts.findIndex(
133      (part, i) => part === 'cache' && parts[i - 1] === 'plugins',
134    )
135  
136    if (cacheIndex === -1) {
137      return null
138    }
139  
140    // Versioned path has 3 components after 'cache': marketplace/plugin/version
141    const componentsAfterCache = parts.slice(cacheIndex + 1)
142    if (componentsAfterCache.length >= 3) {
143      return componentsAfterCache[2] || null
144    }
145  
146    return null
147  }
148  
149  /**
150   * Check if a path is a versioned plugin path.
151   *
152   * @param path - Path to check
153   * @returns True if path follows versioned structure
154   */
155  export function isVersionedPath(path: string): boolean {
156    return getVersionFromPath(path) !== null
157  }