feat: sync plugin updates with update channel
This commit is contained in:
@@ -7,6 +7,8 @@ Docs: https://docs.clawd.bot
|
|||||||
### Changes
|
### Changes
|
||||||
- Deps: update workspace + memory-lancedb dependencies.
|
- Deps: update workspace + memory-lancedb dependencies.
|
||||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||||
|
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||||
|
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
||||||
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
||||||
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
|
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ High-level:
|
|||||||
4. Installs deps (pnpm preferred; npm fallback).
|
4. Installs deps (pnpm preferred; npm fallback).
|
||||||
5. Builds + builds the Control UI.
|
5. Builds + builds the Control UI.
|
||||||
6. Runs `clawdbot doctor` as the final “safe update” check.
|
6. Runs `clawdbot doctor` as the final “safe update” check.
|
||||||
|
7. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
|
||||||
|
|
||||||
## `--update` shorthand
|
## `--update` shorthand
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
|
|||||||
|
|
||||||
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
|
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
|
||||||
|
|
||||||
|
## Plugins and channels
|
||||||
|
|
||||||
|
When you switch channels with `clawdbot update`, Clawdbot also syncs plugin sources:
|
||||||
|
|
||||||
|
- `dev` prefers bundled plugins from the git checkout.
|
||||||
|
- `stable` and `beta` restore npm-installed plugin packages.
|
||||||
|
|
||||||
## Tagging best practices
|
## Tagging best practices
|
||||||
|
|
||||||
- Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
|
- Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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";
|
||||||
@@ -7,15 +6,12 @@ import type { Command } from "commander";
|
|||||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { resolveArchiveKind } from "../infra/archive.js";
|
import { resolveArchiveKind } from "../infra/archive.js";
|
||||||
import {
|
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
|
||||||
installPluginFromNpmSpec,
|
|
||||||
installPluginFromPath,
|
|
||||||
resolvePluginInstallDir,
|
|
||||||
} from "../plugins/install.js";
|
|
||||||
import { recordPluginInstall } from "../plugins/installs.js";
|
import { recordPluginInstall } from "../plugins/installs.js";
|
||||||
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
|
import { applyExclusiveSlotSelection } from "../plugins/slots.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 { updateNpmInstalledPlugins } from "../plugins/update.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
@@ -70,16 +66,6 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySlotSelectionForPlugin(
|
function applySlotSelectionForPlugin(
|
||||||
config: ClawdbotConfig,
|
config: ClawdbotConfig,
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
@@ -438,88 +424,30 @@ export function registerPluginsCli(program: Command) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextCfg = cfg;
|
const result = await updateNpmInstalledPlugins({
|
||||||
let updatedCount = 0;
|
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) {
|
for (const outcome of result.outcomes) {
|
||||||
const record = installs[pluginId];
|
if (outcome.status === "error") {
|
||||||
if (!record) {
|
defaultRuntime.log(chalk.red(outcome.message));
|
||||||
defaultRuntime.log(chalk.yellow(`No install record for "${pluginId}".`));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (record.source !== "npm") {
|
if (outcome.status === "skipped") {
|
||||||
defaultRuntime.log(chalk.yellow(`Skipping "${pluginId}" (source: ${record.source}).`));
|
defaultRuntime.log(chalk.yellow(outcome.message));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!record.spec) {
|
defaultRuntime.log(outcome.message);
|
||||||
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) {
|
if (!opts.dryRun && result.changed) {
|
||||||
await writeConfigFile(nextCfg);
|
await writeConfigFile(result.config);
|
||||||
defaultRuntime.log("Restart the gateway to load plugins.");
|
defaultRuntime.log("Restart the gateway to load plugins.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ vi.mock("../commands/doctor.js", () => ({
|
|||||||
vi.mock("./daemon-cli.js", () => ({
|
vi.mock("./daemon-cli.js", () => ({
|
||||||
runDaemonRestart: vi.fn(),
|
runDaemonRestart: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
// Mock plugin update helpers
|
||||||
|
vi.mock("../plugins/update.js", () => ({
|
||||||
|
syncPluginsForUpdateChannel: vi.fn(),
|
||||||
|
updateNpmInstalledPlugins: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the runtime
|
// Mock the runtime
|
||||||
vi.mock("../runtime.js", () => ({
|
vi.mock("../runtime.js", () => ({
|
||||||
@@ -74,6 +79,8 @@ describe("update-cli", () => {
|
|||||||
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
||||||
const { readConfigFileSnapshot } = await import("../config/config.js");
|
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||||
const { checkUpdateStatus, fetchNpmTagVersion } = await import("../infra/update-check.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(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd());
|
||||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
|
||||||
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
||||||
@@ -105,6 +112,16 @@ describe("update-cli", () => {
|
|||||||
latestVersion: "1.2.3",
|
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);
|
setTty(false);
|
||||||
setStdoutTty(false);
|
setStdoutTty(false);
|
||||||
});
|
});
|
||||||
@@ -146,6 +163,25 @@ describe("update-cli", () => {
|
|||||||
expect(defaultRuntime.log).toHaveBeenCalled();
|
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 () => {
|
it("updateStatusCommand prints table output", async () => {
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
const { defaultRuntime } = await import("../runtime.js");
|
||||||
const { updateStatusCommand } = await import("./update-cli.js");
|
const { updateStatusCommand } = await import("./update-cli.js");
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
formatUpdateOneLiner,
|
formatUpdateOneLiner,
|
||||||
resolveUpdateAvailability,
|
resolveUpdateAvailability,
|
||||||
} from "../commands/status.update.js";
|
} from "../commands/status.update.js";
|
||||||
|
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../plugins/update.js";
|
||||||
|
|
||||||
export type UpdateCommandOptions = {
|
export type UpdateCommandOptions = {
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
@@ -389,6 +390,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
})) ?? process.cwd();
|
})) ?? process.cwd();
|
||||||
|
|
||||||
const configSnapshot = await readConfigFileSnapshot();
|
const configSnapshot = await readConfigFileSnapshot();
|
||||||
|
let activeConfig = configSnapshot.valid ? configSnapshot.config : null;
|
||||||
const storedChannel = configSnapshot.valid
|
const storedChannel = configSnapshot.valid
|
||||||
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
|
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
|
||||||
: null;
|
: null;
|
||||||
@@ -459,6 +461,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
|
activeConfig = next;
|
||||||
if (!opts.json) {
|
if (!opts.json) {
|
||||||
defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`));
|
defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`));
|
||||||
}
|
}
|
||||||
@@ -513,6 +516,87 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
return;
|
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
|
// Restart daemon if requested
|
||||||
if (opts.restart) {
|
if (opts.restart) {
|
||||||
if (!opts.json) {
|
if (!opts.json) {
|
||||||
|
|||||||
401
src/plugins/update.ts
Normal file
401
src/plugins/update.ts
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import type { UpdateChannel } from "../infra/update-channels.js";
|
||||||
|
import { resolveUserPath } from "../utils.js";
|
||||||
|
import { discoverClawdbotPlugins } from "./discovery.js";
|
||||||
|
import { installPluginFromNpmSpec, resolvePluginInstallDir } from "./install.js";
|
||||||
|
import { recordPluginInstall } from "./installs.js";
|
||||||
|
import { loadPluginManifest } from "./manifest.js";
|
||||||
|
|
||||||
|
export type PluginUpdateLogger = {
|
||||||
|
info?: (message: string) => void;
|
||||||
|
warn?: (message: string) => void;
|
||||||
|
error?: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginUpdateStatus = "updated" | "unchanged" | "skipped" | "error";
|
||||||
|
|
||||||
|
export type PluginUpdateOutcome = {
|
||||||
|
pluginId: string;
|
||||||
|
status: PluginUpdateStatus;
|
||||||
|
message: string;
|
||||||
|
currentVersion?: string;
|
||||||
|
nextVersion?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginUpdateSummary = {
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
changed: boolean;
|
||||||
|
outcomes: PluginUpdateOutcome[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginChannelSyncSummary = {
|
||||||
|
switchedToBundled: string[];
|
||||||
|
switchedToNpm: string[];
|
||||||
|
warnings: string[];
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginChannelSyncResult = {
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
changed: boolean;
|
||||||
|
summary: PluginChannelSyncSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BundledPluginSource = {
|
||||||
|
pluginId: string;
|
||||||
|
localPath: string;
|
||||||
|
npmSpec?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(`${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 resolveBundledPluginSources(params: {
|
||||||
|
workspaceDir?: string;
|
||||||
|
}): Map<string, BundledPluginSource> {
|
||||||
|
const discovery = discoverClawdbotPlugins({ workspaceDir: params.workspaceDir });
|
||||||
|
const bundled = new Map<string, BundledPluginSource>();
|
||||||
|
|
||||||
|
for (const candidate of discovery.candidates) {
|
||||||
|
if (candidate.origin !== "bundled") continue;
|
||||||
|
const manifest = loadPluginManifest(candidate.rootDir);
|
||||||
|
if (!manifest.ok) continue;
|
||||||
|
const pluginId = manifest.manifest.id;
|
||||||
|
if (bundled.has(pluginId)) continue;
|
||||||
|
|
||||||
|
const npmSpec =
|
||||||
|
candidate.packageClawdbot?.install?.npmSpec?.trim() ||
|
||||||
|
candidate.packageName?.trim() ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
bundled.set(pluginId, {
|
||||||
|
pluginId,
|
||||||
|
localPath: candidate.rootDir,
|
||||||
|
npmSpec,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathsEqual(left?: string, right?: string): boolean {
|
||||||
|
if (!left || !right) return false;
|
||||||
|
return resolveUserPath(left) === resolveUserPath(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLoadPathHelpers(existing: string[]) {
|
||||||
|
let paths = [...existing];
|
||||||
|
const resolveSet = () => new Set(paths.map((entry) => resolveUserPath(entry)));
|
||||||
|
let resolved = resolveSet();
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
const addPath = (value: string) => {
|
||||||
|
const normalized = resolveUserPath(value);
|
||||||
|
if (resolved.has(normalized)) return;
|
||||||
|
paths.push(value);
|
||||||
|
resolved.add(normalized);
|
||||||
|
changed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePath = (value: string) => {
|
||||||
|
const normalized = resolveUserPath(value);
|
||||||
|
if (!resolved.has(normalized)) return;
|
||||||
|
paths = paths.filter((entry) => resolveUserPath(entry) !== normalized);
|
||||||
|
resolved = resolveSet();
|
||||||
|
changed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
addPath,
|
||||||
|
removePath,
|
||||||
|
get changed() {
|
||||||
|
return changed;
|
||||||
|
},
|
||||||
|
get paths() {
|
||||||
|
return paths;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNpmInstalledPlugins(params: {
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
logger?: PluginUpdateLogger;
|
||||||
|
pluginIds?: string[];
|
||||||
|
skipIds?: Set<string>;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}): Promise<PluginUpdateSummary> {
|
||||||
|
const logger = params.logger ?? {};
|
||||||
|
const installs = params.config.plugins?.installs ?? {};
|
||||||
|
const targets = params.pluginIds?.length ? params.pluginIds : Object.keys(installs);
|
||||||
|
const outcomes: PluginUpdateOutcome[] = [];
|
||||||
|
let next = params.config;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const pluginId of targets) {
|
||||||
|
if (params.skipIds?.has(pluginId)) {
|
||||||
|
outcomes.push({
|
||||||
|
pluginId,
|
||||||
|
status: "skipped",
|
||||||
|
message: `Skipping "${pluginId}" (already updated).`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = installs[pluginId];
|
||||||
|
if (!record) {
|
||||||
|
outcomes.push({
|
||||||
|
pluginId,
|
||||||
|
status: "skipped",
|
||||||
|
message: `No install record for "${pluginId}".`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.source !== "npm") {
|
||||||
|
outcomes.push({
|
||||||
|
pluginId,
|
||||||
|
status: "skipped",
|
||||||
|
message: `Skipping "${pluginId}" (source: ${record.source}).`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record.spec) {
|
||||||
|
outcomes.push({
|
||||||
|
pluginId,
|
||||||
|
status: "skipped",
|
||||||
|
message: `Skipping "${pluginId}" (missing npm spec).`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installPath = record.installPath ?? resolvePluginInstallDir(pluginId);
|
||||||
|
const currentVersion = await readInstalledPackageVersion(installPath);
|
||||||
|
|
||||||
|
if (params.dryRun) {
|
||||||
|
let probe: Awaited<ReturnType<typeof installPluginFromNpmSpec>>;
|
||||||
|
try {
|
||||||
|
probe = await installPluginFromNpmSpec({
|
||||||
|
spec: record.spec,
|
||||||
|
mode: "update",
|
||||||
|
dryRun: true,
|
||||||
|
expectedPluginId: pluginId,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
outcomes.push({
|
||||||
|
pluginId,
|
||||||
|
status: "error",
|
||||||
|
message: `Failed to check ${pluginId}: ${String(err)}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!probe.ok) {
|
||||||
|
outcomes.push({
|
||||||
|
pluginId,
|
||||||
|
status: "error",
|
||||||
|
message: `Failed to check ${pluginId}: ${probe.error}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextVersion = probe.version ?? "unknown";
|
||||||
|
const currentLabel = currentVersion ?? "unknown";
|
||||||
|
if (currentVersion && probe.version && currentVersion === probe.version) {
|
||||||
|
outcomes.push({
|
||||||
|
pluginId,
|
||||||
|
status: "unchanged",
|
||||||
|
currentVersion: currentVersion ?? undefined,
|
||||||
|
nextVersion: probe.version ?? undefined,
|
||||||
|
message: `${pluginId} is up to date (${currentLabel}).`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
outcomes.push({
|
||||||
|
pluginId,
|
||||||
|
status: "updated",
|
||||||
|
currentVersion: currentVersion ?? undefined,
|
||||||
|
nextVersion: probe.version ?? undefined,
|
||||||
|
message: `Would update ${pluginId}: ${currentLabel} -> ${nextVersion}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof installPluginFromNpmSpec>>;
|
||||||
|
try {
|
||||||
|
result = await installPluginFromNpmSpec({
|
||||||
|
spec: record.spec,
|
||||||
|
mode: "update",
|
||||||
|
expectedPluginId: pluginId,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
outcomes.push({
|
||||||
|
pluginId,
|
||||||
|
status: "error",
|
||||||
|
message: `Failed to update ${pluginId}: ${String(err)}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
outcomes.push({
|
||||||
|
pluginId,
|
||||||
|
status: "error",
|
||||||
|
message: `Failed to update ${pluginId}: ${result.error}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
|
||||||
|
next = recordPluginInstall(next, {
|
||||||
|
pluginId,
|
||||||
|
source: "npm",
|
||||||
|
spec: record.spec,
|
||||||
|
installPath: result.targetDir,
|
||||||
|
version: nextVersion,
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
|
||||||
|
const currentLabel = currentVersion ?? "unknown";
|
||||||
|
const nextLabel = nextVersion ?? "unknown";
|
||||||
|
if (currentVersion && nextVersion && currentVersion === nextVersion) {
|
||||||
|
outcomes.push({
|
||||||
|
pluginId,
|
||||||
|
status: "unchanged",
|
||||||
|
currentVersion: currentVersion ?? undefined,
|
||||||
|
nextVersion: nextVersion ?? undefined,
|
||||||
|
message: `${pluginId} already at ${currentLabel}.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
outcomes.push({
|
||||||
|
pluginId,
|
||||||
|
status: "updated",
|
||||||
|
currentVersion: currentVersion ?? undefined,
|
||||||
|
nextVersion: nextVersion ?? undefined,
|
||||||
|
message: `Updated ${pluginId}: ${currentLabel} -> ${nextLabel}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: next, changed, outcomes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncPluginsForUpdateChannel(params: {
|
||||||
|
config: ClawdbotConfig;
|
||||||
|
channel: UpdateChannel;
|
||||||
|
workspaceDir?: string;
|
||||||
|
logger?: PluginUpdateLogger;
|
||||||
|
}): Promise<PluginChannelSyncResult> {
|
||||||
|
const summary: PluginChannelSyncSummary = {
|
||||||
|
switchedToBundled: [],
|
||||||
|
switchedToNpm: [],
|
||||||
|
warnings: [],
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir });
|
||||||
|
if (bundled.size === 0) {
|
||||||
|
return { config: params.config, changed: false, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = params.config;
|
||||||
|
const loadHelpers = buildLoadPathHelpers(next.plugins?.load?.paths ?? []);
|
||||||
|
const installs = next.plugins?.installs ?? {};
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (params.channel === "dev") {
|
||||||
|
for (const [pluginId, record] of Object.entries(installs)) {
|
||||||
|
const bundledInfo = bundled.get(pluginId);
|
||||||
|
if (!bundledInfo) continue;
|
||||||
|
|
||||||
|
loadHelpers.addPath(bundledInfo.localPath);
|
||||||
|
|
||||||
|
const alreadyBundled =
|
||||||
|
record.source === "path" && pathsEqual(record.sourcePath, bundledInfo.localPath);
|
||||||
|
if (alreadyBundled) continue;
|
||||||
|
|
||||||
|
next = recordPluginInstall(next, {
|
||||||
|
pluginId,
|
||||||
|
source: "path",
|
||||||
|
sourcePath: bundledInfo.localPath,
|
||||||
|
installPath: bundledInfo.localPath,
|
||||||
|
spec: record.spec ?? bundledInfo.npmSpec,
|
||||||
|
version: record.version,
|
||||||
|
});
|
||||||
|
summary.switchedToBundled.push(pluginId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const [pluginId, record] of Object.entries(installs)) {
|
||||||
|
const bundledInfo = bundled.get(pluginId);
|
||||||
|
if (!bundledInfo) continue;
|
||||||
|
|
||||||
|
if (record.source === "npm") {
|
||||||
|
loadHelpers.removePath(bundledInfo.localPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.source !== "path") continue;
|
||||||
|
if (!pathsEqual(record.sourcePath, bundledInfo.localPath)) continue;
|
||||||
|
|
||||||
|
const spec = record.spec ?? bundledInfo.npmSpec;
|
||||||
|
if (!spec) {
|
||||||
|
summary.warnings.push(`Missing npm spec for ${pluginId}; keeping local path.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<typeof installPluginFromNpmSpec>>;
|
||||||
|
try {
|
||||||
|
result = await installPluginFromNpmSpec({
|
||||||
|
spec,
|
||||||
|
mode: "update",
|
||||||
|
expectedPluginId: pluginId,
|
||||||
|
logger: params.logger,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
summary.errors.push(`Failed to install ${pluginId}: ${String(err)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
summary.errors.push(`Failed to install ${pluginId}: ${result.error}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
next = recordPluginInstall(next, {
|
||||||
|
pluginId,
|
||||||
|
source: "npm",
|
||||||
|
spec,
|
||||||
|
installPath: result.targetDir,
|
||||||
|
version: result.version,
|
||||||
|
sourcePath: undefined,
|
||||||
|
});
|
||||||
|
summary.switchedToNpm.push(pluginId);
|
||||||
|
changed = true;
|
||||||
|
loadHelpers.removePath(bundledInfo.localPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadHelpers.changed) {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
plugins: {
|
||||||
|
...next.plugins,
|
||||||
|
load: {
|
||||||
|
...next.plugins?.load,
|
||||||
|
paths: loadHelpers.paths,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: next, changed, summary };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user