diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 8e30459f2..c9d00c6f0 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -21,6 +21,8 @@ clawdbot plugins info clawdbot plugins enable clawdbot plugins disable clawdbot plugins doctor +clawdbot plugins update +clawdbot plugins update --all ``` ### Install @@ -31,3 +33,12 @@ clawdbot plugins install Security note: treat plugin installs like running code. Prefer pinned versions. +### Update + +```bash +clawdbot plugins update +clawdbot plugins update --all +clawdbot plugins update --dry-run +``` + +Updates only apply to plugins installed from npm (tracked in `plugins.installs`). diff --git a/docs/plugin.md b/docs/plugin.md index 376154aaf..54dc8af11 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -160,11 +160,15 @@ clawdbot plugins install # add a local file/dir to plugins.l clawdbot plugins install ./extensions/voice-call # relative path ok clawdbot plugins install ./plugin.tgz # install from a local tarball clawdbot plugins install @clawdbot/voice-call # install from npm +clawdbot plugins update +clawdbot plugins update --all clawdbot plugins enable clawdbot plugins disable clawdbot plugins doctor ``` +`plugins update` only works for npm installs tracked under `plugins.installs`. + Plugins may also register their own top‑level commands (example: `clawdbot voicecall`). ## Plugin API (overview) diff --git a/src/agents/skills/refresh.ts b/src/agents/skills/refresh.ts index 9f35d1cd5..f22690b7d 100644 --- a/src/agents/skills/refresh.ts +++ b/src/agents/skills/refresh.ts @@ -102,7 +102,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Cla if (!watchEnabled) { if (existing) { watchers.delete(workspaceDir); - existing.timer && clearTimeout(existing.timer); + if (existing.timer) clearTimeout(existing.timer); void existing.watcher.close().catch(() => {}); } return; @@ -115,7 +115,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Cla } if (existing) { watchers.delete(workspaceDir); - existing.timer && clearTimeout(existing.timer); + if (existing.timer) clearTimeout(existing.timer); void existing.watcher.close().catch(() => {}); } diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 6a717d710..fe4843438 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -1,10 +1,17 @@ import fs from "node:fs"; +import fsp from "node:fs/promises"; import path from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; import { loadConfig, writeConfigFile } from "../config/config.js"; -import { installPluginFromArchive, installPluginFromNpmSpec } from "../plugins/install.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import { + installPluginFromArchive, + installPluginFromNpmSpec, + resolvePluginInstallDir, +} from "../plugins/install.js"; +import { recordPluginInstall } from "../plugins/installs.js"; import type { PluginRecord } from "../plugins/registry.js"; import { buildPluginStatusReport } from "../plugins/status.js"; import { defaultRuntime } from "../runtime.js"; @@ -22,6 +29,11 @@ export type PluginInfoOptions = { json?: boolean; }; +export type PluginUpdateOptions = { + all?: boolean; + dryRun?: boolean; +}; + function formatPluginLine(plugin: PluginRecord, verbose = false): string { const status = plugin.status === "loaded" @@ -56,6 +68,16 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string { return parts.join("\n"); } +async function readInstalledPackageVersion(dir: string): Promise { + try { + const raw = await fsp.readFile(path.join(dir, "package.json"), "utf-8"); + const parsed = JSON.parse(raw) as { version?: unknown }; + return typeof parsed.version === "string" ? parsed.version : undefined; + } catch { + return undefined; + } +} + export function registerPluginsCli(program: Command) { const plugins = program .command("plugins") @@ -118,6 +140,8 @@ export function registerPluginsCli(program: Command) { defaultRuntime.error(`Plugin not found: ${id}`); process.exit(1); } + const cfg = loadConfig(); + const install = cfg.plugins?.installs?.[plugin.id]; if (opts.json) { defaultRuntime.log(JSON.stringify(plugin, null, 2)); @@ -151,6 +175,15 @@ export function registerPluginsCli(program: Command) { lines.push(`Services: ${plugin.services.join(", ")}`); } if (plugin.error) lines.push(chalk.red(`Error: ${plugin.error}`)); + if (install) { + lines.push(""); + lines.push(`Install: ${install.source}`); + if (install.spec) lines.push(`Spec: ${install.spec}`); + if (install.sourcePath) lines.push(`Source path: ${install.sourcePath}`); + if (install.installPath) lines.push(`Install path: ${install.installPath}`); + if (install.version) lines.push(`Recorded version: ${install.version}`); + if (install.installedAt) lines.push(`Installed at: ${install.installedAt}`); + } defaultRuntime.log(lines.join("\n")); }); @@ -223,7 +256,7 @@ export function registerPluginsCli(program: Command) { process.exit(1); } - const next = { + let next: ClawdbotConfig = { ...cfg, plugins: { ...cfg.plugins, @@ -236,6 +269,13 @@ export function registerPluginsCli(program: Command) { }, }, }; + next = recordPluginInstall(next, { + pluginId: result.pluginId, + source: "archive", + sourcePath: resolved, + installPath: result.targetDir, + version: result.version, + }); await writeConfigFile(next); defaultRuntime.log(`Installed plugin: ${result.pluginId}`); defaultRuntime.log(`Restart the gateway to load plugins.`); @@ -287,7 +327,7 @@ export function registerPluginsCli(program: Command) { process.exit(1); } - const next = { + let next: ClawdbotConfig = { ...cfg, plugins: { ...cfg.plugins, @@ -300,11 +340,124 @@ export function registerPluginsCli(program: Command) { }, }, }; + next = recordPluginInstall(next, { + pluginId: result.pluginId, + source: "npm", + spec: raw, + installPath: result.targetDir, + version: result.version, + }); await writeConfigFile(next); defaultRuntime.log(`Installed plugin: ${result.pluginId}`); defaultRuntime.log(`Restart the gateway to load plugins.`); }); + plugins + .command("update") + .description("Update installed plugins (npm installs only)") + .argument("[id]", "Plugin id (omit with --all)") + .option("--all", "Update all tracked plugins", false) + .option("--dry-run", "Show what would change without writing", false) + .action(async (id: string | undefined, opts: PluginUpdateOptions) => { + const cfg = loadConfig(); + const installs = cfg.plugins?.installs ?? {}; + const targets = opts.all ? Object.keys(installs) : id ? [id] : []; + + if (targets.length === 0) { + defaultRuntime.error("Provide a plugin id or use --all."); + process.exit(1); + } + + let nextCfg = cfg; + let updatedCount = 0; + + for (const pluginId of targets) { + const record = installs[pluginId]; + if (!record) { + defaultRuntime.log(chalk.yellow(`No install record for "${pluginId}".`)); + continue; + } + if (record.source !== "npm") { + defaultRuntime.log( + chalk.yellow(`Skipping "${pluginId}" (source: ${record.source}).`), + ); + continue; + } + if (!record.spec) { + defaultRuntime.log(chalk.yellow(`Skipping "${pluginId}" (missing npm spec).`)); + continue; + } + + const installPath = record.installPath ?? resolvePluginInstallDir(pluginId); + const currentVersion = await readInstalledPackageVersion(installPath); + + if (opts.dryRun) { + const probe = await installPluginFromNpmSpec({ + spec: record.spec, + mode: "update", + dryRun: true, + expectedPluginId: pluginId, + logger: { + info: (msg) => defaultRuntime.log(msg), + warn: (msg) => defaultRuntime.log(chalk.yellow(msg)), + }, + }); + if (!probe.ok) { + defaultRuntime.log(chalk.red(`Failed to check ${pluginId}: ${probe.error}`)); + continue; + } + + const nextVersion = probe.version ?? "unknown"; + const currentLabel = currentVersion ?? "unknown"; + if (currentVersion && probe.version && currentVersion === probe.version) { + defaultRuntime.log(`${pluginId} is up to date (${currentLabel}).`); + } else { + defaultRuntime.log( + `Would update ${pluginId}: ${currentLabel} → ${nextVersion}.`, + ); + } + continue; + } + + const result = await installPluginFromNpmSpec({ + spec: record.spec, + mode: "update", + expectedPluginId: pluginId, + logger: { + info: (msg) => defaultRuntime.log(msg), + warn: (msg) => defaultRuntime.log(chalk.yellow(msg)), + }, + }); + if (!result.ok) { + defaultRuntime.log(chalk.red(`Failed to update ${pluginId}: ${result.error}`)); + continue; + } + + const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir)); + nextCfg = recordPluginInstall(nextCfg, { + pluginId, + source: "npm", + spec: record.spec, + installPath: result.targetDir, + version: nextVersion, + }); + updatedCount += 1; + + const currentLabel = currentVersion ?? "unknown"; + const nextLabel = nextVersion ?? "unknown"; + if (currentVersion && nextVersion && currentVersion === nextVersion) { + defaultRuntime.log(`${pluginId} already at ${currentLabel}.`); + } else { + defaultRuntime.log(`Updated ${pluginId}: ${currentLabel} → ${nextLabel}.`); + } + } + + if (updatedCount > 0) { + await writeConfigFile(nextCfg); + defaultRuntime.log("Restart the gateway to load plugins."); + } + }); + plugins .command("doctor") .description("Report plugin load issues") diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index e8c7702f0..ffa2d1ae7 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -68,6 +68,9 @@ describe("ensureOnboardingPluginInstalled", () => { expect(result.installed).toBe(true); expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true); expect(result.cfg.plugins?.allow).toContain("zalo"); + expect(result.cfg.plugins?.installs?.zalo?.source).toBe("npm"); + expect(result.cfg.plugins?.installs?.zalo?.spec).toBe("@clawdbot/zalo"); + expect(result.cfg.plugins?.installs?.zalo?.installPath).toBe("/tmp/zalo"); expect(installPluginFromNpmSpec).toHaveBeenCalledWith( expect.objectContaining({ spec: "@clawdbot/zalo" }), ); diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index d4e31de2c..c6fded608 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -4,6 +4,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { createSubsystemLogger } from "../../logging.js"; +import { recordPluginInstall } from "../../plugins/installs.js"; import { loadClawdbotPlugins } from "../../plugins/loader.js"; import { installPluginFromNpmSpec } from "../../plugins/install.js"; import type { RuntimeEnv } from "../../runtime.js"; @@ -158,6 +159,13 @@ export async function ensureOnboardingPluginInstalled(params: { if (result.ok) { next = ensurePluginEnabled(next, result.pluginId); + next = recordPluginInstall(next, { + pluginId: result.pluginId, + source: "npm", + spec: entry.install.npmSpec, + installPath: result.targetDir, + version: result.version, + }); return { cfg: next, installed: true }; } diff --git a/src/config/schema.ts b/src/config/schema.ts index b62e7850e..065409a2c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -220,6 +220,13 @@ const FIELD_LABELS: Record = { "plugins.entries": "Plugin Entries", "plugins.entries.*.enabled": "Plugin Enabled", "plugins.entries.*.config": "Plugin Config", + "plugins.installs": "Plugin Install Records", + "plugins.installs.*.source": "Plugin Install Source", + "plugins.installs.*.spec": "Plugin Install Spec", + "plugins.installs.*.sourcePath": "Plugin Install Source Path", + "plugins.installs.*.installPath": "Plugin Install Path", + "plugins.installs.*.version": "Plugin Install Version", + "plugins.installs.*.installedAt": "Plugin Install Time", }; const FIELD_HELP: Record = { @@ -291,6 +298,14 @@ const FIELD_HELP: Record = { "plugins.entries": "Per-plugin settings keyed by plugin id (enable/disable + config payloads).", "plugins.entries.*.enabled": "Overrides plugin enable/disable for this entry (restart required).", "plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).", + "plugins.installs": + "CLI-managed install metadata (used by `clawdbot plugins update` to locate install sources).", + "plugins.installs.*.source": 'Install source ("npm", "archive", or "path").', + "plugins.installs.*.spec": "Original npm spec used for install (if source is npm).", + "plugins.installs.*.sourcePath": "Original archive/path used for install (if any).", + "plugins.installs.*.installPath": "Resolved install directory (usually ~/.clawdbot/extensions/).", + "plugins.installs.*.version": "Version recorded at install time (if available).", + "plugins.installs.*.installedAt": "ISO timestamp of last install/update.", "agents.defaults.model.primary": "Primary model (provider/model).", "agents.defaults.model.fallbacks": "Ordered fallback models (provider/model). Used when the primary model fails.", diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 731c7fb55..e6cb7807d 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -8,6 +8,15 @@ export type PluginsLoadConfig = { paths?: string[]; }; +export type PluginInstallRecord = { + source: "npm" | "archive" | "path"; + spec?: string; + sourcePath?: string; + installPath?: string; + version?: string; + installedAt?: string; +}; + export type PluginsConfig = { /** Enable or disable plugin loading. */ enabled?: boolean; @@ -17,4 +26,5 @@ export type PluginsConfig = { deny?: string[]; load?: PluginsLoadConfig; entries?: Record; + installs?: Record; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index f75558fd7..4ebcc48ee 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -326,6 +326,21 @@ export const ClawdbotSchema = z .passthrough(), ) .optional(), + installs: z + .record( + z.string(), + z + .object({ + source: z.union([z.literal("npm"), z.literal("archive"), z.literal("path")]), + spec: z.string().optional(), + sourcePath: z.string().optional(), + installPath: z.string().optional(), + version: z.string().optional(), + installedAt: z.string().optional(), + }) + .passthrough(), + ) + .optional(), }) .optional(), }) diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index a41ed84b8..4c399167e 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -172,6 +172,61 @@ describe("installPluginFromArchive", () => { expect(second.error).toContain("already exists"); }); + it("allows updates when mode is update", async () => { + const stateDir = makeTempDir(); + const workDir = makeTempDir(); + const pkgDir = path.join(workDir, "package"); + fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ + name: "@clawdbot/voice-call", + version: "0.0.1", + clawdbot: { extensions: ["./dist/index.js"] }, + }), + "utf-8", + ); + fs.writeFileSync(path.join(pkgDir, "dist", "index.js"), "export {};", "utf-8"); + + const archiveV1 = packToArchive({ + pkgDir, + outDir: workDir, + outName: "plugin-v1.tgz", + }); + + const archiveV2 = (() => { + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ + name: "@clawdbot/voice-call", + version: "0.0.2", + clawdbot: { extensions: ["./dist/index.js"] }, + }), + "utf-8", + ); + return packToArchive({ + pkgDir, + outDir: workDir, + outName: "plugin-v2.tgz", + }); + })(); + + const result = await withStateDir(stateDir, async () => { + const { installPluginFromArchive } = await import("./install.js"); + const first = await installPluginFromArchive({ archivePath: archiveV1 }); + const second = await installPluginFromArchive({ archivePath: archiveV2, mode: "update" }); + return { first, second }; + }); + + expect(result.first.ok).toBe(true); + expect(result.second.ok).toBe(true); + if (!result.second.ok) return; + const manifest = JSON.parse( + fs.readFileSync(path.join(result.second.targetDir, "package.json"), "utf-8"), + ) as { version?: string }; + expect(manifest.version).toBe("0.0.2"); + }); + it("rejects packages without clawdbot.extensions", async () => { const stateDir = makeTempDir(); const workDir = makeTempDir(); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index af6bab3eb..b2a8871f5 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -12,6 +12,7 @@ type PluginInstallLogger = { type PackageManifest = { name?: string; + version?: string; dependencies?: Record; clawdbot?: { extensions?: string[] }; }; @@ -22,6 +23,7 @@ export type InstallPluginResult = pluginId: string; targetDir: string; manifestName?: string; + version?: string; extensions: string[]; } | { ok: false; error: string }; @@ -70,6 +72,13 @@ async function resolvePackedPackageDir(extractDir: string): Promise { return path.join(extractDir, onlyDir); } +export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string { + const extensionsBase = extensionsDir + ? resolveUserPath(extensionsDir) + : path.join(CONFIG_DIR, "extensions"); + return path.join(extensionsBase, safeDirName(pluginId)); +} + async function ensureClawdbotExtensions(manifest: PackageManifest) { const extensions = manifest.clawdbot?.extensions; if (!Array.isArray(extensions)) { @@ -104,9 +113,14 @@ export async function installPluginFromArchive(params: { extensionsDir?: string; timeoutMs?: number; logger?: PluginInstallLogger; + mode?: "install" | "update"; + dryRun?: boolean; + expectedPluginId?: string; }): Promise { const logger = params.logger ?? defaultLogger; const timeoutMs = params.timeoutMs ?? 120_000; + const mode = params.mode ?? "install"; + const dryRun = params.dryRun ?? false; const archivePath = resolveUserPath(params.archivePath); if (!(await fileExists(archivePath))) { @@ -157,17 +171,47 @@ export async function installPluginFromArchive(params: { const pkgName = typeof manifest.name === "string" ? manifest.name : ""; const pluginId = pkgName ? unscopedPackageName(pkgName) : "plugin"; + if (params.expectedPluginId && params.expectedPluginId !== pluginId) { + return { + ok: false, + error: `plugin id mismatch: expected ${params.expectedPluginId}, got ${pluginId}`, + }; + } const targetDir = path.join(extensionsDir, safeDirName(pluginId)); - if (await fileExists(targetDir)) { + if (mode === "install" && (await fileExists(targetDir))) { return { ok: false, error: `plugin already exists: ${targetDir} (delete it first)`, }; } + if (dryRun) { + return { + ok: true, + pluginId, + targetDir, + manifestName: pkgName || undefined, + version: typeof manifest.version === "string" ? manifest.version : undefined, + extensions, + }; + } + logger.info?.(`Installing to ${targetDir}…`); - await fs.cp(packageDir, targetDir, { recursive: true }); + let backupDir: string | null = null; + if (mode === "update" && (await fileExists(targetDir))) { + backupDir = `${targetDir}.backup-${Date.now()}`; + await fs.rename(targetDir, backupDir); + } + try { + await fs.cp(packageDir, targetDir, { recursive: true }); + } catch (err) { + if (backupDir) { + await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined); + await fs.rename(backupDir, targetDir).catch(() => undefined); + } + return { ok: false, error: `failed to copy plugin: ${String(err)}` }; + } for (const entry of extensions) { const resolvedEntry = path.resolve(targetDir, entry); @@ -185,6 +229,10 @@ export async function installPluginFromArchive(params: { cwd: targetDir, }); if (npmRes.code !== 0) { + if (backupDir) { + await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined); + await fs.rename(backupDir, targetDir).catch(() => undefined); + } return { ok: false, error: `npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`, @@ -192,11 +240,16 @@ export async function installPluginFromArchive(params: { } } + if (backupDir) { + await fs.rm(backupDir, { recursive: true, force: true }).catch(() => undefined); + } + return { ok: true, pluginId, targetDir, manifestName: pkgName || undefined, + version: typeof manifest.version === "string" ? manifest.version : undefined, extensions, }; } @@ -206,9 +259,15 @@ export async function installPluginFromNpmSpec(params: { extensionsDir?: string; timeoutMs?: number; logger?: PluginInstallLogger; + mode?: "install" | "update"; + dryRun?: boolean; + expectedPluginId?: string; }): Promise { const logger = params.logger ?? defaultLogger; const timeoutMs = params.timeoutMs ?? 120_000; + const mode = params.mode ?? "install"; + const dryRun = params.dryRun ?? false; + const expectedPluginId = params.expectedPluginId; const spec = params.spec.trim(); if (!spec) return { ok: false, error: "missing npm spec" }; @@ -241,5 +300,8 @@ export async function installPluginFromNpmSpec(params: { extensionsDir: params.extensionsDir, timeoutMs, logger, + mode, + dryRun, + expectedPluginId, }); } diff --git a/src/plugins/installs.ts b/src/plugins/installs.ts new file mode 100644 index 000000000..193f60ef0 --- /dev/null +++ b/src/plugins/installs.ts @@ -0,0 +1,27 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; + +export type PluginInstallUpdate = PluginInstallRecord & { pluginId: string }; + +export function recordPluginInstall(cfg: ClawdbotConfig, update: PluginInstallUpdate): ClawdbotConfig { + const { pluginId, ...record } = update; + const installs = { + ...cfg.plugins?.installs, + [pluginId]: { + ...cfg.plugins?.installs?.[pluginId], + ...record, + installedAt: record.installedAt ?? new Date().toISOString(), + }, + }; + + return { + ...cfg, + plugins: { + ...cfg.plugins, + installs: { + ...installs, + [pluginId]: installs[pluginId], + }, + }, + }; +}