mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-01-31 19:37:45 +01:00
feat: add plugin update tracking
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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 top‑level commands (example: `clawdbot voicecall`).
|
||||
|
||||
## Plugin API (overview)
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}…`);
|
||||
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
27
src/plugins/installs.ts
Normal 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],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user