From 4a3ad3963b15b45e0232ca1131e7b568b904ceef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 19:50:35 +0100 Subject: [PATCH] feat(plugins): report dependency install state --- CHANGELOG.md | 2 + docs/cli/plugins.md | 8 +- docs/tools/plugin.md | 2 + src/plugins/discovery.ts | 17 +++ src/plugins/manifest-registry-installed.ts | 48 +++++-- src/plugins/manifest-registry.ts | 7 + src/plugins/manifest.ts | 2 + src/plugins/registry-types.ts | 2 + src/plugins/status-dependencies.ts | 135 ++++++++++++++++++ src/plugins/status.registry-snapshot.test.ts | 64 +++++++++ src/plugins/status.ts | 14 ++ .../test-helpers/cold-plugin-fixtures.ts | 2 + 12 files changed, 291 insertions(+), 12 deletions(-) create mode 100644 src/plugins/status-dependencies.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e14966b9dc..77e353c8556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai ### Changes +- Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins. + ### Fixes - Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 814fffcd9d7..7a22d8ba357 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -237,11 +237,17 @@ openclaw plugins search --json Switch from the table view to per-plugin detail lines with source/origin/version/activation metadata. - Machine-readable inventory plus registry diagnostics. + Machine-readable inventory plus registry diagnostics and package dependency install state. `plugins list` reads the persisted local plugin registry first, with a manifest-only derived fallback when the registry is missing or invalid. It is useful for checking whether a plugin is installed, enabled, and visible to cold startup planning, but it is not a live runtime probe of an already-running Gateway process. After changing plugin code, enablement, hook policy, or `plugins.load.paths`, restart the Gateway that serves the channel before expecting new `register(api)` code or hooks to run. For remote/container deployments, verify you are restarting the actual `openclaw gateway run` child, not only a wrapper process. + +`plugins list --json` includes each plugin's `dependencyStatus` from `package.json` +`dependencies` and `optionalDependencies`. OpenClaw checks whether those package +names are present along the plugin's normal Node `node_modules` lookup path; it +does not import plugin runtime code, run a package manager, or repair missing +dependencies. `plugins search` is a remote ClawHub catalog lookup. It does not inspect local diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 599f66d1b8e..4f450366e32 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -120,6 +120,8 @@ installed under OpenClaw's managed plugin roots. npm dependencies may be hoisted within OpenClaw's managed npm root; install/update scans that managed root before trust and uninstall removes npm-managed packages through npm. External plugins and custom load paths must still be installed through `openclaw plugins install`. +Use `openclaw plugins list --json` to see the static `dependencyStatus` for each +visible plugin without importing runtime code or repairing dependencies. See [Plugin dependency resolution](/plugins/dependency-resolution) for the install-time lifecycle. diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 3f85587dae5..4d15487be88 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -32,6 +32,10 @@ import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from ". import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; import { resolvePluginSourceRoots } from "./roots.js"; +import { + normalizePluginDependencySpecs, + type PluginDependencySpecMap, +} from "./status-dependencies.js"; const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); const SCANNED_DIRECTORY_IGNORE_NAMES = new Set([ @@ -61,6 +65,8 @@ export type PluginCandidate = { packageDescription?: string; packageDir?: string; packageManifest?: OpenClawPackageManifest; + packageDependencies?: PluginDependencySpecMap; + packageOptionalDependencies?: PluginDependencySpecMap; bundledManifest?: PluginManifest; bundledManifestPath?: string; }; @@ -494,6 +500,10 @@ function addCandidate(params: { } params.seen.add(resolved); const manifest = params.manifest ?? null; + const packageDependencies = normalizePluginDependencySpecs({ + dependencies: manifest?.dependencies, + optionalDependencies: manifest?.optionalDependencies, + }); params.candidates.push({ idHint: params.idHint, source: resolved, @@ -508,6 +518,8 @@ function addCandidate(params: { packageDescription: normalizeOptionalString(manifest?.description), packageDir: params.packageDir, packageManifest: getPackageManifestMetadata(manifest ?? undefined), + packageDependencies: packageDependencies.dependencies, + packageOptionalDependencies: packageDependencies.optionalDependencies, bundledManifest: params.bundledManifest, bundledManifestPath: params.bundledManifestPath, }); @@ -518,6 +530,7 @@ function discoverBundleInRoot(params: { origin: PluginOrigin; ownershipUid?: number | null; workspaceDir?: string; + manifest?: PackageManifest | null; candidates: PluginCandidate[]; diagnostics: PluginDiagnostic[]; seen: Set; @@ -554,6 +567,8 @@ function discoverBundleInRoot(params: { bundleFormat, ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, + manifest: params.manifest, + packageDir: params.rootDir, realpathCache: params.realpathCache, }); return "added"; @@ -683,6 +698,7 @@ function discoverInDirectory(params: { origin: params.origin, ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, + manifest, candidates: params.candidates, diagnostics: params.diagnostics, seen: params.seen, @@ -882,6 +898,7 @@ function discoverFromPath(params: { origin: params.origin, ownershipUid: params.ownershipUid, workspaceDir: params.workspaceDir, + manifest, candidates: params.candidates, diagnostics: params.diagnostics, seen: params.seen, diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts index cc0162124f8..1aec8245b56 100644 --- a/src/plugins/manifest-registry-installed.ts +++ b/src/plugins/manifest-registry-installed.ts @@ -14,6 +14,10 @@ import { type PackageManifest, } from "./manifest.js"; import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js"; +import { + normalizePluginDependencySpecs, + type PluginDependencySpecMap, +} from "./status-dependencies.js"; function resolvePackageJsonPath(record: InstalledPluginIndexRecord): string | undefined { if (!record.packageJson?.path) { @@ -101,9 +105,11 @@ function resolveFallbackPluginSource(record: InstalledPluginIndexRecord): string return path.join(rootDir, DEFAULT_PLUGIN_ENTRY_CANDIDATES[0]); } -function resolveInstalledPackageManifest( - record: InstalledPluginIndexRecord, -): OpenClawPackageManifest | undefined { +function resolveInstalledPackageMetadata(record: InstalledPluginIndexRecord): { + packageManifest?: OpenClawPackageManifest; + packageDependencies?: PluginDependencySpecMap; + packageOptionalDependencies?: PluginDependencySpecMap; +} { const fallbackPackageManifest = record.packageChannel ? { channel: record.packageChannel, @@ -114,17 +120,25 @@ function resolveInstalledPackageManifest( ? path.resolve(rootDir, record.packageJson.path) : undefined; if (!packageJsonPath) { - return fallbackPackageManifest; + return fallbackPackageManifest ? { packageManifest: fallbackPackageManifest } : {}; } const relative = path.relative(rootDir, packageJsonPath); if (relative.startsWith("..") || path.isAbsolute(relative)) { - return fallbackPackageManifest; + return fallbackPackageManifest ? { packageManifest: fallbackPackageManifest } : {}; } try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as PackageManifest; const packageManifest = getPackageManifestMetadata(packageJson); + const dependencies = normalizePluginDependencySpecs({ + dependencies: packageJson.dependencies, + optionalDependencies: packageJson.optionalDependencies, + }); if (!packageManifest) { - return fallbackPackageManifest; + return { + ...(fallbackPackageManifest ? { packageManifest: fallbackPackageManifest } : {}), + packageDependencies: dependencies.dependencies, + packageOptionalDependencies: dependencies.optionalDependencies, + }; } const channel = record.packageChannel || packageManifest.channel @@ -134,17 +148,21 @@ function resolveInstalledPackageManifest( } : undefined; return { - ...packageManifest, - ...(channel ? { channel } : {}), + packageManifest: { + ...packageManifest, + ...(channel ? { channel } : {}), + }, + packageDependencies: dependencies.dependencies, + packageOptionalDependencies: dependencies.optionalDependencies, }; } catch { - return fallbackPackageManifest; + return fallbackPackageManifest ? { packageManifest: fallbackPackageManifest } : {}; } } function toPluginCandidate(record: InstalledPluginIndexRecord): PluginCandidate { const rootDir = resolveInstalledPluginRootDir(record); - const packageManifest = resolveInstalledPackageManifest(record); + const packageMetadata = resolveInstalledPackageMetadata(record); return { idHint: record.pluginId, source: record.source ?? resolveFallbackPluginSource(record), @@ -155,7 +173,15 @@ function toPluginCandidate(record: InstalledPluginIndexRecord): PluginCandidate ...(record.bundleFormat ? { bundleFormat: record.bundleFormat } : {}), ...(record.packageName ? { packageName: record.packageName } : {}), ...(record.packageVersion ? { packageVersion: record.packageVersion } : {}), - ...(packageManifest ? { packageManifest } : {}), + ...(packageMetadata.packageManifest + ? { packageManifest: packageMetadata.packageManifest } + : {}), + ...(packageMetadata.packageDependencies + ? { packageDependencies: packageMetadata.packageDependencies } + : {}), + ...(packageMetadata.packageOptionalDependencies + ? { packageOptionalDependencies: packageMetadata.packageOptionalDependencies } + : {}), packageDir: rootDir, }; } diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 9f69c9507af..e2377d08d02 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -46,6 +46,7 @@ import { checkMinHostVersion } from "./min-host-version.js"; import { isPathInside, safeRealpathSync } from "./path-safety.js"; import type { PluginKind } from "./plugin-kind.types.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; +import type { PluginDependencySpecMap } from "./status-dependencies.js"; /** * Resolve a plugin source path, falling back from .ts to .js when the @@ -130,6 +131,8 @@ export type PluginManifestRecord = { activation?: PluginManifestActivation; setup?: PluginManifestSetup; packageManifest?: OpenClawPackageManifest; + packageDependencies?: PluginDependencySpecMap; + packageOptionalDependencies?: PluginDependencySpecMap; packageChannel?: PluginPackageChannel; packageInstall?: PluginPackageInstall; qaRunners?: PluginManifestQaRunner[]; @@ -316,6 +319,8 @@ function buildRecord(params: { activation: params.manifest.activation, setup: params.manifest.setup, packageManifest: params.candidate.packageManifest, + packageDependencies: params.candidate.packageDependencies, + packageOptionalDependencies: params.candidate.packageOptionalDependencies, packageChannel: params.candidate.packageManifest?.channel, packageInstall: params.candidate.packageManifest?.install, qaRunners: params.manifest.qaRunners, @@ -385,6 +390,8 @@ function buildBundleRecord(params: { packageVersion: params.candidate.packageVersion, packageDescription: params.candidate.packageDescription, packageManifest: params.candidate.packageManifest, + packageDependencies: params.candidate.packageDependencies, + packageOptionalDependencies: params.candidate.packageOptionalDependencies, packageChannel: params.candidate.packageManifest?.channel, packageInstall: params.candidate.packageManifest?.install, format: "bundle", diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index d3fbef5c223..ffa227113a5 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -1737,6 +1737,8 @@ export type PackageManifest = { name?: string; version?: string; description?: string; + dependencies?: Record; + optionalDependencies?: Record; } & Partial>; export function getPackageManifestMetadata( diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index f29dcefc2a7..2c6a68b344f 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -30,6 +30,7 @@ import type { PluginManifestContracts } from "./manifest.js"; import type { MemoryEmbeddingProviderAdapter } from "./memory-embedding-providers.js"; import type { PluginKind } from "./plugin-kind.types.js"; import type { PluginRuntime } from "./runtime/types.js"; +import type { PluginDependencyStatus } from "./status-dependencies.js"; import type { CliBackendPlugin, ImageGenerationProviderPlugin, @@ -377,6 +378,7 @@ export type PluginRecord = { configJsonSchema?: JsonSchemaObject; contracts?: PluginManifestContracts; memorySlotSelected?: boolean; + dependencyStatus?: PluginDependencyStatus; }; export type PluginRegistry = { diff --git a/src/plugins/status-dependencies.ts b/src/plugins/status-dependencies.ts new file mode 100644 index 00000000000..5062c944286 --- /dev/null +++ b/src/plugins/status-dependencies.ts @@ -0,0 +1,135 @@ +import fs from "node:fs"; +import path from "node:path"; + +export type PluginDependencySpecMap = Record; + +export type PluginDependencyEntry = { + name: string; + spec: string; + installed: boolean; + optional: boolean; + resolvedPath?: string; +}; + +export type PluginDependencyStatus = { + hasDependencies: boolean; + installed: boolean; + requiredInstalled: boolean; + optionalInstalled: boolean; + missing: string[]; + missingOptional: string[]; + dependencies: PluginDependencyEntry[]; + optionalDependencies: PluginDependencyEntry[]; +}; + +function normalizeDependencyMap(raw: unknown): PluginDependencySpecMap { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return {}; + } + const normalized: PluginDependencySpecMap = {}; + for (const [name, spec] of Object.entries(raw)) { + const normalizedName = name.trim(); + if (!normalizedName || typeof spec !== "string" || !spec.trim()) { + continue; + } + normalized[normalizedName] = spec.trim(); + } + return normalized; +} + +export function normalizePluginDependencySpecs(params: { + dependencies?: unknown; + optionalDependencies?: unknown; +}): { + dependencies: PluginDependencySpecMap; + optionalDependencies: PluginDependencySpecMap; +} { + return { + dependencies: normalizeDependencyMap(params.dependencies), + optionalDependencies: normalizeDependencyMap(params.optionalDependencies), + }; +} + +function dependencyPathSegments(name: string): string[] | null { + const segments = name.split("/"); + if (segments.length === 1 && segments[0]) { + return [segments[0]]; + } + if (segments.length === 2 && segments[0]?.startsWith("@") && segments[1]) { + return segments; + } + return null; +} + +function findDependencyPackageDir(params: { fromDir: string; name: string }): string | undefined { + const segments = dependencyPathSegments(params.name); + if (!segments) { + return undefined; + } + let current = path.resolve(params.fromDir); + while (true) { + const candidate = path.join(current, "node_modules", ...segments); + if (fs.existsSync(candidate)) { + return candidate; + } + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +} + +function buildDependencyEntries(params: { + rootDir: string | undefined; + dependencies: PluginDependencySpecMap; + optional: boolean; +}): PluginDependencyEntry[] { + return Object.entries(params.dependencies) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([name, spec]) => { + const resolvedPath = params.rootDir + ? findDependencyPackageDir({ fromDir: params.rootDir, name }) + : undefined; + return { + name, + spec, + installed: resolvedPath !== undefined, + optional: params.optional, + ...(resolvedPath ? { resolvedPath } : {}), + }; + }); +} + +export function buildPluginDependencyStatus(params: { + rootDir?: string; + dependencies?: PluginDependencySpecMap; + optionalDependencies?: PluginDependencySpecMap; +}): PluginDependencyStatus { + const dependencies = buildDependencyEntries({ + rootDir: params.rootDir, + dependencies: params.dependencies ?? {}, + optional: false, + }); + const optionalDependencies = buildDependencyEntries({ + rootDir: params.rootDir, + dependencies: params.optionalDependencies ?? {}, + optional: true, + }); + const missing = dependencies.filter((entry) => !entry.installed).map((entry) => entry.name); + const missingOptional = optionalDependencies + .filter((entry) => !entry.installed) + .map((entry) => entry.name); + const requiredInstalled = missing.length === 0; + const optionalInstalled = missingOptional.length === 0; + return { + hasDependencies: dependencies.length > 0 || optionalDependencies.length > 0, + installed: requiredInstalled, + requiredInstalled, + optionalInstalled, + missing, + missingOptional, + dependencies, + optionalDependencies, + }; +} diff --git a/src/plugins/status.registry-snapshot.test.ts b/src/plugins/status.registry-snapshot.test.ts index 91661a1acc7..db8cb957bd7 100644 --- a/src/plugins/status.registry-snapshot.test.ts +++ b/src/plugins/status.registry-snapshot.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { refreshPluginRegistry } from "./plugin-registry.js"; import { buildPluginRegistrySnapshotReport, buildPluginSnapshotReport } from "./status.js"; @@ -73,6 +74,69 @@ describe("buildPluginRegistrySnapshotReport", () => { expect(isColdPluginRuntimeLoaded(fixture)).toBe(false); }); + it("reports package dependency install state without importing plugin runtime", () => { + const rootDir = makeTempDir(); + const fixture = createColdPluginFixture({ + rootDir, + pluginId: "dependency-demo", + packageJson: { + dependencies: { + "missing-required": "1.0.0", + "present-required": "1.0.0", + }, + optionalDependencies: { + "missing-optional": "1.0.0", + }, + }, + manifest: { + id: "dependency-demo", + name: "Dependency Demo", + }, + }); + fs.mkdirSync(path.join(rootDir, "node_modules", "present-required"), { recursive: true }); + + const report = buildPluginRegistrySnapshotReport({ + config: { + plugins: { + load: { paths: [fixture.rootDir] }, + }, + }, + }); + + const plugin = report.plugins.find((entry) => entry.id === "dependency-demo"); + expect(plugin?.dependencyStatus).toMatchObject({ + hasDependencies: true, + installed: false, + requiredInstalled: false, + optionalInstalled: false, + missing: ["missing-required"], + missingOptional: ["missing-optional"], + dependencies: [ + { + name: "missing-required", + spec: "1.0.0", + installed: false, + optional: false, + }, + { + name: "present-required", + spec: "1.0.0", + installed: true, + optional: false, + }, + ], + optionalDependencies: [ + { + name: "missing-optional", + spec: "1.0.0", + installed: false, + optional: true, + }, + ], + }); + expect(isColdPluginRuntimeLoaded(fixture)).toBe(false); + }); + it("replays persisted list metadata without importing plugin runtime", async () => { const fixture = createColdPluginFixture({ rootDir: makeTempDir(), diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 1bc3762a5ec..9b6522325f5 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -37,6 +37,7 @@ import { resolvePluginRuntimeLoadContext, } from "./runtime/load-context.js"; import { loadPluginMetadataRegistrySnapshot } from "./runtime/metadata-registry-loader.js"; +import { buildPluginDependencyStatus } from "./status-dependencies.js"; import type { PluginHookName, PluginLogger } from "./types.js"; export type PluginStatusReport = PluginRegistry & { @@ -212,6 +213,11 @@ function buildPluginRecordFromInstalledIndex( hookCount: 0, configSchema: false, contracts: {}, + dependencyStatus: buildPluginDependencyStatus({ + rootDir: plugin.rootDir, + dependencies: manifest?.packageDependencies, + optionalDependencies: manifest?.packageOptionalDependencies, + }), }; } @@ -363,6 +369,14 @@ function buildPluginReport( Object.assign({}, plugin, { imported: plugin.format !== `bundle` && importedPluginIds.has(plugin.id), version: resolveReportedPluginVersion(plugin, params?.env), + dependencyStatus: + plugin.dependencyStatus ?? + buildPluginDependencyStatus({ + rootDir: plugin.rootDir, + dependencies: metadataSnapshot?.byPluginId.get(plugin.id)?.packageDependencies, + optionalDependencies: metadataSnapshot?.byPluginId.get(plugin.id) + ?.packageOptionalDependencies, + }), }), ), }; diff --git a/src/plugins/test-helpers/cold-plugin-fixtures.ts b/src/plugins/test-helpers/cold-plugin-fixtures.ts index 23c3af2ef4d..99dfd776b72 100644 --- a/src/plugins/test-helpers/cold-plugin-fixtures.ts +++ b/src/plugins/test-helpers/cold-plugin-fixtures.ts @@ -17,6 +17,7 @@ type ColdPluginFixtureOptions = { pluginId?: string; packageName?: string; packageVersion?: string; + packageJson?: Record; providerId?: string; channelId?: string; authChoiceId?: string; @@ -37,6 +38,7 @@ export function createColdPluginFixture(options: ColdPluginFixtureOptions): Cold { name: options.packageName ?? "@example/openclaw-cold-control-plane", version: options.packageVersion ?? "1.0.0", + ...options.packageJson, openclaw: { extensions: ["./index.cjs"] }, }, null,