source dump of claude code
at main 305 lines 12 kB view raw
1/** 2 * Plugin dependency resolution — pure functions, no I/O. 3 * 4 * Semantics are `apt`-style: a dependency is a *presence guarantee*, not a 5 * module graph. Plugin A depending on Plugin B means "B's namespaced 6 * components (MCP servers, commands, agents) must be available when A runs." 7 * 8 * Two entry points: 9 * - `resolveDependencyClosure` — install-time DFS walk, cycle detection 10 * - `verifyAndDemote` — load-time fixed-point check, demotes plugins with 11 * unsatisfied deps (session-local, does NOT write settings) 12 */ 13 14import type { LoadedPlugin, PluginError } from '../../types/plugin.js' 15import type { EditableSettingSource } from '../settings/constants.js' 16import { getSettingsForSource } from '../settings/settings.js' 17import { parsePluginIdentifier } from './pluginIdentifier.js' 18import type { PluginId } from './schemas.js' 19 20/** 21 * Synthetic marketplace sentinel for `--plugin-dir` plugins (pluginLoader.ts 22 * sets `source = "{name}@inline"`). Not a real marketplace — bare deps from 23 * these plugins cannot meaningfully inherit it. 24 */ 25const INLINE_MARKETPLACE = 'inline' 26 27/** 28 * Normalize a dependency reference to fully-qualified "name@marketplace" form. 29 * Bare names (no @) inherit the marketplace of the plugin declaring them — 30 * cross-marketplace deps are blocked anyway, so the @-suffix is boilerplate 31 * in the common case. 32 * 33 * EXCEPTION: if the declaring plugin is @inline (loaded via --plugin-dir), 34 * bare deps are returned unchanged. `inline` is a synthetic sentinel, not a 35 * real marketplace — fabricating "dep@inline" would never match anything. 36 * verifyAndDemote handles bare deps via name-only matching. 37 */ 38export function qualifyDependency( 39 dep: string, 40 declaringPluginId: string, 41): string { 42 if (parsePluginIdentifier(dep).marketplace) return dep 43 const mkt = parsePluginIdentifier(declaringPluginId).marketplace 44 if (!mkt || mkt === INLINE_MARKETPLACE) return dep 45 return `${dep}@${mkt}` 46} 47 48/** 49 * Minimal shape the resolver needs from a marketplace lookup. Keeping this 50 * narrow means the resolver stays testable without constructing full 51 * PluginMarketplaceEntry objects. 52 */ 53export type DependencyLookupResult = { 54 // Entries may be bare names; qualifyDependency normalizes them. 55 dependencies?: string[] 56} 57 58export type ResolutionResult = 59 | { ok: true; closure: PluginId[] } 60 | { ok: false; reason: 'cycle'; chain: PluginId[] } 61 | { ok: false; reason: 'not-found'; missing: PluginId; requiredBy: PluginId } 62 | { 63 ok: false 64 reason: 'cross-marketplace' 65 dependency: PluginId 66 requiredBy: PluginId 67 } 68 69/** 70 * Walk the transitive dependency closure of `rootId` via DFS. 71 * 72 * The returned `closure` ALWAYS contains `rootId`, plus every transitive 73 * dependency that is NOT in `alreadyEnabled`. Already-enabled deps are 74 * skipped (not recursed into) — this avoids surprise settings writes when a 75 * dep is already installed at a different scope. The root is never skipped, 76 * even if already enabled, so re-installing a plugin always re-caches it. 77 * 78 * Cross-marketplace dependencies are BLOCKED by default: a plugin in 79 * marketplace A cannot auto-install a plugin from marketplace B. This is 80 * a security boundary — installing from a trusted marketplace shouldn't 81 * silently pull from an untrusted one. Two escapes: (1) install the 82 * cross-mkt dep yourself first (already-enabled deps are skipped, so the 83 * closure won't touch it), or (2) the ROOT marketplace's 84 * `allowCrossMarketplaceDependenciesOn` allowlist — only the root's list 85 * applies for the whole walk (no transitive trust: if A allows B, B's 86 * plugin depending on C is still blocked unless A also allows C). 87 * 88 * @param rootId Root plugin to resolve from (format: "name@marketplace") 89 * @param lookup Async lookup returning `{dependencies}` or `null` if not found 90 * @param alreadyEnabled Plugin IDs to skip (deps only, root is never skipped) 91 * @param allowedCrossMarketplaces Marketplace names the root trusts for 92 * auto-install (from the root marketplace's manifest) 93 * @returns Closure to install, or a cycle/not-found/cross-marketplace error 94 */ 95export async function resolveDependencyClosure( 96 rootId: PluginId, 97 lookup: (id: PluginId) => Promise<DependencyLookupResult | null>, 98 alreadyEnabled: ReadonlySet<PluginId>, 99 allowedCrossMarketplaces: ReadonlySet<string> = new Set(), 100): Promise<ResolutionResult> { 101 const rootMarketplace = parsePluginIdentifier(rootId).marketplace 102 const closure: PluginId[] = [] 103 const visited = new Set<PluginId>() 104 const stack: PluginId[] = [] 105 106 async function walk( 107 id: PluginId, 108 requiredBy: PluginId, 109 ): Promise<ResolutionResult | null> { 110 // Skip already-enabled DEPENDENCIES (avoids surprise settings writes), 111 // but NEVER skip the root: installing an already-enabled plugin must 112 // still cache/register it. Without this guard, re-installing a plugin 113 // that's in settings but missing from disk (e.g., cache cleared, 114 // installed_plugins.json stale) would return an empty closure and 115 // `cacheAndRegisterPlugin` would never fire — user sees 116 // "✔ Successfully installed" but nothing materializes. 117 if (id !== rootId && alreadyEnabled.has(id)) return null 118 // Security: block auto-install across marketplace boundaries. Runs AFTER 119 // the alreadyEnabled check — if the user manually installed a cross-mkt 120 // dep, it's in alreadyEnabled and we never reach this. 121 const idMarketplace = parsePluginIdentifier(id).marketplace 122 if ( 123 idMarketplace !== rootMarketplace && 124 !(idMarketplace && allowedCrossMarketplaces.has(idMarketplace)) 125 ) { 126 return { 127 ok: false, 128 reason: 'cross-marketplace', 129 dependency: id, 130 requiredBy, 131 } 132 } 133 if (stack.includes(id)) { 134 return { ok: false, reason: 'cycle', chain: [...stack, id] } 135 } 136 if (visited.has(id)) return null 137 visited.add(id) 138 139 const entry = await lookup(id) 140 if (!entry) { 141 return { ok: false, reason: 'not-found', missing: id, requiredBy } 142 } 143 144 stack.push(id) 145 for (const rawDep of entry.dependencies ?? []) { 146 const dep = qualifyDependency(rawDep, id) 147 const err = await walk(dep, id) 148 if (err) return err 149 } 150 stack.pop() 151 152 closure.push(id) 153 return null 154 } 155 156 const err = await walk(rootId, rootId) 157 if (err) return err 158 return { ok: true, closure } 159} 160 161/** 162 * Load-time safety net: for each enabled plugin, verify all manifest 163 * dependencies are also in the enabled set. Demote any that fail. 164 * 165 * Fixed-point loop: demoting plugin A may break plugin B that depends on A, 166 * so we iterate until nothing changes. 167 * 168 * The `reason` field distinguishes: 169 * - `'not-enabled'` — dep exists in the loaded set but is disabled 170 * - `'not-found'` — dep is entirely absent (not in any marketplace) 171 * 172 * Does NOT mutate input. Returns the set of plugin IDs (sources) to demote. 173 * 174 * @param plugins All loaded plugins (enabled + disabled) 175 * @returns Set of pluginIds to demote, plus errors for `/doctor` 176 */ 177export function verifyAndDemote(plugins: readonly LoadedPlugin[]): { 178 demoted: Set<string> 179 errors: PluginError[] 180} { 181 const known = new Set(plugins.map(p => p.source)) 182 const enabled = new Set(plugins.filter(p => p.enabled).map(p => p.source)) 183 // Name-only indexes for bare deps from --plugin-dir (@inline) plugins: 184 // the real marketplace is unknown, so match "B" against any enabled "B@*". 185 // enabledByName is a multiset: if B@epic AND B@other are both enabled, 186 // demoting one mustn't make "B" disappear from the index. 187 const knownByName = new Set( 188 plugins.map(p => parsePluginIdentifier(p.source).name), 189 ) 190 const enabledByName = new Map<string, number>() 191 for (const id of enabled) { 192 const n = parsePluginIdentifier(id).name 193 enabledByName.set(n, (enabledByName.get(n) ?? 0) + 1) 194 } 195 const errors: PluginError[] = [] 196 197 let changed = true 198 while (changed) { 199 changed = false 200 for (const p of plugins) { 201 if (!enabled.has(p.source)) continue 202 for (const rawDep of p.manifest.dependencies ?? []) { 203 const dep = qualifyDependency(rawDep, p.source) 204 // Bare dep ← @inline plugin: match by name only (see enabledByName) 205 const isBare = !parsePluginIdentifier(dep).marketplace 206 const satisfied = isBare 207 ? (enabledByName.get(dep) ?? 0) > 0 208 : enabled.has(dep) 209 if (!satisfied) { 210 enabled.delete(p.source) 211 const count = enabledByName.get(p.name) ?? 0 212 if (count <= 1) enabledByName.delete(p.name) 213 else enabledByName.set(p.name, count - 1) 214 errors.push({ 215 type: 'dependency-unsatisfied', 216 source: p.source, 217 plugin: p.name, 218 dependency: dep, 219 reason: (isBare ? knownByName.has(dep) : known.has(dep)) 220 ? 'not-enabled' 221 : 'not-found', 222 }) 223 changed = true 224 break 225 } 226 } 227 } 228 } 229 230 const demoted = new Set( 231 plugins.filter(p => p.enabled && !enabled.has(p.source)).map(p => p.source), 232 ) 233 return { demoted, errors } 234} 235 236/** 237 * Find all enabled plugins that declare `pluginId` as a dependency. 238 * Used to warn on uninstall/disable ("required by: X, Y"). 239 * 240 * @param pluginId The plugin being removed/disabled 241 * @param plugins All loaded plugins (only enabled ones are checked) 242 * @returns Names of plugins that will break if `pluginId` goes away 243 */ 244export function findReverseDependents( 245 pluginId: PluginId, 246 plugins: readonly LoadedPlugin[], 247): string[] { 248 const { name: targetName } = parsePluginIdentifier(pluginId) 249 return plugins 250 .filter( 251 p => 252 p.enabled && 253 p.source !== pluginId && 254 (p.manifest.dependencies ?? []).some(d => { 255 const qualified = qualifyDependency(d, p.source) 256 // Bare dep (from @inline plugin): match by name only 257 return parsePluginIdentifier(qualified).marketplace 258 ? qualified === pluginId 259 : qualified === targetName 260 }), 261 ) 262 .map(p => p.name) 263} 264 265/** 266 * Build the set of plugin IDs currently enabled at a given settings scope. 267 * Used by install-time resolution to skip already-enabled deps and avoid 268 * surprise settings writes. 269 * 270 * Matches `true` (plain enable) AND array values (version constraints per 271 * settings/types.ts:455-463 — a plugin at `"foo@bar": ["^1.0.0"]` IS enabled). 272 * Without the array check, a version-pinned dep would be re-added to the 273 * closure and the settings write would clobber the constraint with `true`. 274 */ 275export function getEnabledPluginIdsForScope( 276 settingSource: EditableSettingSource, 277): Set<PluginId> { 278 return new Set( 279 Object.entries(getSettingsForSource(settingSource)?.enabledPlugins ?? {}) 280 .filter(([, v]) => v === true || Array.isArray(v)) 281 .map(([k]) => k), 282 ) 283} 284 285/** 286 * Format the "(+ N dependencies)" suffix for install success messages. 287 * Returns empty string when `installedDeps` is empty. 288 */ 289export function formatDependencyCountSuffix(installedDeps: string[]): string { 290 if (installedDeps.length === 0) return '' 291 const n = installedDeps.length 292 return ` (+ ${n} ${n === 1 ? 'dependency' : 'dependencies'})` 293} 294 295/** 296 * Format the "warning: required by X, Y" suffix for uninstall/disable 297 * results. Em-dash style for CLI result messages (not the middot style 298 * used in the notification UI). Returns empty string when no dependents. 299 */ 300export function formatReverseDependentsSuffix( 301 rdeps: string[] | undefined, 302): string { 303 if (!rdeps || rdeps.length === 0) return '' 304 return ` — warning: required by ${rdeps.join(', ')}` 305}