feat: add plugin-root check command
This commit is contained in:
parent
4630939c5c
commit
2f78758348
101
README.md
101
README.md
@ -12,34 +12,119 @@ During development, use a local checkout or packed tarball:
|
||||
|
||||
```bash
|
||||
npm install --save-dev ../plugin-inspector
|
||||
npx plugin-inspector check --no-openclaw
|
||||
```
|
||||
|
||||
Future package name:
|
||||
After the package is published, plugin repos should install it as a dev
|
||||
dependency and run it from the plugin root:
|
||||
|
||||
```bash
|
||||
npm install --save-dev @openclaw/plugin-inspector
|
||||
npx @openclaw/plugin-inspector check
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
Inspect a crabpot-compatible fixture config:
|
||||
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:
|
||||
|
||||
- `reports/plugin-inspector-report.json`
|
||||
- `reports/plugin-inspector-report.md`
|
||||
- `reports/plugin-inspector-issues.md`
|
||||
|
||||
Use `--no-openclaw` when CI should not compare against a local OpenClaw
|
||||
checkout:
|
||||
|
||||
```bash
|
||||
plugin-inspector check --no-openclaw
|
||||
```
|
||||
|
||||
Use a simple plugin-root config when you want stable fixture metadata or
|
||||
expected seams:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"plugin": {
|
||||
"id": "weather",
|
||||
"priority": "high",
|
||||
"seams": ["dynamic-tool"],
|
||||
"sourceRoot": "src",
|
||||
"expect": {
|
||||
"registrations": ["registerTool"]
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"defaultCheckoutPath": "../openclaw"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
plugin-inspector check --config plugin-inspector.config.json
|
||||
```
|
||||
|
||||
Fixture-set configs are still supported for crabpot-style compatibility suites:
|
||||
|
||||
```bash
|
||||
plugin-inspector report --config crabpot.config.json --out reports
|
||||
```
|
||||
|
||||
Fail if expected hooks, registrations, or manifest contracts are missing:
|
||||
|
||||
```bash
|
||||
plugin-inspector ci --config crabpot.config.json --out reports --check
|
||||
```
|
||||
|
||||
Capture a plugin entrypoint in an explicitly isolated execution lane:
|
||||
|
||||
```bash
|
||||
PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture ./dist/index.js
|
||||
```
|
||||
|
||||
### CI
|
||||
|
||||
With a dev dependency:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"plugin:check": "plugin-inspector check --no-openclaw"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
GitHub Actions:
|
||||
|
||||
```yaml
|
||||
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: npm
|
||||
- run: npm ci
|
||||
- run: npm run plugin:check
|
||||
- uses: actions/upload-artifact@v5
|
||||
if: always()
|
||||
with:
|
||||
name: plugin-inspector-reports
|
||||
path: reports/plugin-inspector-*
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
|
||||
35
src/cli.js
35
src/cli.js
@ -1,23 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
import {
|
||||
captureEntrypoint,
|
||||
inspectCompatibilityFixtureSet,
|
||||
inspectFixtureSet,
|
||||
loadInspectorConfig,
|
||||
loadPluginRootConfig,
|
||||
renderTextSummary,
|
||||
writeArtifacts,
|
||||
writeCompatibilityReport,
|
||||
writeReport,
|
||||
} from "./index.js";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
const command = args[0]?.startsWith("-") ? "check" : (args[0] ?? "check");
|
||||
const commandArgs = args[0]?.startsWith("-") ? args : args.slice(1);
|
||||
|
||||
try {
|
||||
if (!command || command === "--help" || command === "-h") {
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
printHelp();
|
||||
} else if (command === "check") {
|
||||
await runCheck(commandArgs);
|
||||
} else if (command === "inspect" || command === "report" || command === "ci") {
|
||||
await runReport(command, args.slice(1));
|
||||
await runReport(command, commandArgs);
|
||||
} else if (command === "capture") {
|
||||
await runCapture(args.slice(1));
|
||||
await runCapture(commandArgs);
|
||||
} else {
|
||||
throw new Error(`unknown command: ${command}`);
|
||||
}
|
||||
@ -26,6 +32,26 @@ try {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function runCheck(commandArgs) {
|
||||
const configPath = readFlag(commandArgs, "--config");
|
||||
const outDir = readFlag(commandArgs, "--out") ?? "reports";
|
||||
const openclawPath = commandArgs.includes("--no-openclaw") ? false : readFlag(commandArgs, "--openclaw");
|
||||
const json = commandArgs.includes("--json");
|
||||
const config = configPath ? await loadInspectorConfig(configPath) : await loadPluginRootConfig();
|
||||
const report = await inspectCompatibilityFixtureSet(config, { openclawPath });
|
||||
await writeCompatibilityReport(report, { outDir });
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
console.log(renderTextSummary(report));
|
||||
}
|
||||
|
||||
if (report.status !== "pass") {
|
||||
throw new Error(`plugin-inspector found ${report.summary.breakageCount} breakages`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runReport(command, commandArgs) {
|
||||
const configPath = readFlag(commandArgs, "--config");
|
||||
const outDir = readFlag(commandArgs, "--out") ?? "reports";
|
||||
@ -77,6 +103,7 @@ function printHelp() {
|
||||
console.log(`plugin-inspector
|
||||
|
||||
Usage:
|
||||
plugin-inspector check [--config <path>] [--out <dir>] [--openclaw <path>] [--no-openclaw] [--json]
|
||||
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>]
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const npmPackagePayloadDir = ".crabpot-package";
|
||||
export const defaultPluginRootConfigFiles = ["plugin-inspector.config.json", ".plugin-inspector.json"];
|
||||
|
||||
export async function loadInspectorConfig(configPath, options = {}) {
|
||||
if (!configPath) {
|
||||
@ -10,9 +12,26 @@ export async function loadInspectorConfig(configPath, options = {}) {
|
||||
const resolvedPath = path.resolve(options.cwd ?? process.cwd(), configPath);
|
||||
const config = JSON.parse(await readFile(resolvedPath, "utf8"));
|
||||
const rootDir = path.resolve(options.cwd ?? process.cwd(), options.rootDir ?? path.dirname(resolvedPath));
|
||||
validateInspectorConfig(config);
|
||||
const normalizedConfig = await normalizeInspectorConfig(config, { rootDir });
|
||||
validateInspectorConfig(normalizedConfig);
|
||||
return {
|
||||
...config,
|
||||
...normalizedConfig,
|
||||
rootDir,
|
||||
configPath: resolvedPath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPluginRootConfig(configPath = null, options = {}) {
|
||||
const rootDir = path.resolve(options.cwd ?? process.cwd());
|
||||
const resolvedPath = configPath ? path.resolve(rootDir, configPath) : findPluginRootConfigPath(rootDir);
|
||||
if (!resolvedPath && !existsSync(path.join(rootDir, "package.json")) && !existsSync(path.join(rootDir, "openclaw.plugin.json"))) {
|
||||
throw new Error("run from a plugin root with package.json/openclaw.plugin.json, or pass --config");
|
||||
}
|
||||
const config = resolvedPath ? JSON.parse(await readFile(resolvedPath, "utf8")) : { version: 1 };
|
||||
const normalizedConfig = await normalizePluginRootConfig(config, { rootDir });
|
||||
validateInspectorConfig(normalizedConfig);
|
||||
return {
|
||||
...normalizedConfig,
|
||||
rootDir,
|
||||
configPath: resolvedPath,
|
||||
};
|
||||
@ -90,3 +109,74 @@ export function fixtureSourceRoot(config, fixture) {
|
||||
}
|
||||
return checkoutPath;
|
||||
}
|
||||
|
||||
export async function normalizePluginRootConfig(config, options = {}) {
|
||||
const rootDir = path.resolve(options.rootDir ?? process.cwd());
|
||||
const plugin = config.plugin ?? {};
|
||||
const packageJson = await readJsonIfExists(path.join(rootDir, "package.json"));
|
||||
const pluginManifest = await readJsonIfExists(path.join(rootDir, "openclaw.plugin.json"));
|
||||
const sourceRoot = plugin.sourceRoot ?? config.sourceRoot ?? ".";
|
||||
const fixture = {
|
||||
id: plugin.id ?? pluginManifest?.id ?? packageId(packageJson?.name) ?? "plugin",
|
||||
name: plugin.name ?? pluginManifest?.name ?? packageJson?.name ?? "Plugin",
|
||||
path: ".",
|
||||
repo: "local",
|
||||
priority: plugin.priority ?? config.priority ?? "high",
|
||||
seams: plugin.seams ?? config.seams ?? inferPluginSeams(pluginManifest, packageJson),
|
||||
why: plugin.why ?? config.why ?? "local OpenClaw plugin root",
|
||||
expect: plugin.expect ?? config.expect,
|
||||
};
|
||||
|
||||
if (sourceRoot !== ".") {
|
||||
fixture.subdir = sourceRoot;
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
submoduleRoot: ".",
|
||||
openclaw: config.openclaw,
|
||||
fixtures: [fixture],
|
||||
};
|
||||
}
|
||||
|
||||
export async function normalizeInspectorConfig(config, options = {}) {
|
||||
if (Array.isArray(config.fixtures)) {
|
||||
return config;
|
||||
}
|
||||
return normalizePluginRootConfig(config, options);
|
||||
}
|
||||
|
||||
function findPluginRootConfigPath(rootDir) {
|
||||
return defaultPluginRootConfigFiles.map((file) => path.join(rootDir, file)).find(existsSync) ?? null;
|
||||
}
|
||||
|
||||
async function readJsonIfExists(filePath) {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(await readFile(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function packageId(packageName) {
|
||||
if (!packageName) {
|
||||
return null;
|
||||
}
|
||||
return packageName
|
||||
.split("/")
|
||||
.pop()
|
||||
.replace(/^openclaw-/, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function inferPluginSeams(pluginManifest, packageJson) {
|
||||
const contracts = Object.keys(pluginManifest?.contracts ?? {});
|
||||
if (contracts.includes("tools")) {
|
||||
return ["dynamic-tool"];
|
||||
}
|
||||
if (packageJson?.openclaw?.extensions || packageJson?.openclaw?.runtimeExtensions) {
|
||||
return ["plugin-runtime"];
|
||||
}
|
||||
return ["plugin-metadata"];
|
||||
}
|
||||
|
||||
@ -106,14 +106,19 @@ export {
|
||||
} from "./openclaw-target.js";
|
||||
export {
|
||||
captureEntrypoint,
|
||||
inspectCompatibilityFixtureSet,
|
||||
inspectFixtureSet,
|
||||
inspectPlugin,
|
||||
inspectSourceText,
|
||||
} from "./inspector.js";
|
||||
export {
|
||||
defaultPluginRootConfigFiles,
|
||||
fixtureCheckoutPath,
|
||||
fixtureSourceRoot,
|
||||
loadInspectorConfig,
|
||||
loadPluginRootConfig,
|
||||
normalizeInspectorConfig,
|
||||
normalizePluginRootConfig,
|
||||
validateInspectorConfig,
|
||||
} from "./config.js";
|
||||
export {
|
||||
@ -143,6 +148,7 @@ export {
|
||||
classifyCompatRecordCoverage,
|
||||
renderMarkdownReport,
|
||||
renderTextSummary,
|
||||
writeCompatibilityReport,
|
||||
writeReport,
|
||||
} from "./report.js";
|
||||
export {
|
||||
|
||||
@ -4,9 +4,43 @@ import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createCaptureApi } from "./capture-api.js";
|
||||
import { fixtureCheckoutPath, fixtureSourceRoot } from "./config.js";
|
||||
import { buildReport } from "./report.js";
|
||||
import { buildCompatibilityFixtureReport } from "./fixture-summary.js";
|
||||
import { readOpenClawTargetSurface } from "./openclaw-target.js";
|
||||
import { buildCompatibilityReport, buildReport } from "./report.js";
|
||||
|
||||
export async function inspectFixtureSet(config, options = {}) {
|
||||
const { inspections, failures } = await inspectConfiguredFixtures(config, options);
|
||||
return buildReport({ config, inspections, failures, generatedAt: options.generatedAt });
|
||||
}
|
||||
|
||||
export async function inspectCompatibilityFixtureSet(config, options = {}) {
|
||||
const { inspections, failures } = await inspectConfiguredFixtures(config, options);
|
||||
const targetOpenClaw =
|
||||
options.targetOpenClaw ??
|
||||
(await readOpenClawTargetSurface({
|
||||
configuredPath: options.openclawPath,
|
||||
manifest: config,
|
||||
rootDir: config.rootDir,
|
||||
}));
|
||||
|
||||
return buildCompatibilityReport({
|
||||
config,
|
||||
inspections,
|
||||
failures,
|
||||
generatedAt: options.generatedAt,
|
||||
targetOpenClaw,
|
||||
buildFixtureReport: ({ fixture, inspection }) =>
|
||||
buildCompatibilityFixtureReport({
|
||||
fixture,
|
||||
inspection,
|
||||
checkoutPath: fixtureCheckoutPath(config, fixture),
|
||||
sourceRoot: fixtureSourceRoot(config, fixture),
|
||||
rootDir: config.rootDir,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectConfiguredFixtures(config, options = {}) {
|
||||
const inspections = [];
|
||||
const failures = [];
|
||||
|
||||
@ -27,7 +61,7 @@ export async function inspectFixtureSet(config, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return buildReport({ config, inspections, failures, generatedAt: options.generatedAt });
|
||||
return { inspections, failures };
|
||||
}
|
||||
|
||||
export async function inspectPlugin(fixture, options = {}) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { renderMarkdownTable, writeJsonMarkdownArtifacts } from "./artifacts.js";
|
||||
import { renderMarkdownTable, writeArtifacts, writeJsonMarkdownArtifacts } from "./artifacts.js";
|
||||
import { renderCompatibilityIssuesReport, renderCompatibilityMarkdownReport } from "./compatibility-report.js";
|
||||
import { buildContractProbes } from "./contract-probes.js";
|
||||
import { classifyCompatibilityFixture } from "./fixture-summary.js";
|
||||
import { buildIssues, summarizeIssueClasses } from "./issues.js";
|
||||
@ -241,6 +242,23 @@ export async function writeReport(report, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeCompatibilityReport(report, options = {}) {
|
||||
const outDir = path.resolve(options.cwd ?? process.cwd(), options.outDir ?? "reports");
|
||||
const basename = options.basename ?? "plugin-inspector-report";
|
||||
const jsonPath = path.join(outDir, `${basename}.json`);
|
||||
const markdownPath = path.join(outDir, `${basename}.md`);
|
||||
const issuesPath = path.join(outDir, options.issuesBasename ?? "plugin-inspector-issues.md");
|
||||
|
||||
return writeArtifacts(
|
||||
[
|
||||
{ name: "jsonPath", path: jsonPath, json: report },
|
||||
{ name: "markdownPath", path: markdownPath, markdown: renderCompatibilityMarkdownReport(report) },
|
||||
{ name: "issuesPath", path: issuesPath, markdown: renderCompatibilityIssuesReport(report) },
|
||||
],
|
||||
{ check: options.check },
|
||||
);
|
||||
}
|
||||
|
||||
export function renderTextSummary(report) {
|
||||
return [
|
||||
`Status: ${report.status.toUpperCase()}`,
|
||||
|
||||
54
test/cli.test.js
Normal file
54
test/cli.test.js
Normal file
@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { test } from "node:test";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
test("check command runs from a plugin root without fixture config", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-cli-root-"));
|
||||
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",
|
||||
);
|
||||
|
||||
const cliPath = path.resolve("src/cli.js");
|
||||
const { stdout } = await execFileAsync(process.execPath, [cliPath, "check", "--out", "reports", "--no-openclaw"], {
|
||||
cwd: rootDir,
|
||||
});
|
||||
const report = JSON.parse(await readFile(path.join(rootDir, "reports", "plugin-inspector-report.json"), "utf8"));
|
||||
const issues = await readFile(path.join(rootDir, "reports", "plugin-inspector-issues.md"), "utf8");
|
||||
|
||||
assert.match(stdout, /Status: PASS/);
|
||||
assert.equal(report.targetOpenClaw.status, "disabled");
|
||||
assert.equal(report.fixtures[0].id, "weather");
|
||||
assert.ok(report.fixtures[0].package.openclaw.entrypoints.some((entrypoint) => entrypoint.exists));
|
||||
assert.match(issues, /# OpenClaw Plugin Issue Findings/);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user