mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-27 04:27:49 +00:00
fix(update): prune stale local bundled plugin shadows
Summary:\n- prune stale local bundled plugin path records during update/doctor repair\n- keep current, same-version, versionless, source-checkout, and arbitrary local path records preserved\n- add changelog and deterministic sort comparator cleanup\n\nVerification:\n- node scripts/run-vitest.mjs src/plugins/contracts/boundary-invariants.test.ts src/plugins/stale-local-bundled-plugin-install-records.test.ts src/cli/update-cli/post-core-plugin-convergence.test.ts src/commands/doctor-plugin-registry.test.ts\n- node scripts/run-oxlint-shards.mjs --threads=8\n- ./node_modules/.bin/oxfmt --check --threads=1 CHANGELOG.md src/plugins/stale-local-bundled-plugin-install-records.ts src/commands/doctor-plugin-registry.ts\n- git diff --check\n- GitHub exact-SHA: Real behavior proof, build-artifacts, checks-fast-contracts-plugins-a, check-prod-types, check-lint, check-test-types green on 8bcbf681ec
This commit is contained in:
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- fix(config): validate browser sandbox bind sources [AI]. (#84799) Thanks @pgondhi987.
|
||||
- doctor: constrain legacy plugin cleanup paths [AI]. (#84801) Thanks @pgondhi987.
|
||||
- Update/doctor: prune stale local bundled plugin install records that point at old compiled bundled output so current bundled plugin schemas win after upgrade. (#84863) Thanks @fuller-stack-dev.
|
||||
- Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.
|
||||
- Node/Linux: keep `OPENCLAW_GATEWAY_TOKEN` out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)
|
||||
- Memory-core/dreaming: reuse stable narrative subagent session keys per workspace and phase while keeping per-run idempotency and bounded cleanup, so stale `dreaming-narrative-*` sessions do not accumulate. Fixes #68252, #69187, and #70402. (#70464) Thanks @chiyouYCH.
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
repairMissingConfiguredPluginInstalls: vi.fn(),
|
||||
@@ -21,6 +24,8 @@ import {
|
||||
} from "./post-core-plugin-convergence.js";
|
||||
|
||||
describe("runPostCorePluginConvergence", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
@@ -31,6 +36,43 @@ describe("runPostCorePluginConvergence", () => {
|
||||
mocks.runPluginPayloadSmokeCheck.mockResolvedValue({ checked: [], failures: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-post-core-convergence-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeBundledPlugin(rootDir: string, pluginId: string): string {
|
||||
const pluginDir = path.join(rootDir, pluginId);
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginDir, "index.js"), "export default {};\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: pluginId,
|
||||
name: pluginId,
|
||||
version: "2026.5.20-beta.1",
|
||||
configSchema: { type: "object" },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: `@openclaw/${pluginId}`,
|
||||
version: "2026.5.20-beta.1",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return pluginDir;
|
||||
}
|
||||
|
||||
it("calls repair with OPENCLAW_UPDATE_POST_CORE_CONVERGENCE=1 set", async () => {
|
||||
const cfg = { plugins: { entries: {} } } as unknown as OpenClawConfig;
|
||||
await runPostCorePluginConvergence({
|
||||
@@ -121,6 +163,55 @@ describe("runPostCorePluginConvergence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prunes stale local bundled plugin shadows from baseline records before repair", async () => {
|
||||
const bundledRoot = makeTempDir();
|
||||
writeBundledPlugin(bundledRoot, "discord");
|
||||
const baseline = {
|
||||
discord: {
|
||||
source: "path" as const,
|
||||
installPath: path.join(makeTempDir(), "dist", "extensions", "discord"),
|
||||
version: "2026.5.4-beta.3",
|
||||
},
|
||||
brave: { source: "npm" as const, installPath: "/p/brave" },
|
||||
};
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
records: { brave: baseline.brave },
|
||||
});
|
||||
const cfg = {
|
||||
plugins: { entries: { discord: { enabled: true }, brave: { enabled: true } } },
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = await runPostCorePluginConvergence({
|
||||
cfg,
|
||||
env: {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
|
||||
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
|
||||
VITEST: "true",
|
||||
},
|
||||
baselineInstallRecords: baseline,
|
||||
});
|
||||
|
||||
expect(mocks.repairMissingConfiguredPluginInstalls).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
env: {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
|
||||
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
|
||||
VITEST: "true",
|
||||
OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION,
|
||||
OPENCLAW_UPDATE_POST_CORE_CONVERGENCE: "1",
|
||||
},
|
||||
baselineRecords: {
|
||||
brave: baseline.brave,
|
||||
},
|
||||
});
|
||||
expect(result.changes).toEqual([
|
||||
'Removed stale local bundled plugin install record "discord".',
|
||||
]);
|
||||
expect(result.installRecords).toEqual({ brave: baseline.brave });
|
||||
});
|
||||
|
||||
it("flags errored=true and surfaces actionable guidance when repair warns", async () => {
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: [],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UPDATE_POST_CORE_CONVERGENCE_ENV } from "../../commands/doctor/shared/u
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../../config/types.plugins.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
|
||||
import { pruneStaleLocalBundledPluginInstallRecords } from "../../plugins/stale-local-bundled-plugin-install-records.js";
|
||||
import {
|
||||
resolveTrustedSourceLinkedOfficialClawHubSpec,
|
||||
resolveTrustedSourceLinkedOfficialNpmSpec,
|
||||
@@ -66,11 +67,17 @@ export async function runPostCorePluginConvergence(params: {
|
||||
OPENCLAW_COMPATIBILITY_HOST_VERSION: VERSION,
|
||||
[UPDATE_POST_CORE_CONVERGENCE_ENV]: "1",
|
||||
};
|
||||
const prunedBaseline = params.baselineInstallRecords
|
||||
? pruneStaleLocalBundledPluginInstallRecords({
|
||||
installRecords: params.baselineInstallRecords,
|
||||
env,
|
||||
})
|
||||
: null;
|
||||
|
||||
const repair = await repairMissingConfiguredPluginInstalls({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
...(params.baselineInstallRecords ? { baselineRecords: params.baselineInstallRecords } : {}),
|
||||
...(prunedBaseline ? { baselineRecords: prunedBaseline.records } : {}),
|
||||
});
|
||||
|
||||
const warnings: PostCoreConvergenceWarning[] = repair.warnings.map((message) => ({
|
||||
@@ -99,7 +106,12 @@ export async function runPostCorePluginConvergence(params: {
|
||||
}
|
||||
|
||||
return {
|
||||
changes: repair.changes,
|
||||
changes: [
|
||||
...(prunedBaseline?.stale.map(
|
||||
(record) => `Removed stale local bundled plugin install record "${record.pluginId}".`,
|
||||
) ?? []),
|
||||
...repair.changes,
|
||||
],
|
||||
warnings,
|
||||
errored: warnings.length > 0,
|
||||
smokeFailures: smoke.failures,
|
||||
|
||||
@@ -222,6 +222,23 @@ function createCurrentIndexWithNpmRecord(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function createCurrentIndexWithPathRecord(params: {
|
||||
pluginId: string;
|
||||
installPath: string;
|
||||
version?: string;
|
||||
}): InstalledPluginIndex {
|
||||
return {
|
||||
...createCurrentIndex(),
|
||||
installRecords: {
|
||||
[params.pluginId]: {
|
||||
source: "path",
|
||||
installPath: params.installPath,
|
||||
...(params.version ? { version: params.version } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function expectedPluginIndexRecord(params: {
|
||||
rootDir: string;
|
||||
pluginId: string;
|
||||
@@ -462,6 +479,113 @@ describe("maybeRepairPluginRegistryState", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("warns about stale local bundled plugin install records that shadow bundled plugins", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const bundledDir = path.join(stateDir, "current", "dist", "extensions", "discord");
|
||||
const staleDir = path.join(stateDir, "old-checkout", "dist", "extensions", "discord");
|
||||
fs.mkdirSync(bundledDir, { recursive: true });
|
||||
fs.mkdirSync(staleDir, { recursive: true });
|
||||
createCandidate(staleDir, "discord");
|
||||
await writePersistedInstalledPluginIndex(
|
||||
createCurrentIndexWithPathRecord({
|
||||
pluginId: "discord",
|
||||
installPath: staleDir,
|
||||
version: "2026.5.4-beta.3",
|
||||
}),
|
||||
{ stateDir },
|
||||
);
|
||||
|
||||
await maybeRepairPluginRegistryState({
|
||||
stateDir,
|
||||
candidates: [
|
||||
createBundledCandidate({
|
||||
rootDir: bundledDir,
|
||||
id: "discord",
|
||||
packageName: "@openclaw/discord",
|
||||
version: "2026.5.20-beta.1",
|
||||
}),
|
||||
],
|
||||
env: hermeticEnv(),
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["discord"],
|
||||
entries: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
prompter: { shouldRepair: false },
|
||||
});
|
||||
|
||||
const notes = vi.mocked(note).mock.calls.join("\n");
|
||||
expect(notes).toContain("Local bundled plugin install records shadow bundled plugins");
|
||||
expect(notes).toContain("discord");
|
||||
expect(notes).toContain(staleDir);
|
||||
const persisted = await readRequiredPersistedInstalledPluginIndex(stateDir);
|
||||
expect(persisted.installRecords).toHaveProperty("discord");
|
||||
});
|
||||
|
||||
it("removes stale local bundled plugin install records during repair", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const bundledDir = path.join(stateDir, "current", "dist", "extensions", "discord");
|
||||
const staleDir = path.join(stateDir, "old-checkout", "dist", "extensions", "discord");
|
||||
fs.mkdirSync(bundledDir, { recursive: true });
|
||||
fs.mkdirSync(staleDir, { recursive: true });
|
||||
createCandidate(staleDir, "discord");
|
||||
await writePersistedInstalledPluginIndex(
|
||||
createCurrentIndexWithPathRecord({
|
||||
pluginId: "discord",
|
||||
installPath: staleDir,
|
||||
version: "2026.5.4-beta.3",
|
||||
}),
|
||||
{ stateDir },
|
||||
);
|
||||
|
||||
await maybeRepairPluginRegistryState({
|
||||
stateDir,
|
||||
candidates: [
|
||||
createBundledCandidate({
|
||||
rootDir: bundledDir,
|
||||
id: "discord",
|
||||
packageName: "@openclaw/discord",
|
||||
version: "2026.5.20-beta.1",
|
||||
}),
|
||||
],
|
||||
env: hermeticEnv(),
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["discord"],
|
||||
entries: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
prompter: { shouldRepair: true },
|
||||
});
|
||||
|
||||
const persisted = await readRequiredPersistedInstalledPluginIndex(stateDir);
|
||||
expect(persisted.installRecords).toStrictEqual({});
|
||||
expect(persisted.refreshReason).toBe("migration");
|
||||
expect(persisted.plugins).toStrictEqual([
|
||||
expectedPluginIndexRecord({
|
||||
pluginId: "discord",
|
||||
rootDir: bundledDir,
|
||||
origin: "bundled",
|
||||
packageName: "@openclaw/discord",
|
||||
packageVersion: "2026.5.20-beta.1",
|
||||
}),
|
||||
]);
|
||||
expect(vi.mocked(note).mock.calls.join("\n")).toContain(
|
||||
"Removed stale local bundled plugin install record",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes stale managed npm packages from the package lock during repair", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const bundledDir = path.join(stateDir, "bundled", "google-meet");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { saveJsonFile } from "../infra/json-file.js";
|
||||
import { tryReadJsonSync } from "../infra/json-files.js";
|
||||
import type { BundledPluginSource } from "../plugins/bundled-sources.js";
|
||||
import { resolveDefaultPluginNpmDir } from "../plugins/install-paths.js";
|
||||
import {
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
@@ -15,6 +16,10 @@ import {
|
||||
relinkOpenClawPeerDependenciesInManagedNpmRoot,
|
||||
} from "../plugins/plugin-peer-link.js";
|
||||
import { refreshPluginRegistry } from "../plugins/plugin-registry.js";
|
||||
import {
|
||||
listStaleLocalBundledPluginInstallRecords,
|
||||
type StaleLocalBundledPluginInstallRecord,
|
||||
} from "../plugins/stale-local-bundled-plugin-install-records.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
@@ -107,7 +112,9 @@ function listStaleManagedNpmBundledPlugins(
|
||||
const dependencies = readStringMap(readJsonObject(npmPackageJsonPath)?.dependencies);
|
||||
const stale: StaleManagedNpmBundledPlugin[] = [];
|
||||
|
||||
for (const packageName of Object.keys(dependencies).toSorted()) {
|
||||
for (const packageName of Object.keys(dependencies).toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
if (!packageName.startsWith("@openclaw/")) {
|
||||
continue;
|
||||
}
|
||||
@@ -132,6 +139,40 @@ function listStaleManagedNpmBundledPlugins(
|
||||
return stale;
|
||||
}
|
||||
|
||||
function loadCurrentBundledPluginSources(
|
||||
params: PluginRegistryDoctorRepairParams,
|
||||
): Map<string, BundledPluginSource> {
|
||||
const currentBundled = loadInstalledPluginIndex({
|
||||
...params,
|
||||
installRecords: {},
|
||||
}).plugins.filter((plugin) => plugin.origin === "bundled");
|
||||
return new Map(
|
||||
currentBundled.map(
|
||||
(plugin) =>
|
||||
[
|
||||
plugin.pluginId,
|
||||
{
|
||||
pluginId: plugin.pluginId,
|
||||
localPath: plugin.rootDir,
|
||||
...(plugin.packageName ? { npmSpec: plugin.packageName } : {}),
|
||||
...(plugin.packageVersion ? { version: plugin.packageVersion } : {}),
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function listStaleLocalBundledPluginInstallRecordShadows(
|
||||
params: PluginRegistryDoctorRepairParams,
|
||||
): Promise<StaleLocalBundledPluginInstallRecord[]> {
|
||||
return listStaleLocalBundledPluginInstallRecords({
|
||||
installRecords: await loadInstalledPluginIndexInstallRecords(params),
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
bundled: loadCurrentBundledPluginSources(params),
|
||||
});
|
||||
}
|
||||
|
||||
function removeManagedNpmDependency(params: {
|
||||
npmRoot: string;
|
||||
packageName: string;
|
||||
@@ -241,6 +282,36 @@ export function maybeRepairStaleManagedNpmBundledPlugins(
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function maybeRepairStaleLocalBundledPluginInstallRecords(
|
||||
params: PluginRegistryDoctorRepairParams,
|
||||
): Promise<string[]> {
|
||||
const stale = await listStaleLocalBundledPluginInstallRecordShadows(params);
|
||||
if (stale.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!params.prompter.shouldRepair) {
|
||||
note(
|
||||
[
|
||||
"Local bundled plugin install records shadow bundled plugins:",
|
||||
...stale.map((record) => `- ${record.pluginId}: ${shortenHomePath(record.stalePath)}`),
|
||||
`Repair with ${formatCliCommand("openclaw doctor --fix")} to remove stale local install records and rebuild the plugin registry.`,
|
||||
].join("\n"),
|
||||
"Plugin registry",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
note(
|
||||
[
|
||||
"Removed stale local bundled plugin install record(s) shadowing bundled plugins:",
|
||||
...stale.map((record) => `- ${record.pluginId}: ${shortenHomePath(record.stalePath)}`),
|
||||
].join("\n"),
|
||||
"Plugin registry",
|
||||
);
|
||||
return stale.map((record) => record.pluginId);
|
||||
}
|
||||
|
||||
export async function maybeRepairManagedNpmOpenClawPeerLinks(
|
||||
params: PluginRegistryDoctorRepairParams,
|
||||
): Promise<boolean> {
|
||||
@@ -323,7 +394,15 @@ export async function maybeRepairPluginRegistryState(
|
||||
(plugin) => plugin.pluginId,
|
||||
);
|
||||
const removedStaleManagedNpmBundledPlugins = maybeRepairStaleManagedNpmBundledPlugins(params);
|
||||
const removedStaleLocalBundledPluginIds =
|
||||
await maybeRepairStaleLocalBundledPluginInstallRecords(params);
|
||||
const repairedManagedNpmOpenClawPeerLinks = await maybeRepairManagedNpmOpenClawPeerLinks(params);
|
||||
const stalePluginIdsToRemove = [
|
||||
...new Set([
|
||||
...(removedStaleManagedNpmBundledPlugins ? staleManagedNpmBundledPluginIds : []),
|
||||
...removedStaleLocalBundledPluginIds,
|
||||
]),
|
||||
];
|
||||
if (!params.prompter.shouldRepair) {
|
||||
if (preflight.action === "migrate") {
|
||||
note(
|
||||
@@ -338,7 +417,17 @@ export async function maybeRepairPluginRegistryState(
|
||||
}
|
||||
|
||||
if (preflight.action === "migrate") {
|
||||
const result = await migratePluginRegistryForInstall(migrationParams);
|
||||
const result = await migratePluginRegistryForInstall({
|
||||
...migrationParams,
|
||||
...(stalePluginIdsToRemove.length > 0
|
||||
? {
|
||||
installRecords: await loadInstallRecordsWithoutPluginIds(
|
||||
params,
|
||||
stalePluginIdsToRemove,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (result.migrated) {
|
||||
const total = result.current.plugins.length;
|
||||
const enabled = result.current.plugins.filter((plugin) => plugin.enabled).length;
|
||||
@@ -353,16 +442,17 @@ export async function maybeRepairPluginRegistryState(
|
||||
if (
|
||||
preflight.action === "skip-existing" ||
|
||||
removedStaleManagedNpmBundledPlugins ||
|
||||
removedStaleLocalBundledPluginIds.length > 0 ||
|
||||
repairedManagedNpmOpenClawPeerLinks
|
||||
) {
|
||||
const index = await refreshPluginRegistry({
|
||||
...migrationParams,
|
||||
reason: "migration",
|
||||
...(removedStaleManagedNpmBundledPlugins
|
||||
...(stalePluginIdsToRemove.length > 0
|
||||
? {
|
||||
installRecords: await loadInstallRecordsWithoutPluginIds(
|
||||
params,
|
||||
staleManagedNpmBundledPluginIds,
|
||||
stalePluginIdsToRemove,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -293,9 +293,11 @@ export async function migratePluginRegistryForInstall(
|
||||
|
||||
const rawConfig = await readMigrationConfig(params);
|
||||
const config = stripShippedPluginInstallConfigRecords(rawConfig) as OpenClawConfig;
|
||||
const durableInstallRecords =
|
||||
params.installRecords ?? (await loadInstalledPluginIndexInstallRecords(params));
|
||||
const installRecords = {
|
||||
...extractShippedPluginInstallConfigRecords(rawConfig),
|
||||
...(await loadInstalledPluginIndexInstallRecords(params)),
|
||||
...durableInstallRecords,
|
||||
};
|
||||
const migrationParams = {
|
||||
...params,
|
||||
|
||||
158
src/plugins/stale-local-bundled-plugin-install-records.test.ts
Normal file
158
src/plugins/stale-local-bundled-plugin-install-records.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import type { BundledPluginSource } from "./bundled-sources.js";
|
||||
import {
|
||||
listStaleLocalBundledPluginInstallRecords,
|
||||
pruneStaleLocalBundledPluginInstallRecords,
|
||||
} from "./stale-local-bundled-plugin-install-records.js";
|
||||
|
||||
function bundledSource(pluginId: string, localPath: string): Map<string, BundledPluginSource> {
|
||||
return new Map([
|
||||
[
|
||||
pluginId,
|
||||
{
|
||||
pluginId,
|
||||
localPath,
|
||||
version: "2026.5.20",
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
describe("listStaleLocalBundledPluginInstallRecords", () => {
|
||||
it("lists path install records that point at stale compiled bundled output", () => {
|
||||
const currentPath = path.join("/opt/openclaw", "dist", "extensions", "discord");
|
||||
const stalePath = path.join("/tmp/old-openclaw", "dist", "extensions", "discord");
|
||||
const records: Record<string, PluginInstallRecord> = {
|
||||
discord: {
|
||||
source: "path",
|
||||
installPath: stalePath,
|
||||
version: "2026.5.4-beta.3",
|
||||
},
|
||||
brave: {
|
||||
source: "npm",
|
||||
installPath: "/tmp/plugins/brave",
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
listStaleLocalBundledPluginInstallRecords({
|
||||
installRecords: records,
|
||||
bundled: bundledSource("discord", currentPath),
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{
|
||||
pluginId: "discord",
|
||||
record: records.discord,
|
||||
recordPathField: "installPath",
|
||||
stalePath,
|
||||
bundledPath: currentPath,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not list the current bundled path", () => {
|
||||
const currentPath = path.join("/opt/openclaw", "dist", "extensions", "discord");
|
||||
|
||||
expect(
|
||||
listStaleLocalBundledPluginInstallRecords({
|
||||
installRecords: {
|
||||
discord: {
|
||||
source: "path",
|
||||
installPath: currentPath,
|
||||
version: "2026.5.4-beta.3",
|
||||
},
|
||||
},
|
||||
bundled: bundledSource("discord", currentPath),
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not list compiled bundled paths without a stale version", () => {
|
||||
const currentPath = path.join("/opt/openclaw", "dist", "extensions", "discord");
|
||||
|
||||
expect(
|
||||
listStaleLocalBundledPluginInstallRecords({
|
||||
installRecords: {
|
||||
discord: {
|
||||
source: "path",
|
||||
installPath: path.join("/tmp/local-openclaw", "dist", "extensions", "discord"),
|
||||
},
|
||||
acpx: {
|
||||
source: "path",
|
||||
installPath: path.join("/tmp/local-openclaw", "dist", "extensions", "acpx"),
|
||||
version: "2026.5.20",
|
||||
},
|
||||
},
|
||||
bundled: new Map([
|
||||
...bundledSource("discord", currentPath),
|
||||
...bundledSource("acpx", path.join("/opt/openclaw", "dist", "extensions", "acpx")),
|
||||
]),
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("does not list source checkout or arbitrary local plugin paths", () => {
|
||||
const currentPath = path.join("/opt/openclaw", "dist", "extensions", "discord");
|
||||
|
||||
expect(
|
||||
listStaleLocalBundledPluginInstallRecords({
|
||||
installRecords: {
|
||||
discord: {
|
||||
source: "path",
|
||||
installPath: path.join("/tmp/openclaw", "extensions", "discord"),
|
||||
version: "2026.5.4-beta.3",
|
||||
},
|
||||
acpx: {
|
||||
source: "path",
|
||||
installPath: path.join("/tmp/custom-plugins", "acpx"),
|
||||
version: "2026.5.4-beta.3",
|
||||
},
|
||||
},
|
||||
bundled: new Map([
|
||||
...bundledSource("discord", currentPath),
|
||||
...bundledSource("acpx", path.join("/opt/openclaw", "dist", "extensions", "acpx")),
|
||||
]),
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneStaleLocalBundledPluginInstallRecords", () => {
|
||||
it("removes only stale local bundled plugin install records", () => {
|
||||
const currentPath = path.join("/opt/openclaw", "dist", "extensions", "discord");
|
||||
const stalePath = path.join("/tmp/old-openclaw", "dist", "extensions", "discord");
|
||||
const records: Record<string, PluginInstallRecord> = {
|
||||
discord: {
|
||||
source: "path",
|
||||
installPath: stalePath,
|
||||
version: "2026.5.4-beta.3",
|
||||
},
|
||||
brave: {
|
||||
source: "npm",
|
||||
installPath: "/tmp/plugins/brave",
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
pruneStaleLocalBundledPluginInstallRecords({
|
||||
installRecords: records,
|
||||
bundled: bundledSource("discord", currentPath),
|
||||
}),
|
||||
).toStrictEqual({
|
||||
records: {
|
||||
brave: records.brave,
|
||||
},
|
||||
stale: [
|
||||
{
|
||||
pluginId: "discord",
|
||||
record: records.discord,
|
||||
recordPathField: "installPath",
|
||||
stalePath,
|
||||
bundledPath: currentPath,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
123
src/plugins/stale-local-bundled-plugin-install-records.ts
Normal file
123
src/plugins/stale-local-bundled-plugin-install-records.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import path from "node:path";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { normalizeBundledLookupPath } from "./bundled-load-path-aliases.js";
|
||||
import { resolveBundledPluginSources, type BundledPluginSource } from "./bundled-sources.js";
|
||||
|
||||
export type StaleLocalBundledPluginInstallRecord = {
|
||||
pluginId: string;
|
||||
record: PluginInstallRecord;
|
||||
recordPathField: "installPath" | "sourcePath";
|
||||
stalePath: string;
|
||||
bundledPath: string;
|
||||
};
|
||||
|
||||
function normalizePathForCompare(rawPath: string, env?: NodeJS.ProcessEnv): string {
|
||||
return path.resolve(normalizeBundledLookupPath(resolveUserPath(rawPath, env)));
|
||||
}
|
||||
|
||||
function primaryInstallRecordPath(record: PluginInstallRecord): {
|
||||
field: "installPath" | "sourcePath";
|
||||
path: string;
|
||||
} | null {
|
||||
if (typeof record.installPath === "string" && record.installPath.trim()) {
|
||||
return { field: "installPath", path: record.installPath };
|
||||
}
|
||||
if (typeof record.sourcePath === "string" && record.sourcePath.trim()) {
|
||||
return { field: "sourcePath", path: record.sourcePath };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function looksLikeCompiledBundledPluginPath(targetPath: string, pluginId: string): boolean {
|
||||
const segments = normalizeBundledLookupPath(targetPath).split(/[\\/]+/u);
|
||||
return segments.some((segment, index) => {
|
||||
return (
|
||||
(segment === "dist" || segment === "dist-runtime") &&
|
||||
segments[index + 1] === "extensions" &&
|
||||
segments[index + 2] === pluginId
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function hasStaleBundledVersion(
|
||||
record: PluginInstallRecord,
|
||||
bundledSource: BundledPluginSource,
|
||||
): boolean {
|
||||
const recordVersion = record.version?.trim();
|
||||
const bundledVersion = bundledSource.version?.trim();
|
||||
return Boolean(recordVersion && bundledVersion && recordVersion !== bundledVersion);
|
||||
}
|
||||
|
||||
export function listStaleLocalBundledPluginInstallRecords(params: {
|
||||
installRecords: Record<string, PluginInstallRecord>;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
bundled?: ReadonlyMap<string, BundledPluginSource>;
|
||||
}): StaleLocalBundledPluginInstallRecord[] {
|
||||
const bundled =
|
||||
params.bundled ??
|
||||
resolveBundledPluginSources({
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
const stale: StaleLocalBundledPluginInstallRecord[] = [];
|
||||
|
||||
for (const [pluginId, record] of Object.entries(params.installRecords).toSorted(
|
||||
([left], [right]) => left.localeCompare(right),
|
||||
)) {
|
||||
if (record.source !== "path") {
|
||||
continue;
|
||||
}
|
||||
const bundledSource = bundled.get(pluginId);
|
||||
if (!bundledSource?.localPath) {
|
||||
continue;
|
||||
}
|
||||
if (!hasStaleBundledVersion(record, bundledSource)) {
|
||||
continue;
|
||||
}
|
||||
const recordPath = primaryInstallRecordPath(record);
|
||||
if (!recordPath) {
|
||||
continue;
|
||||
}
|
||||
const stalePath = normalizePathForCompare(recordPath.path, params.env);
|
||||
const bundledPath = normalizePathForCompare(bundledSource.localPath, params.env);
|
||||
if (stalePath === bundledPath) {
|
||||
continue;
|
||||
}
|
||||
if (!looksLikeCompiledBundledPluginPath(stalePath, pluginId)) {
|
||||
continue;
|
||||
}
|
||||
stale.push({
|
||||
pluginId,
|
||||
record,
|
||||
recordPathField: recordPath.field,
|
||||
stalePath,
|
||||
bundledPath,
|
||||
});
|
||||
}
|
||||
|
||||
return stale;
|
||||
}
|
||||
|
||||
export function pruneStaleLocalBundledPluginInstallRecords(params: {
|
||||
installRecords: Record<string, PluginInstallRecord>;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
bundled?: ReadonlyMap<string, BundledPluginSource>;
|
||||
}): {
|
||||
records: Record<string, PluginInstallRecord>;
|
||||
stale: StaleLocalBundledPluginInstallRecord[];
|
||||
} {
|
||||
const stale = listStaleLocalBundledPluginInstallRecords(params);
|
||||
if (stale.length === 0) {
|
||||
return { records: params.installRecords, stale };
|
||||
}
|
||||
const staleIds = new Set(stale.map((record) => record.pluginId));
|
||||
return {
|
||||
records: Object.fromEntries(
|
||||
Object.entries(params.installRecords).filter(([pluginId]) => !staleIds.has(pluginId)),
|
||||
),
|
||||
stale,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user