feat: add plugin update tracking
This commit is contained in:
@@ -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}…`);
|
||||
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
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