feat: add plugin update tracking

This commit is contained in:
Peter Steinberger
2026-01-16 05:54:47 +00:00
parent d0c70178e0
commit 54ec14262b
12 changed files with 370 additions and 7 deletions

View File

@@ -21,6 +21,8 @@ clawdbot plugins info <id>
clawdbot plugins enable <id>
clawdbot plugins disable <id>
clawdbot plugins doctor
clawdbot plugins update <id>
clawdbot plugins update --all
```
### Install
@@ -31,3 +33,12 @@ clawdbot plugins install <npm-spec>
Security note: treat plugin installs like running code. Prefer pinned versions.
### Update
```bash
clawdbot plugins update <id>
clawdbot plugins update --all
clawdbot plugins update <id> --dry-run
```
Updates only apply to plugins installed from npm (tracked in `plugins.installs`).

View File

@@ -160,11 +160,15 @@ clawdbot plugins install <path> # 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 <id>
clawdbot plugins update --all
clawdbot plugins enable <id>
clawdbot plugins disable <id>
clawdbot plugins doctor
```
`plugins update` only works for npm installs tracked under `plugins.installs`.
Plugins may also register their own toplevel commands (example: `clawdbot voicecall`).
## Plugin API (overview)

View File

@@ -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(() => {});
}

View File

@@ -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<string | undefined> {
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")

View File

@@ -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" }),
);

View File

@@ -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 };
}

View File

@@ -220,6 +220,13 @@ const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
@@ -291,6 +298,14 @@ const FIELD_HELP: Record<string, string> = {
"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/<id>).",
"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.",

View File

@@ -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<string, PluginEntryConfig>;
installs?: Record<string, PluginInstallRecord>;
};

View File

@@ -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(),
})

View File

@@ -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();

View File

@@ -12,6 +12,7 @@ type PluginInstallLogger = {
type PackageManifest = {
name?: string;
version?: string;
dependencies?: Record<string, string>;
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<string> {
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<InstallPluginResult> {
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<InstallPluginResult> {
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,
});
}

27
src/plugins/installs.ts Normal file
View File

@@ -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],
},
},
};
}