πŸ“„ File detail

utils/plugins/pluginVersioning.ts

🧩 .tsπŸ“ 158 linesπŸ’Ύ 5,340 bytesπŸ“ text
← Back to All Files

🎯 Use case

This file lives under β€œutils/”, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, …). On the API surface it exposes calculatePluginVersion, getGitCommitSha, getVersionFromPath, and isVersionedPath β€” mainly functions, hooks, or classes. Dependencies touch crypto. It composes internal code from debug, git, and schemas (relative imports). What the file header says: 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 sourc.

Generated from folder role, exports, dependency roots, and inline comments β€” not hand-reviewed for every path.

🧠 Inline summary

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

πŸ“€ Exports (heuristic)

  • calculatePluginVersion
  • getGitCommitSha
  • getVersionFromPath
  • isVersionedPath

πŸ“š External import roots

Package roots from from "…" (relative paths omitted).

  • crypto

πŸ–₯️ Source preview

/**
 * 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<string> {
  // 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/<m>/<p>/<sha>/ 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<string | null> {
  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
}