mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
refactor: move manifest legacy migration into doctor
This commit is contained in:
@@ -215,8 +215,9 @@ Each list is optional:
|
||||
| `tools` | `string[]` | Agent tool names this plugin owns for bundled contract checks. |
|
||||
|
||||
Legacy top-level `speechProviders`, `mediaUnderstandingProviders`, and
|
||||
`imageGenerationProviders` still parse for compatibility, but new manifests
|
||||
should declare those ids under `contracts` only.
|
||||
`imageGenerationProviders` are deprecated. Use `openclaw doctor --fix` to move
|
||||
them under `contracts`; normal manifest loading no longer treats them as
|
||||
capability ownership.
|
||||
|
||||
## Manifest versus package.json
|
||||
|
||||
|
||||
@@ -70,22 +70,6 @@ function normalizeManifestContracts(raw) {
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeLegacyCapabilityContracts(raw) {
|
||||
return normalizeManifestContracts({
|
||||
speechProviders: raw?.speechProviders,
|
||||
mediaUnderstandingProviders: raw?.mediaUnderstandingProviders,
|
||||
imageGenerationProviders: raw?.imageGenerationProviders,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeManifestContracts(fallback, primary) {
|
||||
const merged = {
|
||||
...fallback,
|
||||
...primary,
|
||||
};
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
function normalizeObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
@@ -127,11 +111,6 @@ function normalizePluginManifest(raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contracts = mergeManifestContracts(
|
||||
normalizeLegacyCapabilityContracts(raw),
|
||||
normalizeManifestContracts(raw.contracts),
|
||||
);
|
||||
|
||||
return {
|
||||
id: raw.id.trim(),
|
||||
configSchema: raw.configSchema,
|
||||
@@ -155,7 +134,9 @@ function normalizePluginManifest(raw) {
|
||||
...(typeof raw.description === "string" ? { description: raw.description.trim() } : {}),
|
||||
...(typeof raw.version === "string" ? { version: raw.version.trim() } : {}),
|
||||
...(normalizeObject(raw.uiHints) ? { uiHints: raw.uiHints } : {}),
|
||||
...(contracts ? { contracts } : {}),
|
||||
...(normalizeManifestContracts(raw.contracts)
|
||||
? { contracts: normalizeManifestContracts(raw.contracts) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
141
src/commands/doctor-plugin-manifests.test.ts
Normal file
141
src/commands/doctor-plugin-manifests.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-helpers/fs-fixtures.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import {
|
||||
collectLegacyPluginManifestContractMigrations,
|
||||
maybeRepairLegacyPluginManifestContracts,
|
||||
} from "./doctor-plugin-manifests.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir() {
|
||||
return makeTrackedTempDir("openclaw-doctor-plugin-manifests", tempDirs);
|
||||
}
|
||||
|
||||
function writeManifest(dir: string, manifest: Record<string, unknown>) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
function writePackageJson(dir: string) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: "@openclaw/test-plugin",
|
||||
version: "1.0.0",
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(path.join(dir, "index.ts"), "export default {};\n", "utf-8");
|
||||
}
|
||||
|
||||
function createRuntime(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as RuntimeEnv;
|
||||
}
|
||||
|
||||
function createPrompter(overrides: Partial<DoctorPrompter> = {}): DoctorPrompter {
|
||||
return {
|
||||
confirm: vi.fn(),
|
||||
confirmAutoFix: vi.fn().mockResolvedValue(true),
|
||||
confirmAggressiveAutoFix: vi.fn(),
|
||||
confirmRuntimeRepair: vi.fn(),
|
||||
select: vi.fn(),
|
||||
shouldRepair: false,
|
||||
shouldForce: false,
|
||||
repairMode: {
|
||||
shouldRepair: false,
|
||||
shouldForce: false,
|
||||
nonInteractive: false,
|
||||
canPrompt: true,
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as DoctorPrompter;
|
||||
}
|
||||
|
||||
describe("doctor plugin manifest legacy contract repair", () => {
|
||||
afterEach(() => {
|
||||
cleanupTrackedTempDirs(tempDirs);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("collects legacy top-level capability keys for migration", () => {
|
||||
const pluginsRoot = makeTempDir();
|
||||
const root = path.join(pluginsRoot, "openai");
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
writePackageJson(root);
|
||||
writeManifest(root, {
|
||||
id: "openai",
|
||||
providers: ["openai"],
|
||||
speechProviders: ["openai"],
|
||||
configSchema: { type: "object" },
|
||||
});
|
||||
|
||||
const migrations = collectLegacyPluginManifestContractMigrations({
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: pluginsRoot,
|
||||
},
|
||||
});
|
||||
|
||||
expect(migrations).toHaveLength(1);
|
||||
expect(migrations[0]?.changeLines).toEqual([
|
||||
expect.stringContaining("moved speechProviders to contracts.speechProviders"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rewrites legacy top-level capability keys into contracts", async () => {
|
||||
const pluginsRoot = makeTempDir();
|
||||
const root = path.join(pluginsRoot, "openai");
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
writePackageJson(root);
|
||||
writeManifest(root, {
|
||||
id: "openai",
|
||||
providers: ["openai"],
|
||||
speechProviders: ["openai"],
|
||||
mediaUnderstandingProviders: ["openai"],
|
||||
contracts: {
|
||||
webSearchProviders: ["gemini"],
|
||||
},
|
||||
configSchema: { type: "object" },
|
||||
});
|
||||
|
||||
await maybeRepairLegacyPluginManifestContracts({
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: pluginsRoot,
|
||||
},
|
||||
runtime: createRuntime(),
|
||||
prompter: createPrompter(),
|
||||
});
|
||||
|
||||
const next = JSON.parse(fs.readFileSync(path.join(root, "openclaw.plugin.json"), "utf-8")) as {
|
||||
speechProviders?: string[];
|
||||
mediaUnderstandingProviders?: string[];
|
||||
contracts?: Record<string, string[]>;
|
||||
};
|
||||
expect(next.speechProviders).toBeUndefined();
|
||||
expect(next.mediaUnderstandingProviders).toBeUndefined();
|
||||
expect(next.contracts).toEqual({
|
||||
speechProviders: ["openai"],
|
||||
mediaUnderstandingProviders: ["openai"],
|
||||
webSearchProviders: ["gemini"],
|
||||
});
|
||||
});
|
||||
});
|
||||
166
src/commands/doctor-plugin-manifests.ts
Normal file
166
src/commands/doctor-plugin-manifests.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import fs from "node:fs";
|
||||
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
const LEGACY_MANIFEST_CONTRACT_KEYS = [
|
||||
"speechProviders",
|
||||
"mediaUnderstandingProviders",
|
||||
"imageGenerationProviders",
|
||||
] as const;
|
||||
|
||||
type LegacyManifestContractMigration = {
|
||||
manifestPath: string;
|
||||
pluginId: string;
|
||||
nextRaw: Record<string, unknown>;
|
||||
changeLines: string[];
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
|
||||
}
|
||||
|
||||
function readManifestJson(manifestPath: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as unknown;
|
||||
return isRecord(raw) ? raw : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildLegacyManifestContractMigration(params: {
|
||||
manifestPath: string;
|
||||
raw: Record<string, unknown>;
|
||||
}): LegacyManifestContractMigration | null {
|
||||
const nextRaw = { ...params.raw };
|
||||
const nextContracts = isRecord(params.raw.contracts) ? { ...params.raw.contracts } : {};
|
||||
const changeLines: string[] = [];
|
||||
|
||||
for (const key of LEGACY_MANIFEST_CONTRACT_KEYS) {
|
||||
if (!(key in params.raw)) {
|
||||
continue;
|
||||
}
|
||||
const legacyValues = normalizeStringList(params.raw[key]);
|
||||
const contractValues = normalizeStringList(nextContracts[key]);
|
||||
if (legacyValues.length > 0 && contractValues.length === 0) {
|
||||
nextContracts[key] = legacyValues;
|
||||
changeLines.push(
|
||||
`- ${shortenHomePath(params.manifestPath)}: moved ${key} to contracts.${key}`,
|
||||
);
|
||||
} else {
|
||||
changeLines.push(
|
||||
`- ${shortenHomePath(params.manifestPath)}: removed legacy ${key} (kept contracts.${key})`,
|
||||
);
|
||||
}
|
||||
delete nextRaw[key];
|
||||
}
|
||||
|
||||
if (changeLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Object.keys(nextContracts).length > 0) {
|
||||
nextRaw.contracts = nextContracts;
|
||||
} else {
|
||||
delete nextRaw.contracts;
|
||||
}
|
||||
|
||||
const pluginId = typeof params.raw.id === "string" ? params.raw.id.trim() : params.manifestPath;
|
||||
return {
|
||||
manifestPath: params.manifestPath,
|
||||
pluginId,
|
||||
nextRaw,
|
||||
changeLines,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectLegacyPluginManifestContractMigrations(params?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): LegacyManifestContractMigration[] {
|
||||
const seen = new Set<string>();
|
||||
const migrations: LegacyManifestContractMigration[] = [];
|
||||
|
||||
for (const plugin of loadPluginManifestRegistry({
|
||||
cache: false,
|
||||
...(params?.env ? { env: params.env } : {}),
|
||||
}).plugins) {
|
||||
if (seen.has(plugin.manifestPath)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(plugin.manifestPath);
|
||||
const raw = readManifestJson(plugin.manifestPath);
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
const migration = buildLegacyManifestContractMigration({
|
||||
manifestPath: plugin.manifestPath,
|
||||
raw,
|
||||
});
|
||||
if (migration) {
|
||||
migrations.push(migration);
|
||||
}
|
||||
}
|
||||
|
||||
return migrations.toSorted((left, right) => left.manifestPath.localeCompare(right.manifestPath));
|
||||
}
|
||||
|
||||
export async function maybeRepairLegacyPluginManifestContracts(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
runtime: RuntimeEnv;
|
||||
prompter: DoctorPrompter;
|
||||
}): Promise<void> {
|
||||
const migrations = collectLegacyPluginManifestContractMigrations(
|
||||
params.env ? { env: params.env } : undefined,
|
||||
);
|
||||
if (migrations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
note(
|
||||
[
|
||||
"Legacy plugin manifest capability keys detected.",
|
||||
...migrations.flatMap((migration) => migration.changeLines),
|
||||
].join("\n"),
|
||||
"Plugin manifests",
|
||||
);
|
||||
|
||||
const shouldRepair =
|
||||
params.prompter.shouldRepair ||
|
||||
(await params.prompter.confirmAutoFix({
|
||||
message: "Rewrite legacy plugin manifest capability keys into contracts now?",
|
||||
initialValue: true,
|
||||
}));
|
||||
if (!shouldRepair) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applied: string[] = [];
|
||||
for (const migration of migrations) {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
migration.manifestPath,
|
||||
`${JSON.stringify(migration.nextRaw, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
applied.push(...migration.changeLines);
|
||||
} catch (error) {
|
||||
params.runtime.error(
|
||||
`Failed to rewrite legacy plugin manifest at ${migration.manifestPath}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (applied.length > 0) {
|
||||
note(applied.join("\n"), "Doctor changes");
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
noteMacLaunchAgentOverrides,
|
||||
noteMacLaunchctlGatewayEnvOverrides,
|
||||
} from "../commands/doctor-platform-notes.js";
|
||||
import { maybeRepairLegacyPluginManifestContracts } from "../commands/doctor-plugin-manifests.js";
|
||||
import type { DoctorOptions, DoctorPrompter } from "../commands/doctor-prompter.js";
|
||||
import { maybeRepairSandboxImages, noteSandboxScopeWarnings } from "../commands/doctor-sandbox.js";
|
||||
import { noteSecurityWarnings } from "../commands/doctor-security.js";
|
||||
@@ -234,6 +235,14 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
async function runLegacyPluginManifestHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
await maybeRepairLegacyPluginManifestContracts({
|
||||
env: process.env,
|
||||
runtime: ctx.runtime,
|
||||
prompter: ctx.prompter,
|
||||
});
|
||||
}
|
||||
|
||||
async function runStateIntegrityHealth(ctx: DoctorHealthFlowContext): Promise<void> {
|
||||
await noteStateIntegrity(ctx.cfg, ctx.prompter, ctx.configPath);
|
||||
}
|
||||
@@ -481,6 +490,11 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] {
|
||||
label: "Legacy state",
|
||||
run: runLegacyStateHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:legacy-plugin-manifests",
|
||||
label: "Legacy plugin manifests",
|
||||
run: runLegacyPluginManifestHealth,
|
||||
}),
|
||||
createDoctorHealthContribution({
|
||||
id: "doctor:state-integrity",
|
||||
label: "State integrity",
|
||||
|
||||
@@ -358,7 +358,7 @@ describe("loadPluginManifestRegistry", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("normalizes legacy top-level capability fields into contracts", () => {
|
||||
it("does not promote legacy top-level capability fields into contracts", () => {
|
||||
const dir = makeTempDir();
|
||||
writeManifest(dir, {
|
||||
id: "openai",
|
||||
@@ -375,11 +375,7 @@ describe("loadPluginManifestRegistry", () => {
|
||||
origin: "bundled",
|
||||
});
|
||||
|
||||
expect(registry.plugins[0]?.contracts).toEqual({
|
||||
speechProviders: ["openai"],
|
||||
mediaUnderstandingProviders: ["openai", "openai-codex"],
|
||||
imageGenerationProviders: ["openai"],
|
||||
});
|
||||
expect(registry.plugins[0]?.contracts).toBeUndefined();
|
||||
});
|
||||
it("skips plugins whose minHostVersion is newer than the current host", () => {
|
||||
const dir = makeTempDir();
|
||||
|
||||
@@ -131,27 +131,6 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u
|
||||
return Object.keys(contracts).length > 0 ? contracts : undefined;
|
||||
}
|
||||
|
||||
function normalizeLegacyCapabilityContracts(
|
||||
raw: Record<string, unknown>,
|
||||
): PluginManifestContracts | undefined {
|
||||
return normalizeManifestContracts({
|
||||
speechProviders: raw.speechProviders,
|
||||
mediaUnderstandingProviders: raw.mediaUnderstandingProviders,
|
||||
imageGenerationProviders: raw.imageGenerationProviders,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeManifestContracts(
|
||||
fallback: PluginManifestContracts | undefined,
|
||||
primary: PluginManifestContracts | undefined,
|
||||
): PluginManifestContracts | undefined {
|
||||
const merged = {
|
||||
...fallback,
|
||||
...primary,
|
||||
} satisfies PluginManifestContracts;
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
function normalizeProviderAuthChoices(
|
||||
value: unknown,
|
||||
): PluginManifestProviderAuthChoice[] | undefined {
|
||||
@@ -303,10 +282,7 @@ export function loadPluginManifest(
|
||||
const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars);
|
||||
const providerAuthChoices = normalizeProviderAuthChoices(raw.providerAuthChoices);
|
||||
const skills = normalizeStringList(raw.skills);
|
||||
const contracts = mergeManifestContracts(
|
||||
normalizeLegacyCapabilityContracts(raw),
|
||||
normalizeManifestContracts(raw.contracts),
|
||||
);
|
||||
const contracts = normalizeManifestContracts(raw.contracts);
|
||||
const channelConfigs = normalizeChannelConfigs(raw.channelConfigs);
|
||||
|
||||
let uiHints: Record<string, PluginConfigUiHint> | undefined;
|
||||
|
||||
Reference in New Issue
Block a user