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

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