feat: sync plugin updates with update channel
This commit is contained in:
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