fix(plugins): auto-install dependencies for bundled plugins

Bundled plugins ship with source but not node_modules. When a bundled
plugin is enabled and has external dependencies, the loader now
automatically runs npm install on first load.

This fixes the issue where plugins like tlon (which depend on
@urbit/http-api and @urbit/aura) would fail to load from a fresh
npm install because their dependencies weren't installed.

Changes:
- loader.ts: Add ensureBundledPluginDeps() to install deps before loading
- docs/channels/tlon.md: Update to reflect bundled status and auto-install
- docs/plugin.md: Add tlon to plugin list, update dep install guidance
- docs/reference/RELEASING.md: Clarify bundled vs npm-published plugins
This commit is contained in:
Hunter Miller 2026-01-26 12:31:26 -06:00
parent b9098f3401
commit 3bf74611c2
4 changed files with 138 additions and 25 deletions

View File

@ -12,30 +12,35 @@ be further restricted via allowlists.
Status: supported via plugin. DMs, group mentions, thread replies, and text-only media fallback
(URL appended to caption). Reactions, polls, and native media uploads are not supported.
## Plugin required
## Plugin setup
Tlon ships as a plugin and is not bundled with the core install.
Tlon is bundled with Clawdbot. Its dependencies (`@urbit/http-api`, `@urbit/aura`) are
automatically installed on first load when you enable the plugin.
Install via CLI (npm registry):
To enable, add `tlon` to your plugins allow list and configure the channel:
```bash
clawdbot plugins install @clawdbot/tlon
```
Local checkout (when running from a git repo):
```bash
clawdbot plugins install ./extensions/tlon
```json5
{
plugins: {
allow: ["tlon"]
},
channels: {
tlon: {
enabled: true,
// ... config below
}
}
}
```
Details: [Plugins](/plugin)
## Setup
1) Install the Tlon plugin.
2) Gather your ship URL and login code.
1) Gather your ship URL and login code.
2) Add `tlon` to your plugins allow list.
3) Configure `channels.tlon`.
4) Restart the gateway.
4) Restart the gateway (dependencies install automatically on first load).
5) DM the bot or mention it in a group channel.
Minimal config (single account):

View File

@ -44,6 +44,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- [Nostr](/channels/nostr) — `@clawdbot/nostr`
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
- [Tlon/Urbit](/channels/tlon) — bundled as `tlon` (disabled by default; auto-installs deps)
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
@ -128,8 +129,9 @@ A plugin directory may include a `package.json` with `clawdbot.extensions`:
Each entry becomes a plugin. If the pack lists multiple extensions, the plugin id
becomes `name/<fileBase>`.
If your plugin imports npm deps, install them in that directory so
`node_modules` is available (`npm install` / `pnpm install`).
If your plugin imports npm deps, bundled plugins will **auto-install** them on
first load. For workspace/linked plugins during development, run `npm install`
or `pnpm install` in the plugin directory manually.
### Channel catalog metadata

View File

@ -81,14 +81,24 @@ When the operator says “release”, immediately do this preflight (no extra qu
## Plugin publish scope (npm)
We only publish **existing npm plugins** under the `@clawdbot/*` scope. Bundled
plugins that are not on npm stay **disk-tree only** (still shipped in
`extensions/**`).
We publish some plugins to npm under the `@clawdbot/*` scope for users who prefer
explicit installation. However, **all plugins are bundled** in `extensions/**` and
their dependencies are **auto-installed on first load** when enabled.
Process to derive the list:
1) `npm search @clawdbot --json` and capture the package names.
2) Compare with `extensions/*/package.json` names.
3) Publish only the **intersection** (already on npm).
### Bundled plugins with auto-install
Bundled plugins ship with source code but not `node_modules`. When a bundled plugin
is enabled and has external dependencies (not `clawdbot` workspace refs), the loader
automatically runs `npm install` in the plugin directory on first load.
This means users don't need to manually install plugins or their dependencies—they
just enable the plugin in config and restart.
### npm-published plugins (optional)
Some plugins are also published to npm for users who prefer explicit installation
via `clawdbot plugins install @clawdbot/xxx`. This copies the plugin to
`~/.clawdbot/extensions/` and runs `npm install` there.
Current npm plugin list (update as needed):
- @clawdbot/bluebubbles
@ -103,5 +113,14 @@ Current npm plugin list (update as needed):
- @clawdbot/zalo
- @clawdbot/zalouser
Release notes must also call out **new optional bundled plugins** that are **not
on by default** (example: `tlon`).
To add a new plugin to npm:
1) First-publish manually: `cd extensions/<plugin> && npm publish --access public`
2) Add to the list above
3) Future releases will include it automatically
### Bundled-only plugins
Some plugins are bundled but not published to npm (e.g., `tlon`). These work via
the auto-install mechanism—users just enable them in config.
Release notes should call out **new bundled plugins** so users know they're available.

View File

@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@ -43,6 +44,69 @@ const registryCache = new Map<string, PluginRegistry>();
const defaultLogger = () => createSubsystemLogger("plugins");
/**
* Ensures plugin dependencies are installed for bundled plugins.
* Bundled plugins ship with source but not node_modules; this installs
* deps on first load if the plugin has dependencies in package.json.
*/
function ensureBundledPluginDeps(params: {
rootDir: string;
pluginId: string;
logger: PluginLogger;
}): { ok: boolean; error?: string } {
const { rootDir, pluginId, logger } = params;
const packageJsonPath = path.join(rootDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
return { ok: true }; // No package.json, nothing to install
}
let manifest: { dependencies?: Record<string, string> };
try {
manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
} catch {
return { ok: true }; // Can't parse, skip
}
const deps = manifest.dependencies ?? {};
const depNames = Object.keys(deps).filter(
(name) => !name.startsWith("clawdbot") && name !== "clawdbot",
);
if (depNames.length === 0) {
return { ok: true }; // No external dependencies
}
// Check if first dependency exists in node_modules
const firstDep = depNames[0];
const depPath = path.join(rootDir, "node_modules", ...firstDep.split("/"));
if (fs.existsSync(depPath)) {
return { ok: true }; // Dependencies already installed
}
logger.info?.(`[plugins] ${pluginId}: installing dependencies…`);
try {
const result = spawnSync("npm", ["install", "--omit=dev", "--silent"], {
cwd: rootDir,
encoding: "utf-8",
timeout: 300_000,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
const errorMsg = result.stderr?.trim() || result.stdout?.trim() || "unknown error";
return { ok: false, error: `npm install failed: ${errorMsg}` };
}
logger.info?.(`[plugins] ${pluginId}: dependencies installed`);
return { ok: true };
} catch (err) {
return { ok: false, error: `failed to install dependencies: ${String(err)}` };
}
}
const resolvePluginSdkAlias = (): string | null => {
try {
const modulePath = fileURLToPath(import.meta.url);
@ -282,6 +346,29 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
// Ensure bundled plugin dependencies are installed before loading
if (candidate.origin === "bundled") {
const depsResult = ensureBundledPluginDeps({
rootDir: candidate.rootDir,
pluginId,
logger,
});
if (!depsResult.ok) {
logger.error(`[plugins] ${record.id} failed to install deps: ${depsResult.error}`);
record.status = "error";
record.error = depsResult.error ?? "failed to install dependencies";
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: record.error,
});
continue;
}
}
let mod: ClawdbotPluginModule | null = null;
try {
mod = jiti(candidate.source) as ClawdbotPluginModule;