| /** | |
| * 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(', ')}` | |
| } | |