π File detail
utils/plugins/dependencyResolver.ts
π― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes qualifyDependency, DependencyLookupResult, ResolutionResult, resolveDependencyClosure, and verifyAndDemote (and more) β mainly functions, hooks, or classes. It composes internal code from types, settings, pluginIdentifier, and schemas (relative imports). What the file header says: Plugin dependency resolution β pure functions, no I/O. Semantics are `apt`-style: a dependency is a *presence guarantee*, not a module graph. Plugin A depending on Plugin B means "B's namespaced components (MCP servers, commands, agents) must be available when A runs." Two entry.
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
Plugin dependency resolution β pure functions, no I/O. Semantics are `apt`-style: a dependency is a *presence guarantee*, not a module graph. Plugin A depending on Plugin B means "B's namespaced components (MCP servers, commands, agents) must be available when A runs." Two entry points: - `resolveDependencyClosure` β install-time DFS walk, cycle detection - `verifyAndDemote` β load-time fixed-point check, demotes plugins with unsatisfied deps (session-local, does NOT write settings)
π€ Exports (heuristic)
qualifyDependencyDependencyLookupResultResolutionResultresolveDependencyClosureverifyAndDemotefindReverseDependentsgetEnabledPluginIdsForScopeformatDependencyCountSuffixformatReverseDependentsSuffix
π₯οΈ Source preview
/**
* Plugin dependency resolution β pure functions, no I/O.
*
* Semantics are `apt`-style: a dependency is a *presence guarantee*, not a
* module graph. Plugin A depending on Plugin B means "B's namespaced
* components (MCP servers, commands, agents) must be available when A runs."
*
* Two entry points:
* - `resolveDependencyClosure` β install-time DFS walk, cycle detection
* - `verifyAndDemote` β load-time fixed-point check, demotes plugins with
* unsatisfied deps (session-local, does NOT write settings)
*/
import type { LoadedPlugin, PluginError } from '../../types/plugin.js'
import type { EditableSettingSource } from '../settings/constants.js'
import { getSettingsForSource } from '../settings/settings.js'
import { parsePluginIdentifier } from './pluginIdentifier.js'
import type { PluginId } from './schemas.js'
/**
* Synthetic marketplace sentinel for `--plugin-dir` plugins (pluginLoader.ts
* sets `source = "{name}@inline"`). Not a real marketplace β bare deps from
* these plugins cannot meaningfully inherit it.
*/
const INLINE_MARKETPLACE = 'inline'
/**
* Normalize a dependency reference to fully-qualified "name@marketplace" form.
* Bare names (no @) inherit the marketplace of the plugin declaring them β
* cross-marketplace deps are blocked anyway, so the @-suffix is boilerplate
* in the common case.
*
* EXCEPTION: if the declaring plugin is @inline (loaded via --plugin-dir),
* bare deps are returned unchanged. `inline` is a synthetic sentinel, not a
* real marketplace β fabricating "dep@inline" would never match anything.
* verifyAndDemote handles bare deps via name-only matching.
*/
export function qualifyDependency(
dep: string,
declaringPluginId: string,
): string {
if (parsePluginIdentifier(dep).marketplace) return dep
const mkt = parsePluginIdentifier(declaringPluginId).marketplace
if (!mkt || mkt === INLINE_MARKETPLACE) return dep
return `${dep}@${mkt}`
}
/**
* Minimal shape the resolver needs from a marketplace lookup. Keeping this
* narrow means the resolver stays testable without constructing full
* PluginMarketplaceEntry objects.
*/
export type DependencyLookupResult = {
// Entries may be bare names; qualifyDependency normalizes them.
dependencies?: string[]
}
export type ResolutionResult =
| { ok: true; closure: PluginId[] }
| { ok: false; reason: 'cycle'; chain: PluginId[] }
| { ok: false; reason: 'not-found'; missing: PluginId; requiredBy: PluginId }
| {
ok: false
reason: 'cross-marketplace'
dependency: PluginId
requiredBy: PluginId
}
/**
* Walk the transitive dependency closure of `rootId` via DFS.
*
* The returned `closure` ALWAYS contains `rootId`, plus every transitive
* dependency that is NOT in `alreadyEnabled`. Already-enabled deps are
* skipped (not recursed into) β this avoids surprise settings writes when a
* dep is already installed at a different scope. The root is never skipped,
* even if already enabled, so re-installing a plugin always re-caches it.
*
* Cross-marketplace dependencies are BLOCKED by default: a plugin in
* marketplace A cannot auto-install a plugin from marketplace B. This is
* a security boundary β installing from a trusted marketplace shouldn't
* silently pull from an untrusted one. Two escapes: (1) install the
* cross-mkt dep yourself first (already-enabled deps are skipped, so the
* closure won't touch it), or (2) the ROOT marketplace's
* `allowCrossMarketplaceDependenciesOn` allowlist β only the root's list
* applies for the whole walk (no transitive trust: if A allows B, B's
* plugin depending on C is still blocked unless A also allows C).
*
* @param rootId Root plugin to resolve from (format: "name@marketplace")
* @param lookup Async lookup returning `{dependencies}` or `null` if not found
* @param alreadyEnabled Plugin IDs to skip (deps only, root is never skipped)
* @param allowedCrossMarketplaces Marketplace names the root trusts for
* auto-install (from the root marketplace's manifest)
* @returns Closure to install, or a cycle/not-found/cross-marketplace error
*/
export async function resolveDependencyClosure(
rootId: PluginId,
lookup: (id: PluginId) => Promise<DependencyLookupResult | null>,
alreadyEnabled: ReadonlySet<PluginId>,
allowedCrossMarketplaces: ReadonlySet<string> = new Set(),
): Promise<ResolutionResult> {
const rootMarketplace = parsePluginIdentifier(rootId).marketplace
const closure: PluginId[] = []
const visited = new Set<PluginId>()
const stack: PluginId[] = []
async function walk(
id: PluginId,
requiredBy: PluginId,
): Promise<ResolutionResult | null> {
// Skip already-enabled DEPENDENCIES (avoids surprise settings writes),
// but NEVER skip the root: installing an already-enabled plugin must
// still cache/register it. Without this guard, re-installing a plugin
// that's in settings but missing from disk (e.g., cache cleared,
// installed_plugins.json stale) would return an empty closure and
// `cacheAndRegisterPlugin` would never fire β user sees
// "β Successfully installed" but nothing materializes.
if (id !== rootId && alreadyEnabled.has(id)) return null
// Security: block auto-install across marketplace boundaries. Runs AFTER
// the alreadyEnabled check β if the user manually installed a cross-mkt
// dep, it's in alreadyEnabled and we never reach this.
const idMarketplace = parsePluginIdentifier(id).marketplace
if (
idMarketplace !== rootMarketplace &&
!(idMarketplace && allowedCrossMarketplaces.has(idMarketplace))
) {
return {
ok: false,
reason: 'cross-marketplace',
dependency: id,
requiredBy,
}
}
if (stack.includes(id)) {
return { ok: false, reason: 'cycle', chain: [...stack, id] }
}
if (visited.has(id)) return null
visited.add(id)
const entry = await lookup(id)
if (!entry) {
return { ok: false, reason: 'not-found', missing: id, requiredBy }
}
stack.push(id)
for (const rawDep of entry.dependencies ?? []) {
const dep = qualifyDependency(rawDep, id)
const err = await walk(dep, id)
if (err) return err
}
stack.pop()
closure.push(id)
return null
}
const err = await walk(rootId, rootId)
if (err) return err
return { ok: true, closure }
}
/**
* Load-time safety net: for each enabled plugin, verify all manifest
* dependencies are also in the enabled set. Demote any that fail.
*
* Fixed-point loop: demoting plugin A may break plugin B that depends on A,
* so we iterate until nothing changes.
*
* The `reason` field distinguishes:
* - `'not-enabled'` β dep exists in the loaded set but is disabled
* - `'not-found'` β dep is entirely absent (not in any marketplace)
*
* Does NOT mutate input. Returns the set of plugin IDs (sources) to demote.
*
* @param plugins All loaded plugins (enabled + disabled)
* @returns Set of pluginIds to demote, plus errors for `/doctor`
*/
export function verifyAndDemote(plugins: readonly LoadedPlugin[]): {
demoted: Set<string>
errors: PluginError[]
} {
const known = new Set(plugins.map(p => p.source))
const enabled = new Set(plugins.filter(p => p.enabled).map(p => p.source))
// Name-only indexes for bare deps from --plugin-dir (@inline) plugins:
// the real marketplace is unknown, so match "B" against any enabled "B@*".
// enabledByName is a multiset: if B@epic AND B@other are both enabled,
// demoting one mustn't make "B" disappear from the index.
const knownByName = new Set(
plugins.map(p => parsePluginIdentifier(p.source).name),
)
const enabledByName = new Map<string, number>()
for (const id of enabled) {
const n = parsePluginIdentifier(id).name
enabledByName.set(n, (enabledByName.get(n) ?? 0) + 1)
}
const errors: PluginError[] = []
let changed = true
while (changed) {
changed = false
for (const p of plugins) {
if (!enabled.has(p.source)) continue
for (const rawDep of p.manifest.dependencies ?? []) {
const dep = qualifyDependency(rawDep, p.source)
// Bare dep β @inline plugin: match by name only (see enabledByName)
const isBare = !parsePluginIdentifier(dep).marketplace
const satisfied = isBare
? (enabledByName.get(dep) ?? 0) > 0
: enabled.has(dep)
if (!satisfied) {
enabled.delete(p.source)
const count = enabledByName.get(p.name) ?? 0
if (count <= 1) enabledByName.delete(p.name)
else enabledByName.set(p.name, count - 1)
errors.push({
type: 'dependency-unsatisfied',
source: p.source,
plugin: p.name,
dependency: dep,
reason: (isBare ? knownByName.has(dep) : known.has(dep))
? 'not-enabled'
: 'not-found',
})
changed = true
break
}
}
}
}
const demoted = new Set(
plugins.filter(p => p.enabled && !enabled.has(p.source)).map(p => p.source),
)
return { demoted, errors }
}
/**
* Find all enabled plugins that declare `pluginId` as a dependency.
* Used to warn on uninstall/disable ("required by: X, Y").
*
* @param pluginId The plugin being removed/disabled
* @param plugins All loaded plugins (only enabled ones are checked)
* @returns Names of plugins that will break if `pluginId` goes away
*/
export function findReverseDependents(
pluginId: PluginId,
plugins: readonly LoadedPlugin[],
): string[] {
const { name: targetName } = parsePluginIdentifier(pluginId)
return plugins
.filter(
p =>
p.enabled &&
p.source !== pluginId &&
(p.manifest.dependencies ?? []).some(d => {
const qualified = qualifyDependency(d, p.source)
// Bare dep (from @inline plugin): match by name only
return parsePluginIdentifier(qualified).marketplace
? qualified === pluginId
: qualified === targetName
}),
)
.map(p => p.name)
}
/**
* Build the set of plugin IDs currently enabled at a given settings scope.
* Used by install-time resolution to skip already-enabled deps and avoid
* surprise settings writes.
*
* Matches `true` (plain enable) AND array values (version constraints per
* settings/types.ts:455-463 β a plugin at `"foo@bar": ["^1.0.0"]` IS enabled).
* Without the array check, a version-pinned dep would be re-added to the
* closure and the settings write would clobber the constraint with `true`.
*/
export function getEnabledPluginIdsForScope(
settingSource: EditableSettingSource,
): Set<PluginId> {
return new Set(
Object.entries(getSettingsForSource(settingSource)?.enabledPlugins ?? {})
.filter(([, v]) => v === true || Array.isArray(v))
.map(([k]) => k),
)
}
/**
* Format the "(+ N dependencies)" suffix for install success messages.
* Returns empty string when `installedDeps` is empty.
*/
export function formatDependencyCountSuffix(installedDeps: string[]): string {
if (installedDeps.length === 0) return ''
const n = installedDeps.length
return ` (+ ${n} ${n === 1 ? 'dependency' : 'dependencies'})`
}
/**
* Format the "warning: required by X, Y" suffix for uninstall/disable
* results. Em-dash style for CLI result messages (not the middot style
* used in the notification UI). Returns empty string when no dependents.
*/
export function formatReverseDependentsSuffix(
rdeps: string[] | undefined,
): string {
if (!rdeps || rdeps.length === 0) return ''
return ` β warning: required by ${rdeps.join(', ')}`
}