/ utils / plugins / dependencyResolver.ts
dependencyResolver.ts
  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  
 14  import type { LoadedPlugin, PluginError } from '../../types/plugin.js'
 15  import type { EditableSettingSource } from '../settings/constants.js'
 16  import { getSettingsForSource } from '../settings/settings.js'
 17  import { parsePluginIdentifier } from './pluginIdentifier.js'
 18  import 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   */
 25  const 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   */
 38  export 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   */
 53  export type DependencyLookupResult = {
 54    // Entries may be bare names; qualifyDependency normalizes them.
 55    dependencies?: string[]
 56  }
 57  
 58  export 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   */
 95  export 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   */
177  export 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   */
244  export 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   */
275  export 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   */
289  export 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   */
300  export function formatReverseDependentsSuffix(
301    rdeps: string[] | undefined,
302  ): string {
303    if (!rdeps || rdeps.length === 0) return ''
304    return ` — warning: required by ${rdeps.join(', ')}`
305  }