diff --git a/docs/channels/tlon.md b/docs/channels/tlon.md index a2436d5e7..2ad6f3f8c 100644 --- a/docs/channels/tlon.md +++ b/docs/channels/tlon.md @@ -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): diff --git a/docs/plugin.md b/docs/plugin.md index c57a024f2..6dc7a34e1 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -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/`. -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 diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 244757a48..ab4737821 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -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/ && 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. diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 931c15d59..18b63cd4e 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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(); 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 }; + 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;