File size: 11,673 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 | /**
* 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(', ')}`
}
|