306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
/**
|
|
* 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(', ')}`
|
|
}
|