RFC 0057 — Plugin external dependency resolution
Summary
When a plugin is added to a Sovereign instance, its external npm dependencies (anything that isn't a workspace package or a platform peer) must be resolvable by the Next.js compiler inside the runtime. Today that resolution fails silently unless someone manually adds the plugin's deps to runtime/package.json — a non-starter for third-party plugins and a DX landmine for first-party ones.
This RFC specifies that sv plugin add and sv plugin remove automatically maintain a runtime/generated/plugin-deps.json ledger and keep runtime/package.json in sync with the union of external deps across all installed plugins. The fix is transparent to plugin developers: they declare deps in their own package.json and the platform takes care of the rest.
Motivation
Plugin source files are copied by the generate step into runtime/app/(plugins)/<id>/ and compiled by Next.js as part of the runtime's module graph. Module resolution happens from the runtime's node_modules — not from the plugin's own directory. pnpm's strict isolation means a dep declared in plugins/my-plugin/package.json is installed in the plugin's own virtual store and is invisible to the runtime compiler.
The consequence today: every plugin that brings an external dependency (e.g. @dnd-kit/core, a charting library, a date library) silently breaks the runtime's typecheck and compilation unless someone manually adds those deps to runtime/package.json. This was discovered during Tasks plugin development when @dnd-kit/* had to be manually patched in.
This is untenable at scale:
- Third-party plugin developers have no way to trigger the patch.
sv plugin removehas no corresponding cleanup path.- The coupling between a plugin's deps and the runtime's manifest is invisible and undocumented.
Current state
sv plugin addclones/copies a plugin intoplugins/<id>/, registers it, and runspnpm installonce. It does not inspect the plugin'spackage.jsonfor external deps.sv plugin removedeletes the plugin directory and updates the registry. It does not clean up any deps.runtime/package.jsonis manually maintained. There is no record of which entries were added for which plugin.plugins/*are pnpm workspace members (viaplugins/*glob inpnpm-workspace.yaml). Their deps are installed in the pnpm virtual store but are not hoisted to the runtime's resolution scope.- Local dev plugins (
plugins/*.local/) follow the same model and have the same resolution gap.
Proposed design
1. Plugin dep ledger — runtime/generated/plugin-deps.json
A generated JSON file tracking which external deps each plugin contributed. Never hand-edited. Committed to the repo (it's a generated artefact, like registry.ts).
{
"fs.sovereign.tasks": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2"
}
}The ledger is the authoritative record of what runtime/package.json contains on behalf of plugins. It enables safe cleanup on remove: a dep is removed from runtime/package.json only when no remaining plugin entry in the ledger references it.
2. Dep extraction — what counts as an external dep
When reading a plugin's package.json, filter out:
@sovereignfs/*— workspace packages, already resolved via pnpm workspaces.- Platform peer deps already in
runtime/package.json:next,react,react-dom,typescript,react-dom, and any other explicit platform dep. devDependencies— never needed at runtime.
Everything remaining in dependencies is an external dep that must be hoisted to the runtime.
3. sv plugin add — install path
After the plugin directory is created and the registry is regenerated:
- Read
plugins/<id>/package.json. - Extract external deps (step 2 filter).
- If non-empty: a. Write/update
runtime/generated/plugin-deps.jsonwith the plugin's entry. b. Add the deps toruntime/package.jsondependencies(merge, not replace — preserve existing entries from other plugins). c. Runpnpm install --filter runtimeto install the new deps. - Print a summary:
Added 3 runtime deps for fs.sovereign.tasks.
If the plugin declares no external deps, skip silently.
4. sv plugin remove — cleanup path
After the plugin directory and registry entry are removed:
- Read
runtime/generated/plugin-deps.json. - Find the departing plugin's dep set.
- Compute the union of all remaining plugins' dep sets.
- Remove from
runtime/package.jsonany dep that is in the departing set but not in the remaining union (i.e. no other plugin still needs it). - Update
runtime/generated/plugin-deps.json(delete the plugin's entry). - Run
pnpm install --filter runtimeto prune the removed deps. - Print a summary:
Removed 2 runtime deps (1 still needed by another plugin, kept).
5. Local dev plugins — pnpm dev sync
For .local plugins the add/remove path isn't always used. scripts/dev.ts should run a lightweight sync at startup:
- Scan
plugins/*.local/forpackage.jsonfiles. - For each, extract external deps.
- Compare against
runtime/generated/plugin-deps.json. - If there is a diff (new dep or removed dep), update the ledger and
runtime/package.json, then runpnpm install --filter runtime. - Print a one-line notice if deps changed; continue dev startup as normal.
This makes pnpm dev self-healing for local plugins — adding a dependency to a local plugin's package.json is picked up automatically on the next pnpm dev.
6. Version conflict policy
If a plugin declares "some-lib": "^2.0.0" and another plugin already contributed "some-lib": "^1.0.0" to the runtime, the newer version wins and the ledger records both contributors. On remove, the dep stays at the remaining plugin's version. A warning is printed when a version conflict is detected at install time so the operator is aware.
Alternatives considered
Do nothing / document the manual step
Rejected. Third-party plugin developers cannot update runtime/package.json on an installed instance. The manual step is invisible and fails at runtime, not at install time.
Require plugins to declare platform peers only
Rejected. Plugins are mini-applications and will legitimately need domain libraries (date pickers, chart libs, drag-and-drop). Prohibiting external deps would severely limit what plugins can build.
Change the compilation model (longer-term path)
Instead of copying plugin source into the runtime, build each plugin as a compiled package (via tsup) and have the runtime import it. This lets pnpm resolve the plugin's deps from the plugin's own node_modules naturally — no hoisting needed. This is the right long-term architecture but requires significant changes to the generate step and the way Next.js loads plugin routes.
This RFC is the interim fix; the compilation model change is the eventual successor. Both can coexist: the ledger approach works whether source is copied or imported.
Use pnpm --shamefully-hoist
Rejected. Shamefully hoisting all workspace deps defeats the purpose of pnpm's strict isolation and can cause hard-to-diagnose version conflicts across unrelated packages.
Add each plugin as a direct dep of the runtime (workspace:*)
Adding "@sovereignfs/sovereign-tasks": "workspace:*" to runtime/package.json makes pnpm link the plugin and transitively install its deps. But it still requires runtime/package.json to list every installed plugin, which is the same coupling problem — just at the package level instead of the dep level. It also doesn't work for third-party plugins whose package names aren't @sovereignfs/*.
Open questions
- Should the ledger file be committed or gitignored? Committing it makes review transparent (you can see what a new plugin adds). Gitignoring it means it's always regenerated. Leaning toward committed.
- Should version conflict warnings be blocking (error + exit) or advisory? Advisory seems right — SemVer ranges often resolve safely.
- Should
sv plugin migrate(the DB migration command from the same sprint) and this dep-hoisting step be combined into a singlesv plugin synccommand that brings a plugin fully up to date after a manifest or schema change? - Does the dev-startup sync in
scripts/dev.tsadd too much latency? Ifpnpm installis triggered on everypnpm deveven when nothing changed, it should be gated on a hash/mtime check of the pluginpackage.json.
Adoption path
- RFC draft: agree on ledger format, filter rules, and the dev-startup sync.
- Implementation: update
sv plugin add,sv plugin remove, andscripts/dev.ts. Generate initialplugin-deps.jsonfrom the currentruntime/package.jsonentries that are known to be plugin-contributed (@dnd-kit/*from Tasks). - Remove manual workaround: delete
@dnd-kit/*fromruntime/package.jsonand let the ledger manage them. - Docs: update
docs/plugin-development.md— external deps are declared in the plugin's ownpackage.jsonand the platform installs them; no manual step needed.
No published package (@sovereignfs/sdk, @sovereignfs/ui) or manifest schema changes are required.
Changelog
| Version | Date | Change |
|---|---|---|
| 0.1 | July 2026 | Initial draft |