158 lines
5.2 KiB
TypeScript
158 lines
5.2 KiB
TypeScript
/**
|
|
* 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
|
|
}
|