feat(cli): streamline plugin root checks
Adds plugin-root init, explicit runtime/mock SDK flags, config-driven runtime capture, and npx-oriented docs/CI examples.
This commit is contained in:
parent
a1a5bcac20
commit
0fc9697a14
131
README.md
131
README.md
@ -1,36 +1,46 @@
|
||||
<img src="docs/plugin-inspector-banner.jpg" alt="openclaw plugin inspector banner"/>
|
||||
|
||||
# 💊 OpenClaw Plugin Inspector
|
||||
# OpenClaw Plugin Inspector
|
||||
|
||||
`plugin-inspector` is the reusable OpenClaw plugin compatibility inspector. It
|
||||
wraps the static inspection, registration capture, and report model prototyped
|
||||
in crabpot into an npm-publishable package.
|
||||
`plugin-inspector` is the offline compatibility check for OpenClaw plugins. Run
|
||||
it from a plugin root to inspect package metadata, `openclaw.plugin.json`, SDK
|
||||
imports, `api.on(...)`, `api.register*`, and optional runtime registration
|
||||
capture.
|
||||
|
||||
## Install
|
||||
## Quick Start
|
||||
|
||||
Install it as a dev dependency in a plugin repo:
|
||||
From a plugin package directory:
|
||||
|
||||
```bash
|
||||
npx @openclaw/plugin-inspector
|
||||
```
|
||||
|
||||
That runs `check`, writes report artifacts to `reports/`, and exits non-zero
|
||||
when compatibility breakages are found.
|
||||
|
||||
Add a local config and GitHub Actions workflow:
|
||||
|
||||
```bash
|
||||
npx @openclaw/plugin-inspector init --ci
|
||||
```
|
||||
|
||||
Or install it as a dev dependency:
|
||||
|
||||
```bash
|
||||
npm install --save-dev @openclaw/plugin-inspector
|
||||
npx plugin-inspector check
|
||||
```
|
||||
|
||||
Then run it from the plugin root:
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx @openclaw/plugin-inspector check
|
||||
npx @openclaw/plugin-inspector check --plugin-root ./plugins/weather
|
||||
npx @openclaw/plugin-inspector init --ci --package-manager pnpm
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
Run the default plugin-root check from a plugin package directory:
|
||||
|
||||
```bash
|
||||
plugin-inspector check
|
||||
```
|
||||
|
||||
That command reads the current directory as one plugin, inspects package
|
||||
metadata, `openclaw.plugin.json`, source imports, `api.on(...)`,
|
||||
`api.register*`, and writes:
|
||||
`check` reads the current directory as one plugin unless `--plugin-root` is set.
|
||||
It writes:
|
||||
|
||||
- `reports/plugin-inspector-report.json`
|
||||
- `reports/plugin-inspector-report.md`
|
||||
@ -43,8 +53,8 @@ checkout:
|
||||
plugin-inspector check --no-openclaw
|
||||
```
|
||||
|
||||
Use a simple plugin-root config when you want stable fixture metadata or
|
||||
expected seams:
|
||||
Use `plugin-inspector.config.json` when CI needs stable fixture metadata,
|
||||
expected seams, or runtime capture defaults:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -58,6 +68,9 @@ expected seams:
|
||||
"registrations": ["registerTool"]
|
||||
}
|
||||
},
|
||||
"capture": {
|
||||
"mockSdk": true
|
||||
},
|
||||
"openclaw": {
|
||||
"defaultCheckoutPath": "../openclaw"
|
||||
}
|
||||
@ -70,48 +83,51 @@ Then run:
|
||||
plugin-inspector check --config plugin-inspector.config.json
|
||||
```
|
||||
|
||||
Copy-ready examples live in `examples/plugin-inspector.config.json` and
|
||||
`init --ci` writes this shape for you, plus
|
||||
`.github/workflows/plugin-inspector.yml`. Copy-ready examples also live in
|
||||
`examples/plugin-inspector.config.json` and
|
||||
`examples/github-actions-plugin-inspector.yml`.
|
||||
|
||||
Fixture-set configs are still supported for crabpot-style compatibility suites:
|
||||
## Runtime Capture
|
||||
|
||||
Runtime capture imports plugin entrypoints in an isolated subprocess and records
|
||||
the registrations made during `register(api)`. It is opt-in because it executes
|
||||
plugin code:
|
||||
|
||||
```bash
|
||||
plugin-inspector report --config crabpot.config.json --out reports
|
||||
PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 npx @openclaw/plugin-inspector check --runtime --mock-sdk
|
||||
```
|
||||
|
||||
Capture a plugin entrypoint in an explicitly isolated execution lane:
|
||||
By default, runtime capture uses a generated mock for `openclaw/plugin-sdk` and
|
||||
common external packages so plugin code can load in clean CI without OpenClaw
|
||||
installed. Use `--real-sdk` only when the plugin workspace already has real SDK
|
||||
dependencies installed and you intentionally want to test that path.
|
||||
|
||||
Runtime capture writes:
|
||||
|
||||
- `reports/plugin-inspector-runtime-capture.json`
|
||||
- `reports/plugin-inspector-runtime-capture.md`
|
||||
|
||||
You can also capture one entrypoint directly:
|
||||
|
||||
```bash
|
||||
PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture ./dist/index.js --mock-sdk
|
||||
```
|
||||
|
||||
Run the optional runtime capture smoke during `check`:
|
||||
## CI
|
||||
|
||||
```bash
|
||||
PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector check --no-openclaw --capture
|
||||
```
|
||||
|
||||
Runtime capture creates a temporary mock `openclaw/plugin-sdk` package, imports
|
||||
declared OpenClaw package entrypoints, calls their `register(api)` function with
|
||||
the capture API, and writes:
|
||||
|
||||
- `reports/plugin-inspector-runtime-capture.json`
|
||||
- `reports/plugin-inspector-runtime-capture.md`
|
||||
|
||||
### CI
|
||||
|
||||
With a dev dependency:
|
||||
Minimal package scripts:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"plugin:check": "plugin-inspector check --no-openclaw",
|
||||
"plugin:check:runtime": "PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector check --no-openclaw --capture"
|
||||
"plugin:check:runtime": "PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector check --no-openclaw --runtime --mock-sdk"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
GitHub Actions:
|
||||
GitHub Actions without a local dev dependency:
|
||||
|
||||
```yaml
|
||||
name: plugin-inspector
|
||||
@ -131,8 +147,8 @@ jobs:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run plugin:check
|
||||
- run: npm run plugin:check:runtime
|
||||
- run: npx @openclaw/plugin-inspector check --no-openclaw
|
||||
- run: PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 npx @openclaw/plugin-inspector check --no-openclaw --runtime --mock-sdk
|
||||
- uses: actions/upload-artifact@v5
|
||||
if: always()
|
||||
with:
|
||||
@ -140,6 +156,31 @@ jobs:
|
||||
path: reports/plugin-inspector-*
|
||||
```
|
||||
|
||||
## Fixture Suites
|
||||
|
||||
Fixture-set configs are still supported for crabpot-style compatibility suites:
|
||||
|
||||
```bash
|
||||
plugin-inspector report --config crabpot.config.json --out reports
|
||||
```
|
||||
|
||||
Use fixture suites when one repo wants to inspect many plugins. Use plugin-root
|
||||
`check` for normal plugin CI.
|
||||
|
||||
## Mocking Model
|
||||
|
||||
Default inspection is static, offline, and credential-free. Runtime capture is
|
||||
the only mode that imports plugin code.
|
||||
|
||||
When `--mock-sdk` is enabled, the inspector generates temporary modules for
|
||||
`openclaw/plugin-sdk` subpaths and unresolved external packages discovered in
|
||||
the plugin import graph. The mock SDK captures registrations; it does not call
|
||||
network services, launch OpenClaw, run provider SDKs, or emulate service
|
||||
lifecycle side effects.
|
||||
|
||||
Use the mock lane for plugin compatibility CI. Keep live provider/service tests
|
||||
in the plugin repo behind their own credentials and explicit opt-in flags.
|
||||
|
||||
## Scope
|
||||
|
||||
Default inspection is offline and credential-free. It reads manifests, package
|
||||
@ -148,5 +189,5 @@ metadata, and source files, then reports observed `api.on(...)`,
|
||||
OpenClaw target checkout parsing is limited to public compatibility registries,
|
||||
SDK package exports, manifest types, hooks, and captured registrar metadata.
|
||||
|
||||
Cold import capture and synthetic contract probes are explicit opt-in modes.
|
||||
Live lanes will stay credential-gated and must never run in default CI.
|
||||
Cold import capture, synthetic contract probes, and runtime capture are explicit
|
||||
opt-in modes. Live lanes stay credential-gated and must never run in default CI.
|
||||
|
||||
@ -15,8 +15,8 @@ jobs:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run plugin:check
|
||||
- run: npm run plugin:check:runtime
|
||||
- run: npx @openclaw/plugin-inspector check --no-openclaw
|
||||
- run: PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 npx @openclaw/plugin-inspector check --no-openclaw --runtime --mock-sdk
|
||||
- uses: actions/upload-artifact@v5
|
||||
if: always()
|
||||
with:
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
"registrations": ["registerTool"]
|
||||
}
|
||||
},
|
||||
"capture": {
|
||||
"mockSdk": true
|
||||
},
|
||||
"openclaw": {
|
||||
"defaultCheckoutPath": "../openclaw"
|
||||
}
|
||||
|
||||
@ -116,12 +116,21 @@ export {
|
||||
defaultPluginRootConfigFiles,
|
||||
fixtureCheckoutPath,
|
||||
fixtureSourceRoot,
|
||||
inferPluginSeams,
|
||||
loadInspectorConfig,
|
||||
loadPluginRootConfig,
|
||||
normalizeInspectorConfig,
|
||||
normalizePluginRootConfig,
|
||||
packageId,
|
||||
validateInspectorConfig,
|
||||
} from "./config.js";
|
||||
export {
|
||||
buildPluginInspectorConfig,
|
||||
defaultInitConfigPath,
|
||||
defaultInitWorkflowPath,
|
||||
renderGithubActionsWorkflow,
|
||||
writePluginInspectorInit,
|
||||
} from "./init.js";
|
||||
export {
|
||||
buildPlatformProbes,
|
||||
defaultPlatformTargets,
|
||||
|
||||
22
src/api.js
22
src/api.js
@ -1,6 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { createCaptureApi } from "./capture-api.js";
|
||||
import { loadInspectorConfig, loadPluginRootConfig } from "./config.js";
|
||||
import { writePluginInspectorInit } from "./init.js";
|
||||
import { captureEntrypoint } from "./inspector.js";
|
||||
import { renderTextSummary, writeCompatibilityReport } from "./report.js";
|
||||
import { buildRuntimeCaptureReport, writeRuntimeCaptureReport } from "./runtime-capture-report.js";
|
||||
@ -45,24 +46,25 @@ export async function writePluginReports(report, options = {}) {
|
||||
|
||||
export async function runPluginCheck(options = {}) {
|
||||
const outDir = options.outDir ?? "reports";
|
||||
const report = await inspectPluginRoot(options);
|
||||
const paths = await writePluginReports(report, { ...options, outDir });
|
||||
const config = await loadPluginConfig(options);
|
||||
const report = await inspectPluginRoot({ ...options, config });
|
||||
const paths = await writePluginReports(report, { ...options, pluginRoot: config.rootDir, outDir });
|
||||
const result = { report, paths };
|
||||
const capture = options.capture ?? config.capture?.runtime ?? false;
|
||||
const mockSdk = options.mockSdk ?? config.capture?.mockSdk ?? true;
|
||||
|
||||
if (options.capture === true) {
|
||||
if (capture === true) {
|
||||
if (process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED !== "1") {
|
||||
throw new Error("runtime capture imports plugin code; rerun with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 in an isolated workspace");
|
||||
}
|
||||
const config = await loadPluginConfig(options);
|
||||
const runtimeCapture = await buildRuntimeCaptureReport({
|
||||
mockSdk: options.mockSdk ?? true,
|
||||
mockSdk,
|
||||
report,
|
||||
rootDir: config.rootDir,
|
||||
});
|
||||
const outputRoot = options.cwd ?? options.pluginRoot ?? process.cwd();
|
||||
const runtimeCapturePaths = await writeRuntimeCaptureReport(runtimeCapture, {
|
||||
jsonPath: path.resolve(outputRoot, outDir, "plugin-inspector-runtime-capture.json"),
|
||||
markdownPath: path.resolve(outputRoot, outDir, "plugin-inspector-runtime-capture.md"),
|
||||
jsonPath: path.resolve(config.rootDir, outDir, "plugin-inspector-runtime-capture.json"),
|
||||
markdownPath: path.resolve(config.rootDir, outDir, "plugin-inspector-runtime-capture.md"),
|
||||
});
|
||||
result.runtimeCapture = runtimeCapture;
|
||||
result.runtimeCapturePaths = runtimeCapturePaths;
|
||||
@ -82,4 +84,8 @@ export async function capturePluginEntrypoint(entrypoint, options = {}) {
|
||||
return captureEntrypoint(entrypoint, options);
|
||||
}
|
||||
|
||||
export async function setupPluginInspector(options = {}) {
|
||||
return writePluginInspectorInit(options);
|
||||
}
|
||||
|
||||
export { createCaptureApi, renderTextSummary };
|
||||
|
||||
69
src/cli.js
69
src/cli.js
@ -7,6 +7,7 @@ import {
|
||||
captureEntrypoint,
|
||||
inspectFixtureSet,
|
||||
loadInspectorConfig,
|
||||
writePluginInspectorInit,
|
||||
writeArtifacts,
|
||||
writeReport,
|
||||
} from "./advanced.js";
|
||||
@ -20,6 +21,8 @@ try {
|
||||
printHelp();
|
||||
} else if (command === "check") {
|
||||
await runCheck(commandArgs);
|
||||
} else if (command === "init") {
|
||||
await runInit(commandArgs);
|
||||
} else if (command === "inspect" || command === "report" || command === "ci") {
|
||||
await runReport(command, commandArgs);
|
||||
} else if (command === "capture") {
|
||||
@ -34,11 +37,13 @@ try {
|
||||
|
||||
async function runCheck(commandArgs) {
|
||||
const configPath = readFlag(commandArgs, "--config");
|
||||
const pluginRoot = readFlag(commandArgs, "--plugin-root") ?? readFlag(commandArgs, "--root");
|
||||
const outDir = readFlag(commandArgs, "--out") ?? "reports";
|
||||
const openclawPath = commandArgs.includes("--no-openclaw") ? false : readFlag(commandArgs, "--openclaw");
|
||||
const json = commandArgs.includes("--json");
|
||||
const capture = commandArgs.includes("--capture");
|
||||
const { report } = await runPluginCheck({ configPath, outDir, openclawPath, capture });
|
||||
const capture = readRuntimeFlag(commandArgs);
|
||||
const mockSdk = readMockSdkFlag(commandArgs);
|
||||
const { report } = await runPluginCheck({ configPath, pluginRoot, outDir, openclawPath, capture, mockSdk });
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
@ -51,6 +56,25 @@ async function runCheck(commandArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
async function runInit(commandArgs) {
|
||||
const pluginRoot = readFlag(commandArgs, "--plugin-root") ?? readFlag(commandArgs, "--root");
|
||||
const configPath = readFlag(commandArgs, "--config") ?? undefined;
|
||||
const workflowPath = readFlag(commandArgs, "--workflow") ?? undefined;
|
||||
const packageManager = readFlag(commandArgs, "--package-manager") ?? "npm";
|
||||
const result = await writePluginInspectorInit({
|
||||
pluginRoot,
|
||||
configPath,
|
||||
workflowPath,
|
||||
packageManager,
|
||||
ci: commandArgs.includes("--ci"),
|
||||
force: commandArgs.includes("--force"),
|
||||
});
|
||||
|
||||
for (const filePath of result.written) {
|
||||
console.log(`wrote ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runReport(command, commandArgs) {
|
||||
const configPath = readFlag(commandArgs, "--config");
|
||||
const outDir = readFlag(commandArgs, "--out") ?? "reports";
|
||||
@ -75,7 +99,7 @@ async function runCapture(commandArgs) {
|
||||
const entrypoint = commandArgs.find((arg) => !arg.startsWith("-"));
|
||||
const outputPath = readFlag(commandArgs, "--output");
|
||||
const pluginRoot = readFlag(commandArgs, "--plugin-root");
|
||||
const mockSdk = commandArgs.includes("--mock-sdk");
|
||||
const mockSdk = readMockSdkFlag(commandArgs) ?? commandArgs.includes("--mock-sdk");
|
||||
if (!entrypoint) {
|
||||
throw new Error("capture requires an entrypoint path");
|
||||
}
|
||||
@ -100,14 +124,49 @@ function readFlag(commandArgs, name) {
|
||||
return commandArgs[index + 1] ?? null;
|
||||
}
|
||||
|
||||
function readRuntimeFlag(commandArgs) {
|
||||
if (commandArgs.includes("--runtime") || commandArgs.includes("--capture")) {
|
||||
return true;
|
||||
}
|
||||
if (commandArgs.includes("--no-runtime") || commandArgs.includes("--no-capture")) {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readMockSdkFlag(commandArgs) {
|
||||
const sdk = readFlag(commandArgs, "--sdk");
|
||||
if (sdk === "mock") {
|
||||
return true;
|
||||
}
|
||||
if (sdk === "real") {
|
||||
return false;
|
||||
}
|
||||
if (sdk && !["mock", "real"].includes(sdk)) {
|
||||
throw new Error("--sdk must be mock or real");
|
||||
}
|
||||
if (commandArgs.includes("--mock-sdk")) {
|
||||
return true;
|
||||
}
|
||||
if (commandArgs.includes("--real-sdk")) {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`plugin-inspector
|
||||
|
||||
Usage:
|
||||
plugin-inspector check [--config <path>] [--out <dir>] [--openclaw <path>] [--no-openclaw] [--capture] [--json]
|
||||
plugin-inspector
|
||||
plugin-inspector check [--plugin-root <path>] [--config <path>] [--out <dir>] [--openclaw <path>] [--no-openclaw] [--runtime] [--mock-sdk|--real-sdk] [--json]
|
||||
plugin-inspector init [--plugin-root <path>] [--config <path>] [--ci] [--package-manager npm|pnpm|yarn|bun] [--force]
|
||||
plugin-inspector report --config <path> [--out <dir>] [--check] [--json]
|
||||
plugin-inspector inspect --config <path> [--out <dir>] [--check] [--json]
|
||||
plugin-inspector ci --config <path> [--out <dir>]
|
||||
PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture <entrypoint> [--mock-sdk] [--plugin-root <path>] [--output <path>]
|
||||
PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture <entrypoint> [--mock-sdk|--real-sdk] [--plugin-root <path>] [--output <path>]
|
||||
|
||||
Default check runs from the current plugin root and writes reports/ unless --out is set.
|
||||
Runtime capture is opt-in because it imports plugin code; use --runtime with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1.
|
||||
`);
|
||||
}
|
||||
|
||||
@ -52,6 +52,19 @@ export function validateInspectorConfig(config) {
|
||||
errors.push("config.fixtures must be a non-empty array");
|
||||
}
|
||||
|
||||
if (config.capture !== undefined) {
|
||||
if (!config.capture || typeof config.capture !== "object" || Array.isArray(config.capture)) {
|
||||
errors.push("config.capture must be an object when present");
|
||||
} else {
|
||||
if (config.capture.runtime !== undefined && typeof config.capture.runtime !== "boolean") {
|
||||
errors.push("config.capture.runtime must be a boolean when present");
|
||||
}
|
||||
if (config.capture.mockSdk !== undefined && typeof config.capture.mockSdk !== "boolean") {
|
||||
errors.push("config.capture.mockSdk must be a boolean when present");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = new Set();
|
||||
const paths = new Set();
|
||||
for (const fixture of config.fixtures ?? []) {
|
||||
@ -134,6 +147,7 @@ export async function normalizePluginRootConfig(config, options = {}) {
|
||||
return {
|
||||
version: 1,
|
||||
submoduleRoot: ".",
|
||||
capture: config.capture,
|
||||
openclaw: config.openclaw,
|
||||
fixtures: [fixture],
|
||||
};
|
||||
@ -157,7 +171,7 @@ async function readJsonIfExists(filePath) {
|
||||
return JSON.parse(await readFile(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function packageId(packageName) {
|
||||
export function packageId(packageName) {
|
||||
if (!packageName) {
|
||||
return null;
|
||||
}
|
||||
@ -170,7 +184,7 @@ function packageId(packageName) {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function inferPluginSeams(pluginManifest, packageJson) {
|
||||
export function inferPluginSeams(pluginManifest, packageJson) {
|
||||
const contracts = Object.keys(pluginManifest?.contracts ?? {});
|
||||
if (contracts.includes("tools")) {
|
||||
return ["dynamic-tool"];
|
||||
|
||||
@ -6,5 +6,6 @@ export {
|
||||
loadPluginConfig,
|
||||
renderTextSummary,
|
||||
runPluginCheck,
|
||||
setupPluginInspector,
|
||||
writePluginReports,
|
||||
} from "./api.js";
|
||||
|
||||
150
src/init.js
Normal file
150
src/init.js
Normal file
@ -0,0 +1,150 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { inferPluginSeams, packageId } from "./config.js";
|
||||
|
||||
export const defaultInitConfigPath = "plugin-inspector.config.json";
|
||||
export const defaultInitWorkflowPath = ".github/workflows/plugin-inspector.yml";
|
||||
|
||||
export async function writePluginInspectorInit(options = {}) {
|
||||
const pluginRoot = path.resolve(options.pluginRoot ?? options.cwd ?? process.cwd());
|
||||
const configPath = path.resolve(pluginRoot, options.configPath ?? defaultInitConfigPath);
|
||||
const written = [];
|
||||
|
||||
if (existsSync(configPath) && options.force !== true) {
|
||||
throw new Error(`${path.relative(pluginRoot, configPath)} already exists; pass --force to overwrite it`);
|
||||
}
|
||||
|
||||
const config = await buildPluginInspectorConfig({ pluginRoot });
|
||||
await mkdir(path.dirname(configPath), { recursive: true });
|
||||
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
written.push(configPath);
|
||||
|
||||
if (options.ci === true) {
|
||||
const workflowPath = path.resolve(pluginRoot, options.workflowPath ?? defaultInitWorkflowPath);
|
||||
if (existsSync(workflowPath) && options.force !== true) {
|
||||
throw new Error(`${path.relative(pluginRoot, workflowPath)} already exists; pass --force to overwrite it`);
|
||||
}
|
||||
await mkdir(path.dirname(workflowPath), { recursive: true });
|
||||
await writeFile(workflowPath, renderGithubActionsWorkflow({ packageManager: options.packageManager }), "utf8");
|
||||
written.push(workflowPath);
|
||||
}
|
||||
|
||||
return { pluginRoot, configPath, written };
|
||||
}
|
||||
|
||||
export async function buildPluginInspectorConfig(options = {}) {
|
||||
const pluginRoot = path.resolve(options.pluginRoot ?? options.cwd ?? process.cwd());
|
||||
const packageJson = await readJsonIfExists(path.join(pluginRoot, "package.json"));
|
||||
const pluginManifest = await readJsonIfExists(path.join(pluginRoot, "openclaw.plugin.json"));
|
||||
const sourceRoot = inferSourceRoot(packageJson);
|
||||
|
||||
const plugin = {
|
||||
id: pluginManifest?.id ?? packageId(packageJson?.name) ?? "plugin",
|
||||
priority: "high",
|
||||
seams: inferPluginSeams(pluginManifest, packageJson),
|
||||
};
|
||||
|
||||
if (sourceRoot !== ".") {
|
||||
plugin.sourceRoot = sourceRoot;
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
plugin,
|
||||
capture: {
|
||||
mockSdk: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renderGithubActionsWorkflow(options = {}) {
|
||||
const packageManager = normalizePackageManager(options.packageManager);
|
||||
const setup = packageManagerSetup(packageManager);
|
||||
|
||||
return `name: plugin-inspector
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: ${setup.cache}
|
||||
${setup.corepack ? " - run: corepack enable\n" : ""} - run: ${setup.install}
|
||||
- run: ${setup.exec} @openclaw/plugin-inspector check --no-openclaw
|
||||
- run: PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 ${setup.exec} @openclaw/plugin-inspector check --no-openclaw --runtime --mock-sdk
|
||||
- uses: actions/upload-artifact@v5
|
||||
if: always()
|
||||
with:
|
||||
name: plugin-inspector-reports
|
||||
path: reports/plugin-inspector-*
|
||||
`;
|
||||
}
|
||||
|
||||
function inferSourceRoot(packageJson) {
|
||||
const entrypoints = [
|
||||
packageJson?.openclaw?.entrypoint,
|
||||
...(packageJson?.openclaw?.extensions ?? []),
|
||||
...(packageJson?.openclaw?.runtimeExtensions ?? []),
|
||||
].filter((value) => typeof value === "string");
|
||||
const entrypoint = entrypoints[0] ?? packageJson?.exports?.["."] ?? packageJson?.main ?? "src/index.js";
|
||||
if (typeof entrypoint === "string" && entrypoint.startsWith("src/")) {
|
||||
return "src";
|
||||
}
|
||||
return ".";
|
||||
}
|
||||
|
||||
async function readJsonIfExists(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(await readFile(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function normalizePackageManager(packageManager = "npm") {
|
||||
if (["npm", "pnpm", "yarn", "bun"].includes(packageManager)) {
|
||||
return packageManager;
|
||||
}
|
||||
throw new Error("--package-manager must be npm, pnpm, yarn, or bun");
|
||||
}
|
||||
|
||||
function packageManagerSetup(packageManager) {
|
||||
if (packageManager === "pnpm") {
|
||||
return {
|
||||
cache: "pnpm",
|
||||
corepack: true,
|
||||
install: "pnpm install --frozen-lockfile",
|
||||
exec: "pnpm dlx",
|
||||
};
|
||||
}
|
||||
if (packageManager === "yarn") {
|
||||
return {
|
||||
cache: "yarn",
|
||||
corepack: true,
|
||||
install: "yarn install --immutable",
|
||||
exec: "yarn dlx",
|
||||
};
|
||||
}
|
||||
if (packageManager === "bun") {
|
||||
return {
|
||||
cache: "npm",
|
||||
corepack: false,
|
||||
install: "bun install --frozen-lockfile",
|
||||
exec: "bunx",
|
||||
};
|
||||
}
|
||||
return {
|
||||
cache: "npm",
|
||||
corepack: false,
|
||||
install: "npm ci",
|
||||
exec: "npx",
|
||||
};
|
||||
}
|
||||
@ -9,6 +9,7 @@ import {
|
||||
inspectPluginRoot,
|
||||
loadPluginConfig,
|
||||
runPluginCheck,
|
||||
setupPluginInspector,
|
||||
} from "../src/index.js";
|
||||
|
||||
test("public API runs the plugin-root check and writes reports", async () => {
|
||||
@ -61,6 +62,41 @@ test("public API exposes capture through an explicit entrypoint helper", async (
|
||||
);
|
||||
});
|
||||
|
||||
test("public API can initialize plugin inspector files", async () => {
|
||||
const pluginRoot = await createPluginRoot();
|
||||
|
||||
const result = await setupPluginInspector({ pluginRoot, ci: true, packageManager: "npm" });
|
||||
const config = JSON.parse(await readFile(path.join(pluginRoot, "plugin-inspector.config.json"), "utf8"));
|
||||
const workflow = await readFile(path.join(pluginRoot, ".github", "workflows", "plugin-inspector.yml"), "utf8");
|
||||
|
||||
assert.equal(result.written.length, 2);
|
||||
assert.equal(config.plugin.id, "weather");
|
||||
assert.equal(config.capture.mockSdk, true);
|
||||
assert.match(workflow, /npx @openclaw\/plugin-inspector check --no-openclaw/);
|
||||
});
|
||||
|
||||
test("public API honors config-driven runtime capture", async () => {
|
||||
const pluginRoot = await createPluginRoot();
|
||||
await writeFile(
|
||||
path.join(pluginRoot, "plugin-inspector.config.json"),
|
||||
`${JSON.stringify({ version: 1, capture: { runtime: true, mockSdk: true } }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const previous = process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED;
|
||||
process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED = "1";
|
||||
try {
|
||||
const result = await runPluginCheck({ pluginRoot, outDir: "reports", openclawPath: false });
|
||||
assert.equal(result.runtimeCapture.summary.registrationCount, 1);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED;
|
||||
} else {
|
||||
process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function createPluginRoot() {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-api-root-"));
|
||||
await mkdir(path.join(rootDir, "src"), { recursive: true });
|
||||
|
||||
@ -65,3 +65,102 @@ test("check command runs from a plugin root without fixture config", async () =>
|
||||
assert.equal(capture.summary.capturedCount, 1);
|
||||
assert.equal(capture.summary.registrationCount, 1);
|
||||
});
|
||||
|
||||
test("check command can target a plugin root and use runtime aliases", async () => {
|
||||
const rootDir = await createCliPluginRoot("plugin-inspector-cli-target-");
|
||||
const cliPath = path.resolve("src/cli.js");
|
||||
|
||||
await execFileAsync(
|
||||
process.execPath,
|
||||
[cliPath, "--plugin-root", rootDir, "--out", "reports", "--no-openclaw", "--runtime", "--mock-sdk"],
|
||||
{
|
||||
cwd: os.tmpdir(),
|
||||
env: {
|
||||
...process.env,
|
||||
PLUGIN_INSPECTOR_EXECUTE_ISOLATED: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const report = JSON.parse(await readFile(path.join(rootDir, "reports", "plugin-inspector-report.json"), "utf8"));
|
||||
const capture = JSON.parse(
|
||||
await readFile(path.join(rootDir, "reports", "plugin-inspector-runtime-capture.json"), "utf8"),
|
||||
);
|
||||
assert.equal(report.fixtures[0].id, "weather");
|
||||
assert.equal(capture.summary.capturedCount, 1);
|
||||
});
|
||||
|
||||
test("check command can enable runtime capture from plugin config", async () => {
|
||||
const rootDir = await createCliPluginRoot("plugin-inspector-cli-config-runtime-");
|
||||
await writeFile(
|
||||
path.join(rootDir, "plugin-inspector.config.json"),
|
||||
`${JSON.stringify({ version: 1, capture: { runtime: true, mockSdk: true } }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const cliPath = path.resolve("src/cli.js");
|
||||
|
||||
await execFileAsync(process.execPath, [cliPath, "check", "--out", "reports", "--no-openclaw"], {
|
||||
cwd: rootDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PLUGIN_INSPECTOR_EXECUTE_ISOLATED: "1",
|
||||
},
|
||||
});
|
||||
|
||||
const capture = JSON.parse(
|
||||
await readFile(path.join(rootDir, "reports", "plugin-inspector-runtime-capture.json"), "utf8"),
|
||||
);
|
||||
assert.equal(capture.summary.registrationCount, 1);
|
||||
});
|
||||
|
||||
test("init command writes plugin config and CI workflow", async () => {
|
||||
const rootDir = await createCliPluginRoot("plugin-inspector-cli-init-");
|
||||
const cliPath = path.resolve("src/cli.js");
|
||||
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[cliPath, "init", "--plugin-root", rootDir, "--ci", "--package-manager", "pnpm"],
|
||||
);
|
||||
const config = JSON.parse(await readFile(path.join(rootDir, "plugin-inspector.config.json"), "utf8"));
|
||||
const workflow = await readFile(path.join(rootDir, ".github", "workflows", "plugin-inspector.yml"), "utf8");
|
||||
|
||||
assert.match(stdout, /plugin-inspector\.config\.json/);
|
||||
assert.equal(config.plugin.id, "weather");
|
||||
assert.equal(config.plugin.sourceRoot, "src");
|
||||
assert.equal(config.capture.mockSdk, true);
|
||||
assert.match(workflow, /pnpm dlx @openclaw\/plugin-inspector check --no-openclaw/);
|
||||
assert.match(workflow, /--runtime --mock-sdk/);
|
||||
});
|
||||
|
||||
async function createCliPluginRoot(prefix) {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
await mkdir(path.join(rootDir, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(rootDir, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: "@example/openclaw-weather",
|
||||
version: "1.0.0",
|
||||
type: "module",
|
||||
openclaw: {
|
||||
extensions: ["src/index.js"],
|
||||
compat: { pluginApi: "^1.0.0" },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify({ id: "weather", name: "Weather", version: "1.0.0", contracts: { tools: {} } }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(rootDir, "src", "index.js"),
|
||||
'import { definePluginEntry } from "openclaw/plugin-sdk";\nexport default definePluginEntry((api) => api.registerTool({ name: "weather" }));\n',
|
||||
"utf8",
|
||||
);
|
||||
return rootDir;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user