File size: 5,340 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | /**
* 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
}
|