diff --git a/src/commands/doctor/shared/plugin-registry-migration.ts b/src/commands/doctor/shared/plugin-registry-migration.ts index 6012be86a4ed..304f47650341 100644 --- a/src/commands/doctor/shared/plugin-registry-migration.ts +++ b/src/commands/doctor/shared/plugin-registry-migration.ts @@ -14,11 +14,9 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { inspectPersistedInstalledPluginIndexInstallRecordsSync } from "../../../plugins/installed-plugin-index-record-state.js"; import { loadInstalledPluginIndexInstallRecords } from "../../../plugins/installed-plugin-index-records.js"; import { - inspectPersistedInstalledPluginIndex, readPersistedInstalledPluginIndexSync, resolveInstalledPluginIndexStorePath, writePersistedInstalledPluginIndex, - type InstalledPluginIndexStoreInspection, type InstalledPluginIndexStoreOptions, } from "../../../plugins/installed-plugin-index-store.js"; import { @@ -58,7 +56,6 @@ type PluginRegistryInstallMigrationResult = status: "migrated"; migrated: true; preflight: PluginRegistryInstallMigrationPreflight; - inspection: InstalledPluginIndexStoreInspection; current: InstalledPluginIndex; }; @@ -321,7 +318,6 @@ export async function migratePluginRegistryForInstall( config, installRecords, }; - const inspection = await inspectPersistedInstalledPluginIndex(migrationParams); const candidateIndex = loadInstalledPluginIndex({ ...migrationParams, }); @@ -341,7 +337,6 @@ export async function migratePluginRegistryForInstall( status: "migrated", migrated: true, preflight, - inspection, current, }; } diff --git a/src/plugins/installed-plugin-index-invalidation.ts b/src/plugins/installed-plugin-index-invalidation.ts index 562bcbc0395c..b3bdc9f4713a 100644 --- a/src/plugins/installed-plugin-index-invalidation.ts +++ b/src/plugins/installed-plugin-index-invalidation.ts @@ -45,6 +45,8 @@ export function diffInstalledPluginIndexInvalidationReasons( if ( previousPlugin.rootDir !== currentPlugin.rootDir || previousPlugin.manifestPath !== currentPlugin.manifestPath || + previousPlugin.source !== currentPlugin.source || + previousPlugin.setupSource !== currentPlugin.setupSource || resolveInstalledPluginIndexInstallOwner(previousPlugin) !== resolveInstalledPluginIndexInstallOwner(currentPlugin) || isInstalledPluginIndexInstallOwnerAmbiguous(previousPlugin) !== diff --git a/src/plugins/installed-plugin-index-store.test.ts b/src/plugins/installed-plugin-index-store.test.ts index d2bc066a02f2..1e0ae137a106 100644 --- a/src/plugins/installed-plugin-index-store.test.ts +++ b/src/plugins/installed-plugin-index-store.test.ts @@ -18,7 +18,6 @@ import { writePersistedInstalledPluginIndexInstallRecordsWithLease, } from "./installed-plugin-index-records.js"; import { - inspectPersistedInstalledPluginIndex, readPersistedInstalledPluginIndex, refreshPersistedInstalledPluginIndex, resolveInstalledPluginIndexStorePath, @@ -669,13 +668,12 @@ describe("installed plugin index persistence", () => { }; await writePersistedInstalledPluginIndex(legacy, { stateDir }); - const inspection = await inspectPersistedInstalledPluginIndex({ + const inspection = loadPluginRegistrySnapshotWithMetadata({ stateDir, candidates: [candidate], env, }); - expect(inspection.state).toBe("stale"); - expect(inspection.refreshReasons).toEqual(["migration"]); + expect(inspection.source).toBe("derived"); const refreshed = await refreshPersistedInstalledPluginIndex({ reason: "policy-changed", @@ -735,85 +733,6 @@ describe("installed plugin index persistence", () => { await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toBeNull(); }); - it("inspects missing, fresh, and stale persisted index state without loading runtime", async () => { - const stateDir = makeTempDir(); - const pluginDir = path.join(stateDir, "plugins", "demo"); - fs.mkdirSync(pluginDir, { recursive: true }); - const candidate = createCandidate(pluginDir); - const env = { - OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, - OPENCLAW_VERSION: "2026.4.25", - VITEST: "true", - }; - - const missingInspect = await inspectPersistedInstalledPluginIndex({ - stateDir, - candidates: [candidate], - env, - }); - expect(missingInspect.state).toBe("missing"); - expect(missingInspect.refreshReasons).toEqual(["missing"]); - expect(missingInspect.persisted).toBeNull(); - expectPluginIds(missingInspect.current, ["demo"]); - - const current = await refreshPersistedInstalledPluginIndex({ - reason: "manual", - stateDir, - candidates: [candidate], - env, - }); - - const freshInspect = await inspectPersistedInstalledPluginIndex({ - stateDir, - candidates: [candidate], - env, - }); - expect(freshInspect.state).toBe("fresh"); - expect(freshInspect.refreshReasons).toEqual([]); - expect(freshInspect.persisted).toEqual(current); - expectPluginFields(freshInspect.current, "demo", { enabled: true }); - - const policyInspect = await inspectPersistedInstalledPluginIndex({ - stateDir, - candidates: [candidate], - config: { - plugins: { - entries: { - demo: { - enabled: false, - }, - }, - }, - }, - env, - }); - expect(policyInspect.state).toBe("stale"); - expect(policyInspect.refreshReasons).toEqual(["policy-changed"]); - expect(policyInspect.persisted).toEqual(current); - expectPluginFields(policyInspect.current, "demo", { enabled: false }); - - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "demo", - name: "Demo", - configSchema: { type: "object" }, - providers: ["demo", "demo-next"], - }), - "utf8", - ); - - const staleManifestInspect = await inspectPersistedInstalledPluginIndex({ - stateDir, - candidates: [candidate], - env, - }); - expect(staleManifestInspect.state).toBe("stale"); - expect(staleManifestInspect.refreshReasons).toEqual(["stale-manifest"]); - expect(staleManifestInspect.persisted).toEqual(current); - expectPluginIds(staleManifestInspect.current, ["demo"]); - }); - it("refreshes and persists a rebuilt index without loading plugin runtime", async () => { const stateDir = makeTempDir(); const pluginDir = path.join(stateDir, "plugins", "demo"); diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 4bae7e63d162..c1a7fc142554 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -33,19 +33,15 @@ import { type InstalledPluginIndexStoreOptions, } from "./installed-plugin-index-store-path.js"; import { - diffInstalledPluginIndexInvalidationReasons, extractPluginInstallRecordsFromInstalledPluginIndex, hasInstalledPluginIndexWorkspaceScopeMismatch, hasMissingConfigPathActivationMetadata, INSTALLED_PLUGIN_INDEX_WARNING, INSTALLED_PLUGIN_INDEX_VERSION, INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION, - loadInstalledPluginIndex, resolveInstalledPluginIndexPolicyHash, refreshInstalledPluginIndex, type InstalledPluginIndex, - type InstalledPluginIndexRefreshReason, - type LoadInstalledPluginIndexParams, type RefreshInstalledPluginIndexParams, } from "./installed-plugin-index.js"; import { hasMissingInstalledPluginOwnerMetadata } from "./installed-plugin-package-ownership.js"; @@ -56,16 +52,6 @@ export { type InstalledPluginIndexStoreOptions, } from "./installed-plugin-index-store-path.js"; -/** Freshness state for the persisted installed plugin index. */ -type InstalledPluginIndexStoreState = "missing" | "fresh" | "stale"; - -export type InstalledPluginIndexStoreInspection = { - state: InstalledPluginIndexStoreState; - refreshReasons: readonly InstalledPluginIndexRefreshReason[]; - persisted: InstalledPluginIndex | null; - current: InstalledPluginIndex; -}; - export type InstalledPluginIndexWriteLease = { assertOwnedInTransaction(database: DatabaseSync): void; }; @@ -564,33 +550,6 @@ function refreshPersistedPolicyState( }; } -export async function inspectPersistedInstalledPluginIndex( - params: LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions = {}, -): Promise { - const persisted = await readPersistedInstalledPluginIndex(params); - const current = loadInstalledPluginIndex({ - ...params, - installRecords: - params.installRecords ?? extractPluginInstallRecordsFromInstalledPluginIndex(persisted), - }); - if (!persisted) { - return { - state: "missing", - refreshReasons: ["missing"], - persisted: null, - current, - }; - } - - const refreshReasons = diffInstalledPluginIndexInvalidationReasons(persisted, current); - return { - state: refreshReasons.length > 0 ? "stale" : "fresh", - refreshReasons, - persisted, - current, - }; -} - export async function refreshPersistedInstalledPluginIndex( params: RefreshInstalledPluginIndexParams & InstalledPluginIndexStoreOptions, ): Promise { diff --git a/src/plugins/plugin-registry-inspection.test.ts b/src/plugins/plugin-registry-inspection.test.ts new file mode 100644 index 000000000000..5ae064224c5a --- /dev/null +++ b/src/plugins/plugin-registry-inspection.test.ts @@ -0,0 +1,235 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import type { PluginCandidate } from "./discovery.js"; +import { + readPersistedInstalledPluginIndex, + refreshPersistedInstalledPluginIndex, + writePersistedInstalledPluginIndex, +} from "./installed-plugin-index-store.js"; +import type { InstalledPluginIndex } from "./installed-plugin-index.js"; +import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; +import { + inspectPluginRegistry, + loadPluginRegistrySnapshotWithMetadata, + refreshPluginRegistry, +} from "./plugin-registry.js"; +import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); + clearPluginMetadataLifecycleCaches(); + cleanupTrackedTempDirs(tempDirs); +}); + +function makeTempDir(): string { + return makeTrackedTempDir("openclaw-plugin-registry-inspection", tempDirs); +} + +function hermeticEnv(): NodeJS.ProcessEnv { + return { + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + OPENCLAW_VERSION: "2026.4.25", + VITEST: "true", + }; +} + +function createCandidate(rootDir: string): PluginCandidate { + const source = path.join(rootDir, "index.ts"); + fs.writeFileSync(source, "export default { register() {} };\n", "utf8"); + fs.writeFileSync( + path.join(rootDir, "openclaw.plugin.json"), + JSON.stringify({ id: "demo", name: "Demo", configSchema: { type: "object" } }), + "utf8", + ); + return { idHint: "demo", source, rootDir, origin: "global" }; +} + +function createEmptyIndex(stateDir: string): InstalledPluginIndex { + return { + version: 1, + hostContractVersion: "2026.4.25", + compatRegistryVersion: "compat-v1", + migrationVersion: 1, + policyHash: "policy-v1", + generatedAtMs: 1777118400000, + installRecords: { + missing: { + source: "npm", + spec: "missing-plugin@1.0.0", + installPath: path.join(stateDir, "plugins", "missing"), + }, + }, + plugins: [], + diagnostics: [], + }; +} + +describe("plugin registry inspection", () => { + it("derives without persisted install records when persisted reads are disabled", async () => { + const stateDir = makeTempDir(); + const pluginDir = makeTempDir(); + const candidate = createCandidate(pluginDir); + await writePersistedInstalledPluginIndex(createEmptyIndex(stateDir), { stateDir }); + + const result = loadPluginRegistrySnapshotWithMetadata({ + stateDir, + candidates: [candidate], + env: hermeticEnv(), + preferPersisted: false, + }); + + expect(result.source).toBe("derived"); + expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["demo"]); + expect(result.snapshot.installRecords).not.toHaveProperty("missing"); + }); + + it("reports missing, fresh, policy, and manifest freshness from the snapshot selector", async () => { + const stateDir = makeTempDir(); + const pluginDir = makeTempDir(); + const candidate = createCandidate(pluginDir); + const env = hermeticEnv(); + const config = {}; + + const missing = await inspectPluginRegistry({ stateDir, candidates: [candidate], config, env }); + expect(missing.state).toBe("missing"); + expect(missing.refreshReasons).toEqual(["missing"]); + + await refreshPluginRegistry({ + reason: "manual", + stateDir, + candidates: [candidate], + config, + env, + }); + const fresh = await inspectPluginRegistry({ stateDir, candidates: [candidate], config, env }); + expect(fresh.state).toBe("fresh"); + expect(fresh.refreshReasons).toEqual([]); + + const policy = await inspectPluginRegistry({ + stateDir, + candidates: [candidate], + config: { plugins: { entries: { demo: { enabled: false } } } }, + env, + }); + expect(policy.state).toBe("stale"); + expect(policy.refreshReasons).toEqual(["policy-changed"]); + + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "demo", + name: "Demo", + configSchema: { type: "object" }, + providers: ["demo-next"], + }), + "utf8", + ); + const manifest = await inspectPluginRegistry({ + stateDir, + candidates: [candidate], + config, + env, + }); + expect(manifest.state).toBe("stale"); + expect(manifest.refreshReasons).toEqual(["stale-manifest"]); + }); + + it("agrees with snapshot selection when a packaged runtime entry changes", async () => { + const stateDir = makeTempDir(); + const pluginDir = makeTempDir(); + const sourceCandidate = createCandidate(pluginDir); + const env = hermeticEnv(); + await refreshPluginRegistry({ + reason: "manual", + stateDir, + candidates: [sourceCandidate], + env, + }); + const builtSource = path.join(pluginDir, "index.js"); + fs.writeFileSync(builtSource, "export default { register() {} };\n", "utf8"); + fs.rmSync(sourceCandidate.source); + const builtCandidate = { ...sourceCandidate, source: builtSource }; + + const snapshot = loadPluginRegistrySnapshotWithMetadata({ + stateDir, + candidates: [builtCandidate], + env, + }); + const inspection = await inspectPluginRegistry({ + stateDir, + candidates: [builtCandidate], + env, + }); + + expect(snapshot.source).toBe("derived"); + expect(snapshot.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + "persisted-registry-stale-source", + ]); + expect(inspection.state).toBe("stale"); + expect(inspection.refreshReasons).toEqual(["source-changed"]); + expect(inspection.current.plugins[0]?.source).toBe(builtSource); + }); + + it("uses the configured system-agent workspace for the freshness verdict", async () => { + const stateDir = makeTempDir(); + const workspaceDir = makeTempDir(); + const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "demo"); + fs.mkdirSync(pluginDir, { recursive: true }); + createCandidate(pluginDir); + const env = { ...hermeticEnv(), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const config = { + agents: { + ownership: "explicit" as const, + defaults: { systemAgent: { agentId: "main" } }, + entries: { main: { workspace: workspaceDir } }, + }, + }; + await refreshPersistedInstalledPluginIndex({ + reason: "manual", + stateDir, + config, + env, + }); + + const listSelection = loadPluginRegistrySnapshotWithMetadata({ + stateDir, + workspaceDir, + config, + env, + }); + const inspection = await inspectPluginRegistry({ stateDir, config, env }); + + expect(listSelection.source).toBe("derived"); + expect(listSelection.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + "persisted-registry-stale-source", + ]); + expect(inspection.state).toBe("stale"); + expect(inspection.refreshReasons).toEqual(["source-changed"]); + expect(inspection.current.workspaceDir).toBe(workspaceDir); + expect(inspection.current.plugins.map((plugin) => plugin.pluginId)).toEqual(["demo"]); + + await refreshPluginRegistry({ reason: "manual", stateDir, config, env }); + const repaired = await inspectPluginRegistry({ stateDir, config, env }); + expect(repaired.state).toBe("fresh"); + expect(repaired.refreshReasons).toEqual([]); + }); + + it("preserves install records when refreshing the persisted registry", async () => { + const stateDir = makeTempDir(); + await writePersistedInstalledPluginIndex(createEmptyIndex(stateDir), { stateDir }); + + await refreshPluginRegistry({ reason: "manual", stateDir, candidates: [], env: hermeticEnv() }); + + const persisted = await readPersistedInstalledPluginIndex({ stateDir }); + expect(persisted?.installRecords.missing).toMatchObject({ + source: "npm", + spec: "missing-plugin@1.0.0", + installPath: path.join(stateDir, "plugins", "missing"), + }); + expect(persisted?.plugins).toEqual([]); + }); +}); diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index fa76a9ecec9a..b17bc8649a45 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -18,13 +18,12 @@ import { safeFileSignature, safeHashFile } from "./installed-plugin-index-hash.j import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import { - inspectPersistedInstalledPluginIndex, readPersistedInstalledPluginIndexSync, refreshPersistedInstalledPluginIndex, - type InstalledPluginIndexStoreInspection, type InstalledPluginIndexStoreOptions, } from "./installed-plugin-index-store.js"; import { + diffInstalledPluginIndexInvalidationReasons, extractPluginInstallRecordsFromInstalledPluginIndex, getInstalledPluginRecord, hasInstalledPluginIndexWorkspaceScopeMismatch, @@ -124,7 +123,6 @@ function resolvePluginRegistryContent( export type PluginRegistrySnapshot = InstalledPluginIndex; export type PluginRegistryRecord = InstalledPluginIndexRecord; -type PluginRegistryInspection = InstalledPluginIndexStoreInspection; export type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js"; type PluginRegistrySnapshotDiagnosticCode = | "persisted-registry-missing" @@ -156,6 +154,26 @@ type GetPluginRecordParams = LoadPluginRegistryParams & { pluginId: string; }; +function resolveControlPlaneRegistryParams(params: T): T { + if (!params.config) { + return params; + } + const workspace = resolvePluginControlPlaneWorkspace({ + config: params.config, + env: params.env, + workspaceDir: params.workspaceDir, + }); + const diagnostics = appendPluginControlPlaneWorkspaceDiagnostic( + params.diagnostics ?? [], + workspace, + ); + return { + ...params, + ...(diagnostics.length > 0 ? { diagnostics } : {}), + ...(workspace.workspaceDir !== undefined ? { workspaceDir: workspace.workspaceDir } : {}), + }; +} + function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams): boolean { return ( params.allowCurrent !== false && @@ -619,10 +637,41 @@ export function isPluginEnabled(params: GetPluginRecordParams): boolean { return isInstalledPluginEnabled(resolveSnapshot(params), params.pluginId, params.config); } -export function inspectPluginRegistry( +export async function inspectPluginRegistry( params: LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions = {}, -): Promise { - return inspectPersistedInstalledPluginIndex(params); +) { + const inspectionParams = resolveControlPlaneRegistryParams(params); + const persisted = readPersistedInstalledPluginIndexSync(inspectionParams); + // Inspection and runtime selection share one verdict so runtime cannot reject "fresh". + const result = loadPluginRegistrySnapshotWithMetadata({ + ...inspectionParams, + allowCurrent: false, + }); + if (!persisted) { + return { + state: "missing" as const, + refreshReasons: ["missing"], + persisted: null, + current: result.snapshot, + }; + } + const fresh = result.source === "persisted"; + const refreshReasons = fresh + ? [] + : [...diffInstalledPluginIndexInvalidationReasons(persisted, result.snapshot)]; + if (!fresh && refreshReasons.length === 0) { + refreshReasons.push( + result.diagnostics.some((diagnostic) => diagnostic.code === "persisted-registry-stale-policy") + ? "policy-changed" + : "source-changed", + ); + } + return { + state: fresh ? ("fresh" as const) : ("stale" as const), + refreshReasons, + persisted, + current: result.snapshot, + }; } export function refreshPluginRegistry( @@ -631,15 +680,5 @@ export function refreshPluginRegistry( if (!params.config) { return refreshPersistedInstalledPluginIndex(params); } - const workspace = resolvePluginControlPlaneWorkspace({ - config: params.config, - env: params.env, - workspaceDir: params.workspaceDir, - }); - const refreshParams = { - ...params, - diagnostics: appendPluginControlPlaneWorkspaceDiagnostic(params.diagnostics ?? [], workspace), - ...(workspace.workspaceDir !== undefined ? { workspaceDir: workspace.workspaceDir } : {}), - }; - return refreshPersistedInstalledPluginIndex(refreshParams); + return refreshPersistedInstalledPluginIndex(resolveControlPlaneRegistryParams(params)); } diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 5e5175ba55df..e1c1677dcf03 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -8,10 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { recordPluginCandidateInstallOwner } from "./candidate-install-owner.js"; import type { PluginCandidate } from "./discovery.js"; -import { - readPersistedInstalledPluginIndex, - writePersistedInstalledPluginIndex, -} from "./installed-plugin-index-store.js"; +import { writePersistedInstalledPluginIndex } from "./installed-plugin-index-store.js"; import { resolveInstalledPluginIndexPolicyHash, type InstalledPluginIndex, @@ -21,13 +18,11 @@ import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle. import { createPluginRegistryIdNormalizer, getPluginRecord, - inspectPluginRegistry, isPluginEnabled, listPluginContributionIds, loadPluginRegistrySnapshot, loadPluginRegistrySnapshotWithMetadata, normalizePluginsConfigWithRegistry, - refreshPluginRegistry, resolveManifestContractOwnerPluginId, resolveManifestContractPluginIds, resolvePluginContributionOwners, @@ -981,94 +976,4 @@ describe("plugin registry facade", () => { expect(first.snapshot.hostContractVersion).toBe("2026.4.25"); expect(second.snapshot.hostContractVersion).toBe("2026.4.26"); }); - - it("derives a fresh registry without persisted install records when caller disables persisted reads", async () => { - const stateDir = makeTempDir(); - const rootDir = makeTempDir(); - const candidate = createCandidate(rootDir); - await writePersistedInstalledPluginIndex( - createIndex("persisted", { - installRecords: { - persisted: { - source: "npm", - spec: "persisted-plugin@1.0.0", - installPath: path.join(stateDir, "plugins", "persisted"), - }, - }, - }), - { stateDir }, - ); - - const result = loadPluginRegistrySnapshotWithMetadata({ - stateDir, - candidates: [candidate], - env: hermeticEnv(), - preferPersisted: false, - }); - - expect(result.source).toBe("derived"); - expectSnapshotPluginIds(result.snapshot, ["demo"]); - expect(result.snapshot.installRecords).not.toHaveProperty("persisted"); - }); - - it("exposes explicit persisted registry inspect and refresh operations", async () => { - const stateDir = makeTempDir(); - const pluginDir = path.join(stateDir, "plugins", "demo"); - fs.mkdirSync(pluginDir, { recursive: true }); - const candidate = createCandidate(pluginDir); - const env = hermeticEnv(); - - const missingInspect = await inspectPluginRegistry({ stateDir, candidates: [candidate], env }); - expect(missingInspect.state).toBe("missing"); - expect(missingInspect.refreshReasons).toEqual(["missing"]); - expect(missingInspect.persisted).toBeNull(); - expect(missingInspect.current.plugins.map((plugin) => plugin.pluginId)).toEqual(["demo"]); - - await refreshPluginRegistry({ - reason: "manual", - stateDir, - candidates: [candidate], - env, - }); - - const freshInspect = await inspectPluginRegistry({ stateDir, candidates: [candidate], env }); - expect(freshInspect.state).toBe("fresh"); - expect(freshInspect.refreshReasons).toEqual([]); - expect(freshInspect.persisted?.plugins.map((plugin) => plugin.pluginId)).toEqual(["demo"]); - }); - - it("preserves install records when refreshing the persisted registry", async () => { - const stateDir = makeTempDir(); - await writePersistedInstalledPluginIndex( - createIndex("missing", { - installRecords: { - missing: { - source: "npm", - spec: "missing-plugin@1.0.0", - installPath: path.join(stateDir, "plugins", "missing"), - }, - }, - plugins: [], - }), - { stateDir }, - ); - - await refreshPluginRegistry({ - reason: "manual", - stateDir, - candidates: [], - env: hermeticEnv(), - }); - - const persisted = await readPersistedInstalledPluginIndex({ stateDir }); - if (!persisted) { - throw new Error("Expected persisted plugin index"); - } - expectInstallRecord(persisted.installRecords, "missing", { - source: "npm", - spec: "missing-plugin@1.0.0", - installPath: path.join(stateDir, "plugins", "missing"), - }); - expect(persisted.plugins).toEqual([]); - }); });