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 enable <id>
|
||||||
clawdbot plugins disable <id>
|
clawdbot plugins disable <id>
|
||||||
clawdbot plugins doctor
|
clawdbot plugins doctor
|
||||||
|
clawdbot plugins update <id>
|
||||||
|
clawdbot plugins update --all
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
@@ -31,3 +33,12 @@ clawdbot plugins install <npm-spec>
|
|||||||
|
|
||||||
Security note: treat plugin installs like running code. Prefer pinned versions.
|
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 ./extensions/voice-call # relative path ok
|
||||||
clawdbot plugins install ./plugin.tgz # install from a local tarball
|
clawdbot plugins install ./plugin.tgz # install from a local tarball
|
||||||
clawdbot plugins install @clawdbot/voice-call # install from npm
|
clawdbot plugins install @clawdbot/voice-call # install from npm
|
||||||
|
clawdbot plugins update <id>
|
||||||
|
clawdbot plugins update --all
|
||||||
clawdbot plugins enable <id>
|
clawdbot plugins enable <id>
|
||||||
clawdbot plugins disable <id>
|
clawdbot plugins disable <id>
|
||||||
clawdbot plugins doctor
|
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`).
|
Plugins may also register their own top‑level commands (example: `clawdbot voicecall`).
|
||||||
|
|
||||||
## Plugin API (overview)
|
## Plugin API (overview)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Cla
|
|||||||
if (!watchEnabled) {
|
if (!watchEnabled) {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
watchers.delete(workspaceDir);
|
watchers.delete(workspaceDir);
|
||||||
existing.timer && clearTimeout(existing.timer);
|
if (existing.timer) clearTimeout(existing.timer);
|
||||||
void existing.watcher.close().catch(() => {});
|
void existing.watcher.close().catch(() => {});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -115,7 +115,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Cla
|
|||||||
}
|
}
|
||||||
if (existing) {
|
if (existing) {
|
||||||
watchers.delete(workspaceDir);
|
watchers.delete(workspaceDir);
|
||||||
existing.timer && clearTimeout(existing.timer);
|
if (existing.timer) clearTimeout(existing.timer);
|
||||||
void existing.watcher.close().catch(() => {});
|
void existing.watcher.close().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import fsp from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
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 type { PluginRecord } from "../plugins/registry.js";
|
||||||
import { buildPluginStatusReport } from "../plugins/status.js";
|
import { buildPluginStatusReport } from "../plugins/status.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@@ -22,6 +29,11 @@ export type PluginInfoOptions = {
|
|||||||
json?: boolean;
|
json?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PluginUpdateOptions = {
|
||||||
|
all?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function formatPluginLine(plugin: PluginRecord, verbose = false): string {
|
function formatPluginLine(plugin: PluginRecord, verbose = false): string {
|
||||||
const status =
|
const status =
|
||||||
plugin.status === "loaded"
|
plugin.status === "loaded"
|
||||||
@@ -56,6 +68,16 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
|
|||||||
return parts.join("\n");
|
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) {
|
export function registerPluginsCli(program: Command) {
|
||||||
const plugins = program
|
const plugins = program
|
||||||
.command("plugins")
|
.command("plugins")
|
||||||
@@ -118,6 +140,8 @@ export function registerPluginsCli(program: Command) {
|
|||||||
defaultRuntime.error(`Plugin not found: ${id}`);
|
defaultRuntime.error(`Plugin not found: ${id}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const install = cfg.plugins?.installs?.[plugin.id];
|
||||||
|
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
defaultRuntime.log(JSON.stringify(plugin, null, 2));
|
defaultRuntime.log(JSON.stringify(plugin, null, 2));
|
||||||
@@ -151,6 +175,15 @@ export function registerPluginsCli(program: Command) {
|
|||||||
lines.push(`Services: ${plugin.services.join(", ")}`);
|
lines.push(`Services: ${plugin.services.join(", ")}`);
|
||||||
}
|
}
|
||||||
if (plugin.error) lines.push(chalk.red(`Error: ${plugin.error}`));
|
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"));
|
defaultRuntime.log(lines.join("\n"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -223,7 +256,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = {
|
let next: ClawdbotConfig = {
|
||||||
...cfg,
|
...cfg,
|
||||||
plugins: {
|
plugins: {
|
||||||
...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);
|
await writeConfigFile(next);
|
||||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
@@ -287,7 +327,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = {
|
let next: ClawdbotConfig = {
|
||||||
...cfg,
|
...cfg,
|
||||||
plugins: {
|
plugins: {
|
||||||
...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);
|
await writeConfigFile(next);
|
||||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
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
|
plugins
|
||||||
.command("doctor")
|
.command("doctor")
|
||||||
.description("Report plugin load issues")
|
.description("Report plugin load issues")
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ describe("ensureOnboardingPluginInstalled", () => {
|
|||||||
expect(result.installed).toBe(true);
|
expect(result.installed).toBe(true);
|
||||||
expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true);
|
expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true);
|
||||||
expect(result.cfg.plugins?.allow).toContain("zalo");
|
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(installPluginFromNpmSpec).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ spec: "@clawdbot/zalo" }),
|
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 { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { createSubsystemLogger } from "../../logging.js";
|
import { createSubsystemLogger } from "../../logging.js";
|
||||||
|
import { recordPluginInstall } from "../../plugins/installs.js";
|
||||||
import { loadClawdbotPlugins } from "../../plugins/loader.js";
|
import { loadClawdbotPlugins } from "../../plugins/loader.js";
|
||||||
import { installPluginFromNpmSpec } from "../../plugins/install.js";
|
import { installPluginFromNpmSpec } from "../../plugins/install.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
@@ -158,6 +159,13 @@ export async function ensureOnboardingPluginInstalled(params: {
|
|||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
next = ensurePluginEnabled(next, result.pluginId);
|
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 };
|
return { cfg: next, installed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,13 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"plugins.entries": "Plugin Entries",
|
"plugins.entries": "Plugin Entries",
|
||||||
"plugins.entries.*.enabled": "Plugin Enabled",
|
"plugins.entries.*.enabled": "Plugin Enabled",
|
||||||
"plugins.entries.*.config": "Plugin Config",
|
"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> = {
|
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": "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.*.enabled": "Overrides plugin enable/disable for this entry (restart required).",
|
||||||
"plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).",
|
"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.primary": "Primary model (provider/model).",
|
||||||
"agents.defaults.model.fallbacks":
|
"agents.defaults.model.fallbacks":
|
||||||
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ export type PluginsLoadConfig = {
|
|||||||
paths?: string[];
|
paths?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PluginInstallRecord = {
|
||||||
|
source: "npm" | "archive" | "path";
|
||||||
|
spec?: string;
|
||||||
|
sourcePath?: string;
|
||||||
|
installPath?: string;
|
||||||
|
version?: string;
|
||||||
|
installedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PluginsConfig = {
|
export type PluginsConfig = {
|
||||||
/** Enable or disable plugin loading. */
|
/** Enable or disable plugin loading. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -17,4 +26,5 @@ export type PluginsConfig = {
|
|||||||
deny?: string[];
|
deny?: string[];
|
||||||
load?: PluginsLoadConfig;
|
load?: PluginsLoadConfig;
|
||||||
entries?: Record<string, PluginEntryConfig>;
|
entries?: Record<string, PluginEntryConfig>;
|
||||||
|
installs?: Record<string, PluginInstallRecord>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -326,6 +326,21 @@ export const ClawdbotSchema = z
|
|||||||
.passthrough(),
|
.passthrough(),
|
||||||
)
|
)
|
||||||
.optional(),
|
.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(),
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -172,6 +172,61 @@ describe("installPluginFromArchive", () => {
|
|||||||
expect(second.error).toContain("already exists");
|
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 () => {
|
it("rejects packages without clawdbot.extensions", async () => {
|
||||||
const stateDir = makeTempDir();
|
const stateDir = makeTempDir();
|
||||||
const workDir = makeTempDir();
|
const workDir = makeTempDir();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type PluginInstallLogger = {
|
|||||||
|
|
||||||
type PackageManifest = {
|
type PackageManifest = {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
version?: string;
|
||||||
dependencies?: Record<string, string>;
|
dependencies?: Record<string, string>;
|
||||||
clawdbot?: { extensions?: string[] };
|
clawdbot?: { extensions?: string[] };
|
||||||
};
|
};
|
||||||
@@ -22,6 +23,7 @@ export type InstallPluginResult =
|
|||||||
pluginId: string;
|
pluginId: string;
|
||||||
targetDir: string;
|
targetDir: string;
|
||||||
manifestName?: string;
|
manifestName?: string;
|
||||||
|
version?: string;
|
||||||
extensions: string[];
|
extensions: string[];
|
||||||
}
|
}
|
||||||
| { ok: false; error: string };
|
| { ok: false; error: string };
|
||||||
@@ -70,6 +72,13 @@ async function resolvePackedPackageDir(extractDir: string): Promise<string> {
|
|||||||
return path.join(extractDir, onlyDir);
|
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) {
|
async function ensureClawdbotExtensions(manifest: PackageManifest) {
|
||||||
const extensions = manifest.clawdbot?.extensions;
|
const extensions = manifest.clawdbot?.extensions;
|
||||||
if (!Array.isArray(extensions)) {
|
if (!Array.isArray(extensions)) {
|
||||||
@@ -104,9 +113,14 @@ export async function installPluginFromArchive(params: {
|
|||||||
extensionsDir?: string;
|
extensionsDir?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
logger?: PluginInstallLogger;
|
logger?: PluginInstallLogger;
|
||||||
|
mode?: "install" | "update";
|
||||||
|
dryRun?: boolean;
|
||||||
|
expectedPluginId?: string;
|
||||||
}): Promise<InstallPluginResult> {
|
}): Promise<InstallPluginResult> {
|
||||||
const logger = params.logger ?? defaultLogger;
|
const logger = params.logger ?? defaultLogger;
|
||||||
const timeoutMs = params.timeoutMs ?? 120_000;
|
const timeoutMs = params.timeoutMs ?? 120_000;
|
||||||
|
const mode = params.mode ?? "install";
|
||||||
|
const dryRun = params.dryRun ?? false;
|
||||||
|
|
||||||
const archivePath = resolveUserPath(params.archivePath);
|
const archivePath = resolveUserPath(params.archivePath);
|
||||||
if (!(await fileExists(archivePath))) {
|
if (!(await fileExists(archivePath))) {
|
||||||
@@ -157,17 +171,47 @@ export async function installPluginFromArchive(params: {
|
|||||||
|
|
||||||
const pkgName = typeof manifest.name === "string" ? manifest.name : "";
|
const pkgName = typeof manifest.name === "string" ? manifest.name : "";
|
||||||
const pluginId = pkgName ? unscopedPackageName(pkgName) : "plugin";
|
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));
|
const targetDir = path.join(extensionsDir, safeDirName(pluginId));
|
||||||
|
|
||||||
if (await fileExists(targetDir)) {
|
if (mode === "install" && (await fileExists(targetDir))) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `plugin already exists: ${targetDir} (delete it first)`,
|
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}…`);
|
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) {
|
for (const entry of extensions) {
|
||||||
const resolvedEntry = path.resolve(targetDir, entry);
|
const resolvedEntry = path.resolve(targetDir, entry);
|
||||||
@@ -185,6 +229,10 @@ export async function installPluginFromArchive(params: {
|
|||||||
cwd: targetDir,
|
cwd: targetDir,
|
||||||
});
|
});
|
||||||
if (npmRes.code !== 0) {
|
if (npmRes.code !== 0) {
|
||||||
|
if (backupDir) {
|
||||||
|
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
await fs.rename(backupDir, targetDir).catch(() => undefined);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`,
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginId,
|
pluginId,
|
||||||
targetDir,
|
targetDir,
|
||||||
manifestName: pkgName || undefined,
|
manifestName: pkgName || undefined,
|
||||||
|
version: typeof manifest.version === "string" ? manifest.version : undefined,
|
||||||
extensions,
|
extensions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -206,9 +259,15 @@ export async function installPluginFromNpmSpec(params: {
|
|||||||
extensionsDir?: string;
|
extensionsDir?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
logger?: PluginInstallLogger;
|
logger?: PluginInstallLogger;
|
||||||
|
mode?: "install" | "update";
|
||||||
|
dryRun?: boolean;
|
||||||
|
expectedPluginId?: string;
|
||||||
}): Promise<InstallPluginResult> {
|
}): Promise<InstallPluginResult> {
|
||||||
const logger = params.logger ?? defaultLogger;
|
const logger = params.logger ?? defaultLogger;
|
||||||
const timeoutMs = params.timeoutMs ?? 120_000;
|
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();
|
const spec = params.spec.trim();
|
||||||
if (!spec) return { ok: false, error: "missing npm spec" };
|
if (!spec) return { ok: false, error: "missing npm spec" };
|
||||||
|
|
||||||
@@ -241,5 +300,8 @@ export async function installPluginFromNpmSpec(params: {
|
|||||||
extensionsDir: params.extensionsDir,
|
extensionsDir: params.extensionsDir,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
logger,
|
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