diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index ac0a755c3ca..9c3aa7584c3 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -32,7 +32,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const DEFAULT_EXTENSIONS_DIR = join(__dirname, "..", "dist", "extensions"); const DEFAULT_PACKAGE_ROOT = join(__dirname, ".."); const DISABLE_POSTINSTALL_ENV = "OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL"; -const DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION"; const EAGER_BUNDLED_PLUGIN_DEPS_ENV = "OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"; const DIST_INVENTORY_PATH = "dist/postinstall-inventory.json"; const LEGACY_QA_CHANNEL_DIR = ["qa", "channel"].join("-"); @@ -662,45 +661,34 @@ async function importInstalledDistModule(params, distPath) { } export async function runPluginRegistryPostinstallMigration(params = {}) { - const env = params.env ?? process.env; const log = params.log ?? console; const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; - if (env?.[DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV]?.trim()) { - return { status: "disabled" }; - } try { - const [configModule, registryModule] = await Promise.all([ - importInstalledDistModule(params, "dist/config/config.js"), - importInstalledDistModule(params, "dist/plugins/plugin-registry.js"), - ]); - if (!configModule || !registryModule) { + const migrationModule = await importInstalledDistModule( + params, + "dist/commands/doctor/shared/plugin-registry-migration.js", + ); + if (!migrationModule) { return { status: "skipped", reason: "missing-dist-entry" }; } - const readConfig = - typeof configModule.readBestEffortConfig === "function" - ? configModule.readBestEffortConfig - : configModule.loadConfig; - if ( - typeof readConfig !== "function" || - typeof registryModule.ensurePluginRegistryMigrated !== "function" - ) { + if (typeof migrationModule.migratePluginRegistryForInstall !== "function") { return { status: "skipped", reason: "missing-dist-contract" }; } - const config = await readConfig(); - const inspection = await registryModule.ensurePluginRegistryMigrated({ - config, - env, + const result = await migrationModule.migratePluginRegistryForInstall({ + env: params.env ?? process.env, packageRoot, }); - if (inspection.migrated) { - log.log( - `[postinstall] migrated plugin registry: ${inspection.current.plugins.length} plugin(s) indexed`, - ); - return { status: "migrated", inspection }; + for (const warning of result.preflight?.deprecationWarnings ?? []) { + log.warn(`[postinstall] ${warning}`); } - return { status: "fresh", inspection }; + if (result.migrated) { + log.log( + `[postinstall] migrated plugin registry: ${result.current.plugins.length} plugin(s) indexed`, + ); + } + return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); log.warn(`[postinstall] could not migrate plugin registry: ${message}`); diff --git a/src/commands/doctor/shared/plugin-registry-migration.test.ts b/src/commands/doctor/shared/plugin-registry-migration.test.ts new file mode 100644 index 00000000000..ddc8745c405 --- /dev/null +++ b/src/commands/doctor/shared/plugin-registry-migration.test.ts @@ -0,0 +1,172 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginCandidate } from "../../../plugins/discovery.js"; +import { readPersistedInstalledPluginIndex } from "../../../plugins/installed-plugin-index-store.js"; +import { + cleanupTrackedTempDirs, + makeTrackedTempDir, +} from "../../../plugins/test-helpers/fs-fixtures.js"; +import { + FORCE_PLUGIN_REGISTRY_MIGRATION_ENV, + migratePluginRegistryForInstall, + preflightPluginRegistryInstallMigration, +} from "./plugin-registry-migration.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + cleanupTrackedTempDirs(tempDirs); +}); + +function makeTempDir() { + return makeTrackedTempDir("openclaw-plugin-registry-migration", tempDirs); +} + +function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", + OPENCLAW_VERSION: "2026.4.25", + VITEST: "true", + ...overrides, + }; +} + +function createCandidate(rootDir: string): PluginCandidate { + fs.writeFileSync( + path.join(rootDir, "index.ts"), + "throw new Error('runtime entry should not load while migrating plugin registry');\n", + "utf8", + ); + fs.writeFileSync( + path.join(rootDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "demo", + name: "Demo", + configSchema: { type: "object" }, + providers: ["demo"], + }), + "utf8", + ); + return { + idHint: "demo", + source: path.join(rootDir, "index.ts"), + rootDir, + origin: "global", + }; +} + +describe("plugin registry install migration", () => { + it("short-circuits when a registry file already exists", async () => { + const stateDir = makeTempDir(); + const filePath = path.join(stateDir, "plugins", "installed-index.json"); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "{}\n", "utf8"); + const readConfig = vi.fn(async () => ({})); + + await expect( + migratePluginRegistryForInstall({ + stateDir, + readConfig, + env: hermeticEnv(), + }), + ).resolves.toMatchObject({ + status: "skip-existing", + migrated: false, + preflight: { + action: "skip-existing", + filePath, + }, + }); + expect(readConfig).not.toHaveBeenCalled(); + }); + + it("supports dry-run preflight without reading config or writing the registry", async () => { + const stateDir = makeTempDir(); + const readConfig = vi.fn(async () => ({})); + + await expect( + migratePluginRegistryForInstall({ + stateDir, + dryRun: true, + readConfig, + env: hermeticEnv(), + }), + ).resolves.toMatchObject({ + status: "dry-run", + migrated: false, + preflight: { + action: "migrate", + }, + }); + expect(readConfig).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(stateDir, "plugins", "installed-index.json"))).toBe(false); + }); + + it("migrates missing registry state from legacy discovery and config inputs", async () => { + const stateDir = makeTempDir(); + const pluginDir = path.join(stateDir, "plugins", "demo"); + fs.mkdirSync(pluginDir, { recursive: true }); + const candidate = createCandidate(pluginDir); + + await expect( + migratePluginRegistryForInstall({ + stateDir, + candidates: [candidate], + readConfig: async () => ({ + plugins: { + installs: { + demo: { + source: "npm", + resolvedName: "@vendor/demo", + resolvedVersion: "1.0.0", + }, + }, + }, + }), + env: hermeticEnv(), + }), + ).resolves.toMatchObject({ + status: "migrated", + migrated: true, + current: { + refreshReason: "migration", + migrationVersion: 1, + plugins: [ + expect.objectContaining({ + pluginId: "demo", + installRecord: expect.objectContaining({ + source: "npm", + resolvedName: "@vendor/demo", + resolvedVersion: "1.0.0", + }), + }), + ], + }, + }); + + await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({ + refreshReason: "migration", + plugins: [expect.objectContaining({ pluginId: "demo" })], + }); + }); + + it("marks force migration env as deprecated break-glass", () => { + expect( + preflightPluginRegistryInstallMigration({ + stateDir: makeTempDir(), + env: hermeticEnv({ + [FORCE_PLUGIN_REGISTRY_MIGRATION_ENV]: "1", + }), + }), + ).toMatchObject({ + action: "migrate", + force: true, + deprecationWarnings: [ + expect.stringContaining(`${FORCE_PLUGIN_REGISTRY_MIGRATION_ENV} is deprecated`), + ], + }); + }); +}); diff --git a/src/commands/doctor/shared/plugin-registry-migration.ts b/src/commands/doctor/shared/plugin-registry-migration.ts new file mode 100644 index 00000000000..80ae3a4575c --- /dev/null +++ b/src/commands/doctor/shared/plugin-registry-migration.ts @@ -0,0 +1,135 @@ +import fs from "node:fs"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { + inspectPersistedInstalledPluginIndex, + refreshPersistedInstalledPluginIndex, + resolveInstalledPluginIndexStorePath, + type InstalledPluginIndexStoreInspection, + type InstalledPluginIndexStoreOptions, +} from "../../../plugins/installed-plugin-index-store.js"; +import type { + InstalledPluginIndex, + LoadInstalledPluginIndexParams, +} from "../../../plugins/installed-plugin-index.js"; + +export const DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION"; +export const FORCE_PLUGIN_REGISTRY_MIGRATION_ENV = "OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION"; + +export type PluginRegistryInstallMigrationPreflightAction = + | "disabled" + | "skip-existing" + | "migrate"; + +export type PluginRegistryInstallMigrationPreflight = { + action: PluginRegistryInstallMigrationPreflightAction; + filePath: string; + force: boolean; + deprecationWarnings: readonly string[]; +}; + +export type PluginRegistryInstallMigrationResult = + | { + status: "disabled" | "skip-existing" | "dry-run"; + migrated: false; + preflight: PluginRegistryInstallMigrationPreflight; + } + | { + status: "migrated"; + migrated: true; + preflight: PluginRegistryInstallMigrationPreflight; + inspection: InstalledPluginIndexStoreInspection; + current: InstalledPluginIndex; + }; + +export type PluginRegistryInstallMigrationParams = LoadInstalledPluginIndexParams & + InstalledPluginIndexStoreOptions & { + dryRun?: boolean; + existsSync?: (path: string) => boolean; + readConfig?: () => Promise | OpenClawConfig; + }; + +function hasEnvFlag(env: NodeJS.ProcessEnv | undefined, key: string): boolean { + return Boolean(env?.[key]?.trim()); +} + +function forceDeprecationWarning(): string { + return `${FORCE_PLUGIN_REGISTRY_MIGRATION_ENV} is deprecated and will be removed after the plugin registry migration rollout; use doctor registry repair once available.`; +} + +export function preflightPluginRegistryInstallMigration( + params: PluginRegistryInstallMigrationParams = {}, +): PluginRegistryInstallMigrationPreflight { + const env = params.env ?? process.env; + const filePath = resolveInstalledPluginIndexStorePath(params); + const force = hasEnvFlag(env, FORCE_PLUGIN_REGISTRY_MIGRATION_ENV); + const deprecationWarnings = force ? [forceDeprecationWarning()] : []; + if (hasEnvFlag(env, DISABLE_PLUGIN_REGISTRY_MIGRATION_ENV)) { + return { + action: "disabled", + filePath, + force, + deprecationWarnings, + }; + } + const pathExists = params.existsSync ?? fs.existsSync; + if (!force && pathExists(filePath)) { + return { + action: "skip-existing", + filePath, + force, + deprecationWarnings, + }; + } + return { + action: "migrate", + filePath, + force, + deprecationWarnings, + }; +} + +async function readMigrationConfig( + params: PluginRegistryInstallMigrationParams, +): Promise { + if (params.config) { + return params.config; + } + if (params.readConfig) { + return await params.readConfig(); + } + const configModule = await import("../../../config/config.js"); + return await configModule.readBestEffortConfig(); +} + +export async function migratePluginRegistryForInstall( + params: PluginRegistryInstallMigrationParams = {}, +): Promise { + const preflight = preflightPluginRegistryInstallMigration(params); + if (preflight.action === "disabled") { + return { status: "disabled", migrated: false, preflight }; + } + if (preflight.action === "skip-existing") { + return { status: "skip-existing", migrated: false, preflight }; + } + if (params.dryRun) { + return { status: "dry-run", migrated: false, preflight }; + } + + const config = await readMigrationConfig(params); + const migrationParams = { + ...params, + config, + }; + const inspection = await inspectPersistedInstalledPluginIndex(migrationParams); + const current = await refreshPersistedInstalledPluginIndex({ + ...migrationParams, + reason: "migration", + }); + return { + status: "migrated", + migrated: true, + preflight, + inspection, + current, + }; +} diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 26c6f3a245f..ebdabb20578 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -4,7 +4,6 @@ import { afterEach, describe, expect, it } from "vitest"; import type { PluginCandidate } from "./discovery.js"; import { getPluginRecord, - ensurePluginRegistryMigrated, inspectPluginRegistry, isPluginEnabled, listPluginContributionIds, @@ -187,36 +186,4 @@ describe("plugin registry facade", () => { }, }); }); - - it("migrates missing persisted registry state from legacy discovery inputs", async () => { - const stateDir = makeTempDir(); - const pluginDir = path.join(stateDir, "plugins", "demo"); - fs.mkdirSync(pluginDir, { recursive: true }); - const candidate = createCandidate(pluginDir); - const env = hermeticEnv(); - - await expect( - ensurePluginRegistryMigrated({ stateDir, candidates: [candidate], env }), - ).resolves.toMatchObject({ - state: "missing", - refreshReasons: ["missing"], - migrated: true, - current: { - refreshReason: "migration", - migrationVersion: 1, - plugins: [expect.objectContaining({ pluginId: "demo", enabled: true })], - }, - }); - - await expect( - inspectPluginRegistry({ stateDir, candidates: [candidate], env }), - ).resolves.toMatchObject({ - state: "fresh", - refreshReasons: [], - persisted: { - refreshReason: "migration", - plugins: [expect.objectContaining({ pluginId: "demo" })], - }, - }); - }); }); diff --git a/src/plugins/plugin-registry.ts b/src/plugins/plugin-registry.ts index 00884ab3840..9ab3048c034 100644 --- a/src/plugins/plugin-registry.ts +++ b/src/plugins/plugin-registry.ts @@ -20,9 +20,6 @@ import { export type PluginRegistrySnapshot = InstalledPluginIndex; export type PluginRegistryRecord = InstalledPluginIndexRecord; export type PluginRegistryInspection = InstalledPluginIndexStoreInspection; -export type PluginRegistryMigrationInspection = PluginRegistryInspection & { - migrated: boolean; -}; export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams & { index?: PluginRegistrySnapshot; @@ -177,31 +174,3 @@ export function refreshPluginRegistry( store.refreshPersistedInstalledPluginIndex(params), ); } - -function resolveMigrationRefreshReason( - reasons: readonly RefreshInstalledPluginIndexParams["reason"][], -): RefreshInstalledPluginIndexParams["reason"] { - return reasons.includes("missing") || reasons.includes("migration") ? "migration" : "manual"; -} - -export async function ensurePluginRegistryMigrated( - params: LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions = {}, -): Promise { - const store = await import("./installed-plugin-index-store.js"); - const inspection = await store.inspectPersistedInstalledPluginIndex(params); - if (inspection.state === "fresh") { - return { - ...inspection, - migrated: false, - }; - } - const current = await store.refreshPersistedInstalledPluginIndex({ - ...params, - reason: resolveMigrationRefreshReason(inspection.refreshReasons), - }); - return { - ...inspection, - current, - migrated: true, - }; -} diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 89df22acf54..873b5edc2fa 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -251,39 +251,29 @@ describe("bundled plugin postinstall", () => { it("migrates the plugin registry during postinstall from built dist contracts", async () => { const packageRoot = await createTempDirAsync("openclaw-postinstall-registry-"); const log = { log: vi.fn(), warn: vi.fn() }; - const readBestEffortConfig = vi.fn(async () => ({ - plugins: { - installs: { - demo: { - source: "npm", - resolvedName: "@vendor/demo", - resolvedVersion: "1.0.0", - }, - }, - }, - })); - const ensurePluginRegistryMigrated = vi.fn(async () => ({ + const migratePluginRegistryForInstall = vi.fn(async () => ({ + status: "migrated", migrated: true, + preflight: { + deprecationWarnings: [], + }, current: { plugins: [{ pluginId: "demo" }], }, })); const importModule = vi.fn(async (specifier: string) => { - if (specifier.endsWith("/dist/config/config.js")) { - return { readBestEffortConfig }; - } - if (specifier.endsWith("/dist/plugins/plugin-registry.js")) { - return { ensurePluginRegistryMigrated }; + if (specifier.endsWith("/dist/commands/doctor/shared/plugin-registry-migration.js")) { + return { migratePluginRegistryForInstall }; } throw new Error(`unexpected import: ${specifier}`); }); const result = await runPluginRegistryPostinstallMigration({ packageRoot, - existsSync: vi.fn( - (filePath: string) => - filePath.endsWith(path.join("dist", "config", "config.js")) || - filePath.endsWith(path.join("dist", "plugins", "plugin-registry.js")), + existsSync: vi.fn((filePath: string) => + filePath.endsWith( + path.join("dist", "commands", "doctor", "shared", "plugin-registry-migration.js"), + ), ), importModule, env: { OPENCLAW_HOME: "/tmp/home" }, @@ -291,19 +281,7 @@ describe("bundled plugin postinstall", () => { }); expect(result).toMatchObject({ status: "migrated" }); - expect(readBestEffortConfig).toHaveBeenCalled(); - expect(ensurePluginRegistryMigrated).toHaveBeenCalledWith({ - config: { - plugins: { - installs: { - demo: { - source: "npm", - resolvedName: "@vendor/demo", - resolvedVersion: "1.0.0", - }, - }, - }, - }, + expect(migratePluginRegistryForInstall).toHaveBeenCalledWith({ env: { OPENCLAW_HOME: "/tmp/home" }, packageRoot, }); @@ -312,6 +290,29 @@ describe("bundled plugin postinstall", () => { ); }); + it("surfaces deprecated plugin registry migration break-glass warnings", async () => { + const warn = vi.fn(); + const migratePluginRegistryForInstall = vi.fn(async () => ({ + status: "skip-existing", + migrated: false, + preflight: { + deprecationWarnings: ["OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION is deprecated"], + }, + })); + const importModule = vi.fn(async () => ({ migratePluginRegistryForInstall })); + + await runPluginRegistryPostinstallMigration({ + packageRoot: "/pkg", + existsSync: vi.fn(() => true), + importModule, + log: { log: vi.fn(), warn }, + }); + + expect(warn).toHaveBeenCalledWith( + "[postinstall] OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION is deprecated", + ); + }); + it("keeps plugin registry postinstall migration non-fatal when dist entries are unavailable", async () => { const warn = vi.fn(); @@ -329,12 +330,22 @@ describe("bundled plugin postinstall", () => { }); it("honors plugin registry postinstall migration disable env", async () => { + const migratePluginRegistryForInstall = vi.fn(async () => ({ + status: "disabled", + migrated: false, + preflight: { + deprecationWarnings: [], + }, + })); await expect( runPluginRegistryPostinstallMigration({ + packageRoot: "/pkg", env: { OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION: "1" }, + existsSync: vi.fn(() => true), + importModule: vi.fn(async () => ({ migratePluginRegistryForInstall })), log: { log: vi.fn(), warn: vi.fn() }, }), - ).resolves.toEqual({ status: "disabled" }); + ).resolves.toMatchObject({ status: "disabled" }); }); it("prunes stale dist files from packaged installs", async () => { diff --git a/tsdown.config.ts b/tsdown.config.ts index 7953f85372e..67f8777dc67 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -214,10 +214,10 @@ function buildCoreDistEntries(): Record { "agents/models-config.runtime": "src/agents/models-config.runtime.ts", "subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts", "agents/pi-model-discovery-runtime": "src/agents/pi-model-discovery-runtime.ts", + "commands/doctor/shared/plugin-registry-migration": + "src/commands/doctor/shared/plugin-registry-migration.ts", "commands/status.summary.runtime": "src/commands/status.summary.runtime.ts", - "config/config": "src/config/config.ts", "infra/boundary-file-read": "src/infra/boundary-file-read.ts", - "plugins/plugin-registry": "src/plugins/plugin-registry.ts", "plugins/provider-discovery.runtime": "src/plugins/provider-discovery.runtime.ts", "plugins/provider-runtime.runtime": "src/plugins/provider-runtime.runtime.ts", "plugins/public-surface-runtime": "src/plugins/public-surface-runtime.ts",