feat: sync plugin updates with update channel

This commit is contained in:
Peter Steinberger
2026-01-20 15:56:48 +00:00
parent 91ed00f800
commit 99fc0fbac1
7 changed files with 550 additions and 91 deletions

View File

@@ -1,5 +1,4 @@
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";
@@ -7,15 +6,12 @@ import type { Command } from "commander";
import { loadConfig, writeConfigFile } from "../config/config.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveArchiveKind } from "../infra/archive.js";
import {
installPluginFromNpmSpec,
installPluginFromPath,
resolvePluginInstallDir,
} from "../plugins/install.js";
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
import { recordPluginInstall } from "../plugins/installs.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import type { PluginRecord } from "../plugins/registry.js";
import { buildPluginStatusReport } from "../plugins/status.js";
import { updateNpmInstalledPlugins } from "../plugins/update.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
@@ -70,16 +66,6 @@ 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;
}
}
function applySlotSelectionForPlugin(
config: ClawdbotConfig,
pluginId: string,
@@ -438,88 +424,30 @@ export function registerPluginsCli(program: Command) {
process.exit(1);
}
let nextCfg = cfg;
let updatedCount = 0;
const result = await updateNpmInstalledPlugins({
config: cfg,
pluginIds: targets,
dryRun: opts.dryRun,
logger: {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
},
});
for (const pluginId of targets) {
const record = installs[pluginId];
if (!record) {
defaultRuntime.log(chalk.yellow(`No install record for "${pluginId}".`));
for (const outcome of result.outcomes) {
if (outcome.status === "error") {
defaultRuntime.log(chalk.red(outcome.message));
continue;
}
if (record.source !== "npm") {
defaultRuntime.log(chalk.yellow(`Skipping "${pluginId}" (source: ${record.source}).`));
if (outcome.status === "skipped") {
defaultRuntime.log(chalk.yellow(outcome.message));
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}.`);
}
defaultRuntime.log(outcome.message);
}
if (updatedCount > 0) {
await writeConfigFile(nextCfg);
if (!opts.dryRun && result.changed) {
await writeConfigFile(result.config);
defaultRuntime.log("Restart the gateway to load plugins.");
}
});

View File

@@ -38,6 +38,11 @@ vi.mock("../commands/doctor.js", () => ({
vi.mock("./daemon-cli.js", () => ({
runDaemonRestart: vi.fn(),
}));
// Mock plugin update helpers
vi.mock("../plugins/update.js", () => ({
syncPluginsForUpdateChannel: vi.fn(),
updateNpmInstalledPlugins: vi.fn(),
}));
// Mock the runtime
vi.mock("../runtime.js", () => ({
@@ -74,6 +79,8 @@ describe("update-cli", () => {
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
const { readConfigFileSnapshot } = await import("../config/config.js");
const { checkUpdateStatus, fetchNpmTagVersion } = await import("../infra/update-check.js");
const { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } =
await import("../plugins/update.js");
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd());
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
@@ -105,6 +112,16 @@ describe("update-cli", () => {
latestVersion: "1.2.3",
},
});
vi.mocked(syncPluginsForUpdateChannel).mockResolvedValue({
config: baseSnapshot.config,
changed: false,
summary: { switchedToBundled: [], switchedToNpm: [], warnings: [], errors: [] },
});
vi.mocked(updateNpmInstalledPlugins).mockResolvedValue({
config: baseSnapshot.config,
changed: false,
outcomes: [],
});
setTty(false);
setStdoutTty(false);
});
@@ -146,6 +163,25 @@ describe("update-cli", () => {
expect(defaultRuntime.log).toHaveBeenCalled();
});
it("updateCommand syncs plugins after a successful update", async () => {
const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } =
await import("../plugins/update.js");
const { updateCommand } = await import("./update-cli.js");
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
mode: "git",
steps: [],
durationMs: 100,
});
await updateCommand({});
expect(syncPluginsForUpdateChannel).toHaveBeenCalled();
expect(updateNpmInstalledPlugins).toHaveBeenCalled();
});
it("updateStatusCommand prints table output", async () => {
const { defaultRuntime } = await import("../runtime.js");
const { updateStatusCommand } = await import("./update-cli.js");

View File

@@ -36,6 +36,7 @@ import {
formatUpdateOneLiner,
resolveUpdateAvailability,
} from "../commands/status.update.js";
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../plugins/update.js";
export type UpdateCommandOptions = {
json?: boolean;
@@ -389,6 +390,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
})) ?? process.cwd();
const configSnapshot = await readConfigFileSnapshot();
let activeConfig = configSnapshot.valid ? configSnapshot.config : null;
const storedChannel = configSnapshot.valid
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
: null;
@@ -459,6 +461,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
},
};
await writeConfigFile(next);
activeConfig = next;
if (!opts.json) {
defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`));
}
@@ -513,6 +516,87 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
return;
}
if (activeConfig) {
const pluginLogger = opts.json
? {}
: {
info: (msg: string) => defaultRuntime.log(msg),
warn: (msg: string) => defaultRuntime.log(theme.warn(msg)),
error: (msg: string) => defaultRuntime.log(theme.error(msg)),
};
if (!opts.json) {
defaultRuntime.log("");
defaultRuntime.log(theme.heading("Updating plugins..."));
}
const syncResult = await syncPluginsForUpdateChannel({
config: activeConfig,
channel,
workspaceDir: root,
logger: pluginLogger,
});
let pluginConfig = syncResult.config;
const npmResult = await updateNpmInstalledPlugins({
config: pluginConfig,
skipIds: new Set(syncResult.summary.switchedToNpm),
logger: pluginLogger,
});
pluginConfig = npmResult.config;
if (syncResult.changed || npmResult.changed) {
await writeConfigFile(pluginConfig);
}
if (!opts.json) {
const summarizeList = (list: string[]) => {
if (list.length <= 6) return list.join(", ");
return `${list.slice(0, 6).join(", ")} +${list.length - 6} more`;
};
if (syncResult.summary.switchedToBundled.length > 0) {
defaultRuntime.log(
theme.muted(
`Switched to bundled plugins: ${summarizeList(syncResult.summary.switchedToBundled)}.`,
),
);
}
if (syncResult.summary.switchedToNpm.length > 0) {
defaultRuntime.log(
theme.muted(`Restored npm plugins: ${summarizeList(syncResult.summary.switchedToNpm)}.`),
);
}
for (const warning of syncResult.summary.warnings) {
defaultRuntime.log(theme.warn(warning));
}
for (const error of syncResult.summary.errors) {
defaultRuntime.log(theme.error(error));
}
const updated = npmResult.outcomes.filter((entry) => entry.status === "updated").length;
const unchanged = npmResult.outcomes.filter((entry) => entry.status === "unchanged").length;
const failed = npmResult.outcomes.filter((entry) => entry.status === "error").length;
const skipped = npmResult.outcomes.filter((entry) => entry.status === "skipped").length;
if (npmResult.outcomes.length === 0) {
defaultRuntime.log(theme.muted("No plugin updates needed."));
} else {
const parts = [`${updated} updated`, `${unchanged} unchanged`];
if (failed > 0) parts.push(`${failed} failed`);
if (skipped > 0) parts.push(`${skipped} skipped`);
defaultRuntime.log(theme.muted(`npm plugins: ${parts.join(", ")}.`));
}
for (const outcome of npmResult.outcomes) {
if (outcome.status !== "error") continue;
defaultRuntime.log(theme.error(outcome.message));
}
}
} else if (!opts.json) {
defaultRuntime.log(theme.warn("Skipping plugin updates: config is invalid."));
}
// Restart daemon if requested
if (opts.restart) {
if (!opts.json) {