mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Publish public Plugin Inspector findings (#2525)
* feat: gate plugin publishes with inspector warnings * fix: harden plugin publish inspector gate * feat: publish public plugin inspector findings * fix: refine plugin inspector publish errors * feat: add package validation command * fix: refine plugin validation findings UI * fix: scan only latest plugin releases nightly * feat: add dry run for nightly plugin inspection * test: fix plugin validation tab e2e matcher
This commit is contained in:
@@ -1,5 +1,19 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "bun"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "America/Los_Angeles"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- dependencies
|
||||
- needs-cli-release
|
||||
allow:
|
||||
- dependency-name: "@openclaw/plugin-inspector"
|
||||
|
||||
- package-ecosystem: "bun"
|
||||
directory: "/"
|
||||
schedule:
|
||||
@@ -9,6 +23,7 @@ updates:
|
||||
timezone: "America/Los_Angeles"
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
- dependency-name: "@openclaw/plugin-inspector"
|
||||
- dependency-name: "@auth/core"
|
||||
update-types:
|
||||
- "version-update:semver-minor"
|
||||
|
||||
@@ -61,11 +61,6 @@ on:
|
||||
description: Optional source path inside the repository for monorepo package publishes.
|
||||
required: false
|
||||
type: string
|
||||
clawhub_version:
|
||||
description: Legacy npm CLI version input. Kept for compatibility; the workflow now runs the checked-out source.
|
||||
required: false
|
||||
type: string
|
||||
default: latest
|
||||
secrets:
|
||||
clawhub_token:
|
||||
required: false
|
||||
@@ -219,6 +214,7 @@ jobs:
|
||||
echo "CLAWHUB_CONFIG_PATH=$RUNNER_TEMP/clawhub-config.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Resolve publish command
|
||||
id: resolve_publish
|
||||
env:
|
||||
INPUT_SOURCE: ${{ inputs.source }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
@@ -233,6 +229,7 @@ jobs:
|
||||
INPUT_SITE: ${{ inputs.site }}
|
||||
INPUT_REGISTRY: ${{ inputs.registry }}
|
||||
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
@@ -243,6 +240,78 @@ jobs:
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
def split_ref_path(value):
|
||||
if not value:
|
||||
return "", ""
|
||||
if ":" not in value:
|
||||
return value, ""
|
||||
ref, path = value.split(":", 1)
|
||||
return ref, path.strip("/")
|
||||
|
||||
def github_commit_exists(repo, ref):
|
||||
token = os.environ.get("GITHUB_TOKEN", "").strip()
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "clawhub-package-publish",
|
||||
}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
request = Request(
|
||||
f"https://api.github.com/repos/{repo}/commits/{quote(ref, safe='')}",
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=10) as response:
|
||||
return 200 <= response.status < 300
|
||||
except HTTPError as error:
|
||||
if error.code in (404, 422):
|
||||
return False
|
||||
raise
|
||||
|
||||
def resolve_github_url_ref_and_path(repo, kind, segments):
|
||||
min_path_segments = 1 if kind == "blob" else 0
|
||||
max_ref_segments = len(segments) - min_path_segments
|
||||
for ref_segment_count in range(max_ref_segments, 0, -1):
|
||||
ref = "/".join(segments[:ref_segment_count])
|
||||
path = "/".join(segments[ref_segment_count:]).strip("/")
|
||||
if kind == "blob" and not path:
|
||||
continue
|
||||
if not github_commit_exists(repo, ref):
|
||||
continue
|
||||
if kind == "blob":
|
||||
path = "/".join(path.split("/")[:-1]).strip("/")
|
||||
return ref, path
|
||||
raise SystemExit(f"GitHub ref not found in source URL for {repo}")
|
||||
|
||||
def parse_github_source(value):
|
||||
raw = value.strip()
|
||||
if raw.startswith("github:"):
|
||||
raw = raw[len("github:"):]
|
||||
if raw.startswith("https://") or raw.startswith("http://"):
|
||||
parsed = urlparse(raw)
|
||||
if parsed.netloc.lower() != "github.com":
|
||||
return None
|
||||
parts = [part for part in parsed.path.strip("/").split("/") if part]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
repo_name = parts[1][:-4] if parts[1].endswith(".git") else parts[1]
|
||||
repo = f"{parts[0]}/{repo_name}"
|
||||
if len(parts) >= 4 and parts[2] in {"tree", "blob"}:
|
||||
ref, path = resolve_github_url_ref_and_path(repo, parts[2], parts[3:])
|
||||
return {"repo": repo, "ref": ref, "path": path}
|
||||
return {"repo": repo, "ref": "", "path": ""}
|
||||
|
||||
source_part, at, ref_part = raw.partition("@")
|
||||
repo_parts = source_part.split("/")
|
||||
if len(repo_parts) != 2 or not repo_parts[0] or not repo_parts[1]:
|
||||
return None
|
||||
repo_name = repo_parts[1][:-4] if repo_parts[1].endswith(".git") else repo_parts[1]
|
||||
ref, path = split_ref_path(ref_part if at else "")
|
||||
return {"repo": f"{repo_parts[0]}/{repo_name}", "ref": ref, "path": path}
|
||||
|
||||
source = os.environ["INPUT_SOURCE"].strip()
|
||||
if not source:
|
||||
@@ -254,6 +323,30 @@ jobs:
|
||||
is_local_source = source.startswith(".") or source.startswith("/") or Path(source).exists()
|
||||
if ref and "@" not in source and not source.startswith("http") and not is_local_source:
|
||||
source = f"{source}@{ref}"
|
||||
source_path = os.environ["INPUT_SOURCE_PATH"].strip()
|
||||
|
||||
inspect_checkout_repository = ""
|
||||
inspect_checkout_ref = ""
|
||||
inspect_local_root = str(Path(os.environ["GITHUB_WORKSPACE"]).resolve())
|
||||
inspect_subdir = source_path
|
||||
if is_local_source:
|
||||
inspect_local_root = str(Path(source).resolve())
|
||||
else:
|
||||
github_source = parse_github_source(source)
|
||||
source_ref_differs_from_checkout = (
|
||||
bool(github_source and github_source["ref"])
|
||||
and github_source["ref"] != os.environ["GITHUB_SHA"]
|
||||
)
|
||||
if github_source and (
|
||||
github_source["repo"] != os.environ["GITHUB_REPOSITORY"]
|
||||
or source_ref_differs_from_checkout
|
||||
):
|
||||
inspect_checkout_repository = github_source["repo"]
|
||||
inspect_checkout_ref = github_source["ref"]
|
||||
inspect_local_root = str((Path(os.environ["GITHUB_WORKSPACE"]) / "clawhub-publish-source").resolve())
|
||||
inspect_subdir = source_path or github_source["path"]
|
||||
elif github_source:
|
||||
inspect_subdir = source_path or github_source["path"]
|
||||
|
||||
cli_entry = (
|
||||
Path(os.environ["GITHUB_WORKSPACE"])
|
||||
@@ -294,7 +387,6 @@ jobs:
|
||||
source_repo = os.environ["INPUT_SOURCE_REPO"].strip()
|
||||
source_commit = os.environ["INPUT_SOURCE_COMMIT"].strip()
|
||||
source_ref = os.environ["INPUT_SOURCE_REF"].strip()
|
||||
source_path = os.environ["INPUT_SOURCE_PATH"].strip()
|
||||
if source_repo:
|
||||
cmd += ["--source-repo", source_repo]
|
||||
if source_commit:
|
||||
@@ -318,8 +410,55 @@ jobs:
|
||||
path.write_text("#!/usr/bin/env bash\nset -euo pipefail\n" + shell_line + "\n", encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
print(shell_line)
|
||||
|
||||
output_path = Path(os.environ["GITHUB_OUTPUT"])
|
||||
with output_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(f"inspect_checkout_repository={inspect_checkout_repository}\n")
|
||||
fh.write(f"inspect_checkout_ref={inspect_checkout_ref}\n")
|
||||
fh.write(f"inspect_local_root={inspect_local_root}\n")
|
||||
fh.write(f"inspect_subdir={inspect_subdir}\n")
|
||||
PY
|
||||
|
||||
- name: Checkout publish source for plugin inspector
|
||||
if: steps.resolve_publish.outputs.inspect_checkout_repository != ''
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ steps.resolve_publish.outputs.inspect_checkout_repository }}
|
||||
ref: ${{ steps.resolve_publish.outputs.inspect_checkout_ref }}
|
||||
path: clawhub-publish-source
|
||||
|
||||
- name: Run plugin validation
|
||||
env:
|
||||
INSPECT_LOCAL_ROOT: ${{ steps.resolve_publish.outputs.inspect_local_root }}
|
||||
INSPECT_SUBDIR: ${{ steps.resolve_publish.outputs.inspect_subdir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
inspect_root="$(python3 - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(os.environ["INSPECT_LOCAL_ROOT"]).resolve()
|
||||
subdir = os.environ["INSPECT_SUBDIR"].strip()
|
||||
inspect_root = (root / subdir).resolve() if subdir else root
|
||||
if inspect_root != root and root not in inspect_root.parents:
|
||||
raise SystemExit(f"Inspector source path escapes publish source: {subdir}")
|
||||
print(inspect_root)
|
||||
PY
|
||||
)"
|
||||
if [ ! -f "$inspect_root/package.json" ] && [ ! -f "$inspect_root/openclaw.plugin.json" ]; then
|
||||
echo "::warning::Plugin Inspector skipped because $inspect_root is not a plugin root."
|
||||
exit 0
|
||||
fi
|
||||
bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" package validate "$inspect_root" --out "$RUNNER_TEMP/plugin-inspector"
|
||||
|
||||
- name: Upload plugin inspector reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: plugin-inspector-report
|
||||
path: ${{ runner.temp }}/plugin-inspector
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Run package publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Plugin Inspector Nightly
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
batch_size:
|
||||
description: "Maximum plugin releases to scan"
|
||||
required: false
|
||||
default: "25"
|
||||
dry_run:
|
||||
description: "Preview impact without persisting findings or sending emails"
|
||||
required: false
|
||||
default: "false"
|
||||
type: boolean
|
||||
dry_run_max_batches:
|
||||
description: "Maximum preview batches to scan when dry_run is enabled"
|
||||
required: false
|
||||
default: "20"
|
||||
schedule:
|
||||
- cron: "41 8 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan published plugins
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run nightly plugin inspector scan
|
||||
env:
|
||||
CLAWHUB_SITE_URL: ${{ vars.CLAWHUB_SITE_URL || 'https://clawhub.ai' }}
|
||||
CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN: ${{ secrets.CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN }}
|
||||
PLUGIN_INSPECTOR_BATCH_SIZE: ${{ inputs.batch_size || '25' }}
|
||||
PLUGIN_INSPECTOR_DRY_RUN: ${{ inputs.dry_run && '1' || '0' }}
|
||||
PLUGIN_INSPECTOR_DRY_RUN_MAX_BATCHES: ${{ inputs.dry_run_max_batches || '20' }}
|
||||
PLUGIN_INSPECTOR_ARTIFACT_DIR: plugin-inspector-nightly-reports
|
||||
run: bun scripts/package-inspector-nightly-scan.ts
|
||||
|
||||
- name: Upload inspector reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: plugin-inspector-nightly-reports
|
||||
path: plugin-inspector-nightly-reports
|
||||
if-no-files-found: warn
|
||||
@@ -12,6 +12,7 @@
|
||||
"@fontsource/manrope": "5.2.8",
|
||||
"@fontsource/noto-sans-sc": "5.2.9",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@openclaw/plugin-inspector": "0.3.11",
|
||||
"@radix-ui/react-avatar": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.16",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.17",
|
||||
@@ -82,13 +83,14 @@
|
||||
},
|
||||
"packages/clawhub": {
|
||||
"name": "clawhub",
|
||||
"version": "0.20.0",
|
||||
"version": "0.20.1",
|
||||
"bin": {
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
"clawhub": "bin/clawdhub.js",
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "1.5.1",
|
||||
"@openclaw/plugin-inspector": "0.3.11",
|
||||
"arktype": "2.2.0",
|
||||
"commander": "15.0.0",
|
||||
"fflate": "0.8.3",
|
||||
@@ -321,6 +323,8 @@
|
||||
|
||||
"@openclaw/clawhub-mod": ["@openclaw/clawhub-mod@workspace:packages/clawhub-mod"],
|
||||
|
||||
"@openclaw/plugin-inspector": ["@openclaw/plugin-inspector@0.3.11", "", { "bin": { "plugin-inspector": "src/cli.js" } }, "sha512-bTUTq0Smg+U/9P+qJO8cBfdL+ASayfjft8WY9hfRvzJcG6lT3XXCSvs5fJE8jiX76Y911JXZQ8Jg9c5uZsKSUw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
|
||||
|
||||
Vendored
+4
@@ -126,6 +126,8 @@ import type * as lib_webhooks from "../lib/webhooks.js";
|
||||
import type * as llmEval from "../llmEval.js";
|
||||
import type * as maintenance from "../maintenance.js";
|
||||
import type * as managementDevSeed from "../managementDevSeed.js";
|
||||
import type * as packageInspectorHttp from "../packageInspectorHttp.js";
|
||||
import type * as packageInspectorNode from "../packageInspectorNode.js";
|
||||
import type * as packagePublishTokens from "../packagePublishTokens.js";
|
||||
import type * as packages from "../packages.js";
|
||||
import type * as publisherAbuse from "../publisherAbuse.js";
|
||||
@@ -280,6 +282,8 @@ declare const fullApi: ApiFromModules<{
|
||||
llmEval: typeof llmEval;
|
||||
maintenance: typeof maintenance;
|
||||
managementDevSeed: typeof managementDevSeed;
|
||||
packageInspectorHttp: typeof packageInspectorHttp;
|
||||
packageInspectorNode: typeof packageInspectorNode;
|
||||
packagePublishTokens: typeof packagePublishTokens;
|
||||
packages: typeof packages;
|
||||
publisherAbuse: typeof publisherAbuse;
|
||||
|
||||
@@ -501,6 +501,21 @@ describe("devSeed local fixtures", () => {
|
||||
scannedPluginName,
|
||||
]);
|
||||
expect(tables.packages?.every((pkg) => pkg.ownerUserId === userId)).toBe(true);
|
||||
expect(tables.packageInspectorWarnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
packageName: scannedPluginName,
|
||||
findingKind: "warning",
|
||||
code: "legacy-before-agent-start",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
packageName: scannedPluginName,
|
||||
findingKind: "error",
|
||||
code: "missing-expected-seam",
|
||||
scanSource: "nightly",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("retires legacy @local-owner seed publishers so dev-auth users can claim the handle", async () => {
|
||||
|
||||
@@ -1453,11 +1453,18 @@ async function deleteSeedPluginFixtureByName(ctx: MutationCtx, name: string) {
|
||||
.query("packageReleases")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", existing._id))
|
||||
.collect();
|
||||
const inspectorFindings = await ctx.db
|
||||
.query("packageInspectorWarnings")
|
||||
.withIndex("by_package_created", (q) => q.eq("packageId", existing._id))
|
||||
.collect();
|
||||
await deletePackageBadgesForPackage(ctx, existing._id);
|
||||
await ctx.db.delete(existing._id);
|
||||
for (const release of releases) {
|
||||
await ctx.db.delete(release._id);
|
||||
}
|
||||
for (const finding of inspectorFindings) {
|
||||
await ctx.db.delete(finding._id);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSeedPluginFixture(ctx: MutationCtx, name = FLAGGED_PLUGIN_NAME) {
|
||||
@@ -2515,6 +2522,48 @@ export async function seedLocalModerationFixturesHandler(
|
||||
stats: { downloads: 7, installs: 1, stars: 1, versions: 1 },
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert("packageInspectorWarnings", {
|
||||
packageId: scannedPackageId,
|
||||
releaseId: scannedPackageReleaseId,
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
packageName: scannedPluginName,
|
||||
version: "0.1.0",
|
||||
findingKind: "warning",
|
||||
scanSource: "publish",
|
||||
inspectorVersion: "0.3.11",
|
||||
targetOpenClawVersion: "2026.3.24-beta.2",
|
||||
code: "legacy-before-agent-start",
|
||||
severity: "P2",
|
||||
level: "warning",
|
||||
issueClass: "deprecation-warning",
|
||||
compatStatus: "deprecated",
|
||||
deprecated: true,
|
||||
message: "legacy before_agent_start hook is deprecated for the current OpenClaw plugin API",
|
||||
evidence: ["src/index.ts:4", "hook:before_agent_start"],
|
||||
inspectorFindingId: "local-scanned-runtime-plugin:legacy-before-agent-start",
|
||||
createdAt: now,
|
||||
});
|
||||
await ctx.db.insert("packageInspectorWarnings", {
|
||||
packageId: scannedPackageId,
|
||||
releaseId: scannedPackageReleaseId,
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
packageName: scannedPluginName,
|
||||
version: "0.1.0",
|
||||
findingKind: "error",
|
||||
scanSource: "nightly",
|
||||
inspectorVersion: "0.4.0",
|
||||
targetOpenClawVersion: "2026.4.0",
|
||||
code: "missing-expected-seam",
|
||||
severity: "P0",
|
||||
level: "breakage",
|
||||
issueClass: "compatibility-error",
|
||||
message: "registerTool is no longer available on the target OpenClaw compatibility surface",
|
||||
evidence: ["src/index.ts:12", "target:OpenClaw 2026.4.0"],
|
||||
inspectorFindingId: "local-scanned-runtime-plugin:missing-expected-seam",
|
||||
createdAt: now + 1,
|
||||
});
|
||||
await ctx.db.patch(userId, {
|
||||
publishedSkills: 6,
|
||||
totalStars: 3,
|
||||
|
||||
@@ -59,6 +59,11 @@ import {
|
||||
whoamiV1Http,
|
||||
} from "./httpApiV1";
|
||||
import { preflightHandler } from "./httpPreflight";
|
||||
import {
|
||||
packageInspectorArtifactHttp,
|
||||
packageInspectorClaimHttp,
|
||||
packageInspectorResultsHttp,
|
||||
} from "./packageInspectorHttp";
|
||||
|
||||
const http = httpRouter();
|
||||
|
||||
@@ -190,6 +195,24 @@ http.route({
|
||||
handler: mintPublishTokenV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: "/api/v1/package-inspector/claim",
|
||||
method: "POST",
|
||||
handler: packageInspectorClaimHttp,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: "/api/v1/package-inspector/artifact",
|
||||
method: "GET",
|
||||
handler: packageInspectorArtifactHttp,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: "/api/v1/package-inspector/results",
|
||||
method: "POST",
|
||||
handler: packageInspectorResultsHttp,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.packages}/`,
|
||||
method: "POST",
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
declare module "@openclaw/plugin-inspector" {
|
||||
export type PluginInspectorReport = {
|
||||
status?: string;
|
||||
summary?: {
|
||||
breakageCount?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type PluginInspectorPaths = {
|
||||
jsonPath: string;
|
||||
markdownPath?: string;
|
||||
issuesPath?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export const pluginRoot: {
|
||||
runCheck(options?: {
|
||||
pluginRoot?: string;
|
||||
openclawPath?: string | false;
|
||||
outDir?: string;
|
||||
capture?: boolean;
|
||||
mockSdk?: boolean;
|
||||
allowExecution?: boolean;
|
||||
configPath?: string;
|
||||
generatedAt?: string;
|
||||
}): Promise<{ report: PluginInspectorReport; paths: PluginInspectorPaths }>;
|
||||
};
|
||||
|
||||
export const reports: {
|
||||
renderTextSummary(report: PluginInspectorReport, options?: Record<string, unknown>): string;
|
||||
sanitizeArtifact(report: PluginInspectorReport): unknown;
|
||||
};
|
||||
|
||||
export const ci: {
|
||||
writeOutputs(
|
||||
report: PluginInspectorReport,
|
||||
options?: Record<string, unknown>,
|
||||
): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { absolutePackageArtifactUrl } from "./packageInspectorHttp";
|
||||
|
||||
describe("package inspector HTTP helpers", () => {
|
||||
it("returns the protected artifact route for scan claims", () => {
|
||||
const request = new Request("https://example.com/api/v1/package-inspector/claim");
|
||||
|
||||
expect(absolutePackageArtifactUrl(request, "packageReleases:demo-1")).toBe(
|
||||
"https://example.com/api/v1/package-inspector/artifact?releaseId=packageReleases%3Ademo-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { httpAction } from "./_generated/server";
|
||||
import type { ActionCtx } from "./_generated/server";
|
||||
import { json, parseJsonPayload, text } from "./httpApiV1/shared";
|
||||
import { buildDeterministicPackageZip } from "./lib/skillZip";
|
||||
|
||||
const internalRefs = internal as unknown as {
|
||||
packages: {
|
||||
claimPackageInspectorScanBatchInternal: unknown;
|
||||
previewPackageInspectorScanBatchInternal: unknown;
|
||||
getPackageInspectorArtifactInternal: unknown;
|
||||
ingestPackageInspectorScanResultsInternal: unknown;
|
||||
sendPackageInspectorFindingsEmailInternal: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function readBearerToken(request: Request) {
|
||||
return (
|
||||
request.headers
|
||||
.get("authorization")
|
||||
?.match(/^Bearer\s+(.+)$/i)?.[1]
|
||||
?.trim() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function requireWorkerToken(request: Request) {
|
||||
const expected = process.env.CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN?.trim() || "";
|
||||
if (!expected) return { ok: false as const, response: text("Worker unavailable", 503) };
|
||||
if (readBearerToken(request) !== expected) {
|
||||
return { ok: false as const, response: text("Unauthorized", 401) };
|
||||
}
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export function absolutePackageArtifactUrl(request: Request, releaseId: string) {
|
||||
const url = new URL("/api/v1/package-inspector/artifact", request.url);
|
||||
url.searchParams.set("releaseId", releaseId);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function isTruthyParam(value: string | null) {
|
||||
if (!value) return false;
|
||||
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
async function runMutationRef<T>(ctx: Pick<ActionCtx, "runMutation">, ref: unknown, args: unknown) {
|
||||
return (await ctx.runMutation(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
async function runQueryRef<T>(ctx: Pick<ActionCtx, "runQuery">, ref: unknown, args: unknown) {
|
||||
return (await ctx.runQuery(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
async function runActionRef<T>(ctx: Pick<ActionCtx, "runAction">, ref: unknown, args: unknown) {
|
||||
return (await ctx.runAction(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
export const packageInspectorClaimHttp = httpAction(async (ctx, request) => {
|
||||
const auth = requireWorkerToken(request);
|
||||
if (!auth.ok) return auth.response;
|
||||
const url = new URL(request.url);
|
||||
const batchSize = Number(url.searchParams.get("batchSize") ?? "25");
|
||||
const cursor = url.searchParams.get("cursor");
|
||||
const dryRun = isTruthyParam(url.searchParams.get("dryRun"));
|
||||
type ClaimResult = {
|
||||
ok: true;
|
||||
leased: boolean;
|
||||
nextCursor: string | null;
|
||||
items: Array<{
|
||||
packageId: string;
|
||||
releaseId: string;
|
||||
ownerUserId?: string;
|
||||
ownerPublisherId?: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
artifactKind: string;
|
||||
}>;
|
||||
};
|
||||
const claimArgs = {
|
||||
batchSize: Number.isFinite(batchSize) ? batchSize : undefined,
|
||||
...(dryRun ? { cursor } : {}),
|
||||
};
|
||||
const result = dryRun
|
||||
? await runQueryRef<ClaimResult>(
|
||||
ctx,
|
||||
internalRefs.packages.previewPackageInspectorScanBatchInternal,
|
||||
claimArgs,
|
||||
)
|
||||
: await runMutationRef<ClaimResult>(
|
||||
ctx,
|
||||
internalRefs.packages.claimPackageInspectorScanBatchInternal,
|
||||
claimArgs,
|
||||
);
|
||||
return json({
|
||||
...result,
|
||||
dryRun,
|
||||
items: result.items.map((item) => ({
|
||||
...item,
|
||||
downloadUrl: absolutePackageArtifactUrl(request, item.releaseId),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
export const packageInspectorArtifactHttp = httpAction(async (ctx, request) => {
|
||||
const auth = requireWorkerToken(request);
|
||||
if (!auth.ok) return auth.response;
|
||||
const releaseId = new URL(request.url).searchParams.get("releaseId")?.trim();
|
||||
if (!releaseId) return text("Missing releaseId", 400);
|
||||
const artifact = await runQueryRef<{
|
||||
packageName: string;
|
||||
version: string;
|
||||
artifactKind: "legacy-zip" | "npm-pack";
|
||||
clawpackStorageId?: string;
|
||||
clawpackSha256?: string;
|
||||
npmIntegrity?: string;
|
||||
npmShasum?: string;
|
||||
npmTarballName?: string;
|
||||
files: Array<{ path: string; storageId: string }>;
|
||||
} | null>(ctx, internalRefs.packages.getPackageInspectorArtifactInternal, {
|
||||
releaseId,
|
||||
});
|
||||
if (!artifact) return text("Artifact not found", 404);
|
||||
|
||||
if (artifact.artifactKind === "npm-pack") {
|
||||
if (!artifact.clawpackStorageId) return text("Artifact not found", 404);
|
||||
const blob = await ctx.storage.get(artifact.clawpackStorageId as Id<"_storage">);
|
||||
if (!blob) return text("Artifact not found", 404);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${artifact.npmTarballName ?? `${artifact.packageName.replaceAll("/", "-")}-${artifact.version}.tgz`}"`,
|
||||
"X-ClawHub-Artifact-Type": "npm-pack-tarball",
|
||||
};
|
||||
if (artifact.clawpackSha256) {
|
||||
headers.ETag = `"sha256:${artifact.clawpackSha256}"`;
|
||||
headers["X-ClawHub-Artifact-Sha256"] = artifact.clawpackSha256;
|
||||
}
|
||||
if (artifact.npmIntegrity) headers["X-ClawHub-Npm-Integrity"] = artifact.npmIntegrity;
|
||||
if (artifact.npmShasum) headers["X-ClawHub-Npm-Shasum"] = artifact.npmShasum;
|
||||
return new Response(blob, { status: 200, headers });
|
||||
}
|
||||
|
||||
const entries: Array<{ path: string; bytes: Uint8Array }> = [];
|
||||
for (const file of artifact.files) {
|
||||
const blob = await ctx.storage.get(file.storageId as Id<"_storage">);
|
||||
if (!blob) return text(`Missing stored file: ${file.path}`, 500);
|
||||
entries.push({
|
||||
path: file.path,
|
||||
bytes: new Uint8Array(await blob.arrayBuffer()),
|
||||
});
|
||||
}
|
||||
const zip = buildDeterministicPackageZip(entries);
|
||||
return new Response(new Blob([zip], { type: "application/zip" }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="${artifact.packageName.replaceAll("/", "-")}-${artifact.version}.zip"`,
|
||||
"X-ClawHub-Artifact-Type": "legacy-plugin-zip",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const packageInspectorResultsHttp = httpAction(async (ctx, request) => {
|
||||
const auth = requireWorkerToken(request);
|
||||
if (!auth.ok) return auth.response;
|
||||
const parsed = await parseJsonPayload(request, {});
|
||||
if (!parsed.ok) return parsed.response;
|
||||
const payload = parsed.payload;
|
||||
const result = await runMutationRef<{
|
||||
ok: true;
|
||||
inserted: number;
|
||||
shouldEmailOwner: boolean;
|
||||
}>(ctx, internalRefs.packages.ingestPackageInspectorScanResultsInternal, {
|
||||
packageId: payload.packageId,
|
||||
releaseId: payload.releaseId,
|
||||
inspectorVersion: payload.inspectorVersion,
|
||||
targetOpenClawVersion: payload.targetOpenClawVersion,
|
||||
findings: Array.isArray(payload.findings) ? payload.findings : [],
|
||||
});
|
||||
if (result.shouldEmailOwner) {
|
||||
try {
|
||||
await runActionRef(ctx, internalRefs.packages.sendPackageInspectorFindingsEmailInternal, {
|
||||
packageId: payload.packageId,
|
||||
releaseId: payload.releaseId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Package Inspector findings email failed", error);
|
||||
}
|
||||
}
|
||||
return json(result);
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
"use node";
|
||||
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { v } from "convex/values";
|
||||
import { internalAction } from "./_generated/server";
|
||||
|
||||
type InspectorFinding = {
|
||||
id?: string;
|
||||
code: string;
|
||||
severity?: string;
|
||||
level?: string;
|
||||
issueClass?: string;
|
||||
compatStatus?: string;
|
||||
deprecated?: boolean;
|
||||
message: string;
|
||||
evidence?: string[];
|
||||
fixture?: string;
|
||||
decision?: string;
|
||||
};
|
||||
|
||||
type InspectorReport = {
|
||||
status?: string;
|
||||
targetOpenClaw?: unknown;
|
||||
summary?: {
|
||||
breakageCount?: number;
|
||||
warningCount?: number;
|
||||
deprecationWarningCount?: number;
|
||||
issueCount?: number;
|
||||
};
|
||||
breakages?: unknown[];
|
||||
warnings?: unknown[];
|
||||
suggestions?: unknown[];
|
||||
issues?: unknown[];
|
||||
};
|
||||
|
||||
const publishFileValidator = v.object({
|
||||
path: v.string(),
|
||||
size: v.number(),
|
||||
storageId: v.id("_storage"),
|
||||
sha256: v.string(),
|
||||
contentType: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const findingValidator = v.object({
|
||||
id: v.optional(v.string()),
|
||||
code: v.string(),
|
||||
severity: v.optional(v.string()),
|
||||
level: v.optional(v.string()),
|
||||
issueClass: v.optional(v.string()),
|
||||
compatStatus: v.optional(v.string()),
|
||||
deprecated: v.optional(v.boolean()),
|
||||
message: v.string(),
|
||||
evidence: v.optional(v.array(v.string())),
|
||||
fixture: v.optional(v.string()),
|
||||
decision: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const inspectorMetadataValidator = v.object({
|
||||
inspectorVersion: v.optional(v.string()),
|
||||
targetOpenClawVersion: v.optional(v.string()),
|
||||
});
|
||||
|
||||
export const runPackageInspectorForPublishInternal = internalAction({
|
||||
args: {
|
||||
packageName: v.string(),
|
||||
version: v.string(),
|
||||
files: v.array(publishFileValidator),
|
||||
},
|
||||
returns: v.object({
|
||||
status: v.union(v.literal("pass"), v.literal("fail")),
|
||||
summary: v.object({
|
||||
breakageCount: v.number(),
|
||||
warningCount: v.number(),
|
||||
deprecationWarningCount: v.number(),
|
||||
issueCount: v.number(),
|
||||
}),
|
||||
breakages: v.array(findingValidator),
|
||||
warnings: v.array(findingValidator),
|
||||
metadata: inspectorMetadataValidator,
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const root = path.join(
|
||||
tmpdir(),
|
||||
`clawhub-plugin-inspector-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
for (const file of args.files) {
|
||||
const blob = await ctx.storage.get(file.storageId);
|
||||
if (!blob) {
|
||||
throw new Error(`missing package file ${file.path}`);
|
||||
}
|
||||
const target = safeFilePath(root, file.path);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, Buffer.from(await blob.arrayBuffer()));
|
||||
}
|
||||
await writeSyntheticInspectorConfigIfNeeded(root, args.files, args.packageName);
|
||||
|
||||
const { pluginRoot } = await import("@openclaw/plugin-inspector");
|
||||
const { report } = await pluginRoot.runCheck({
|
||||
pluginRoot: root,
|
||||
openclawPath: false,
|
||||
outDir: "reports",
|
||||
capture: false,
|
||||
mockSdk: true,
|
||||
allowExecution: false,
|
||||
generatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return normalizeInspectorReport(report);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
status: "fail" as const,
|
||||
summary: {
|
||||
breakageCount: 1,
|
||||
warningCount: 0,
|
||||
deprecationWarningCount: 0,
|
||||
issueCount: 1,
|
||||
},
|
||||
breakages: [
|
||||
{
|
||||
code: "plugin-inspector-error",
|
||||
severity: "P0",
|
||||
level: "breakage",
|
||||
message: `Plugin Inspector could not inspect ${args.packageName}@${args.version}: ${message}`,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
metadata: {
|
||||
inspectorVersion: getBundledInspectorVersion(),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function writeSyntheticInspectorConfigIfNeeded(
|
||||
root: string,
|
||||
files: Array<{ path: string }>,
|
||||
packageName: string,
|
||||
) {
|
||||
const lowerRootPaths = new Set(files.map((file) => file.path.toLowerCase()));
|
||||
if (
|
||||
lowerRootPaths.has("plugin-inspector.config.json") ||
|
||||
lowerRootPaths.has(".plugin-inspector.json")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")) as {
|
||||
pluginInspector?: unknown;
|
||||
"plugin-inspector"?: unknown;
|
||||
};
|
||||
if (
|
||||
packageJson.pluginInspector &&
|
||||
typeof packageJson.pluginInspector === "object" &&
|
||||
!Array.isArray(packageJson.pluginInspector)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
packageJson["plugin-inspector"] &&
|
||||
typeof packageJson["plugin-inspector"] === "object" &&
|
||||
!Array.isArray(packageJson["plugin-inspector"])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Existing publish validation and the inspector report own malformed package metadata.
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
path.join(root, ".plugin-inspector.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
plugin: {
|
||||
id: toInspectorFixtureId(packageName),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function toInspectorFixtureId(packageName: string) {
|
||||
const base = packageName.split("/").pop() ?? packageName;
|
||||
const normalized = base
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return normalized || "published-plugin";
|
||||
}
|
||||
|
||||
function safeFilePath(root: string, filePath: string) {
|
||||
const normalized = path.normalize(filePath).replace(/^(\.\.(?:\/|\\|$))+/, "");
|
||||
const target = path.resolve(root, normalized);
|
||||
const rootWithSeparator = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
|
||||
if (target !== root && !target.startsWith(rootWithSeparator)) {
|
||||
throw new Error(`unsafe package file path ${filePath}`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function normalizeInspectorReport(report: unknown) {
|
||||
const parsed = isRecord(report) ? (report as InspectorReport) : {};
|
||||
const breakages = normalizeFindings(parsed.breakages, "breakage");
|
||||
const warnings = normalizeWarnings(parsed);
|
||||
return {
|
||||
status:
|
||||
breakages.length > 0 || parsed.status === "fail" ? ("fail" as const) : ("pass" as const),
|
||||
summary: {
|
||||
breakageCount: numberValue(parsed.summary?.breakageCount, breakages.length),
|
||||
warningCount: numberValue(parsed.summary?.warningCount, warnings.length),
|
||||
deprecationWarningCount: numberValue(
|
||||
parsed.summary?.deprecationWarningCount,
|
||||
warnings.filter((finding) => finding.issueClass === "deprecation-warning").length,
|
||||
),
|
||||
issueCount: numberValue(parsed.summary?.issueCount, warnings.length + breakages.length),
|
||||
},
|
||||
breakages,
|
||||
warnings,
|
||||
metadata: {
|
||||
inspectorVersion: getBundledInspectorVersion(),
|
||||
targetOpenClawVersion: extractTargetOpenClawVersion(parsed.targetOpenClaw),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWarnings(report: InspectorReport) {
|
||||
const issueWarnings = normalizeFindings(report.issues, "warning").filter(
|
||||
(finding) => finding.level !== "breakage",
|
||||
);
|
||||
if (issueWarnings.length > 0) return issueWarnings;
|
||||
return [
|
||||
...normalizeFindings(report.warnings, "warning"),
|
||||
...normalizeFindings(report.suggestions, "suggestion"),
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeFindings(value: unknown, defaultLevel: string): InspectorFinding[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => normalizeFinding(item, defaultLevel))
|
||||
.filter((finding): finding is InspectorFinding => Boolean(finding));
|
||||
}
|
||||
|
||||
function normalizeFinding(value: unknown, defaultLevel: string): InspectorFinding | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const message = stringValue(value.message) ?? stringValue(value.title);
|
||||
const code = stringValue(value.code) ?? "plugin-inspector-finding";
|
||||
if (!message) return null;
|
||||
return {
|
||||
id: stringValue(value.id),
|
||||
code,
|
||||
severity: stringValue(value.severity),
|
||||
level: stringValue(value.level) ?? defaultLevel,
|
||||
issueClass: stringValue(value.issueClass),
|
||||
compatStatus: stringValue(value.compatStatus),
|
||||
deprecated: typeof value.deprecated === "boolean" ? value.deprecated : undefined,
|
||||
message,
|
||||
evidence: Array.isArray(value.evidence)
|
||||
? value.evidence.map((entry) => String(entry)).slice(0, 12)
|
||||
: undefined,
|
||||
fixture: stringValue(value.fixture),
|
||||
decision: stringValue(value.decision),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback: number) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function getBundledInspectorVersion() {
|
||||
return stringValue(process.env.CLAWHUB_PLUGIN_INSPECTOR_VERSION);
|
||||
}
|
||||
|
||||
function extractTargetOpenClawVersion(targetOpenClaw: unknown) {
|
||||
if (!isRecord(targetOpenClaw)) return undefined;
|
||||
return (
|
||||
stringValue(targetOpenClaw.version) ??
|
||||
stringValue(targetOpenClaw.openclawVersion) ??
|
||||
stringValue(targetOpenClaw.label) ??
|
||||
stringValue(targetOpenClaw.status)
|
||||
);
|
||||
}
|
||||
+1230
-3
File diff suppressed because it is too large
Load Diff
+898
-5
File diff suppressed because it is too large
Load Diff
@@ -1313,6 +1313,57 @@ const packageReleases = defineTable({
|
||||
.index("by_package_version", ["packageId", "version"])
|
||||
.index("by_sha256hash", ["sha256hash"]);
|
||||
|
||||
const packageInspectorWarnings = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
releaseId: v.id("packageReleases"),
|
||||
ownerUserId: v.id("users"),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
packageName: v.string(),
|
||||
version: v.string(),
|
||||
findingKind: v.optional(v.union(v.literal("warning"), v.literal("error"))),
|
||||
scanSource: v.optional(v.union(v.literal("publish"), v.literal("nightly"))),
|
||||
inspectorVersion: v.optional(v.string()),
|
||||
targetOpenClawVersion: v.optional(v.string()),
|
||||
code: v.string(),
|
||||
severity: v.optional(v.string()),
|
||||
level: v.optional(v.string()),
|
||||
issueClass: v.optional(v.string()),
|
||||
compatStatus: v.optional(v.string()),
|
||||
deprecated: v.optional(v.boolean()),
|
||||
message: v.string(),
|
||||
evidence: v.optional(v.array(v.string())),
|
||||
fixture: v.optional(v.string()),
|
||||
decision: v.optional(v.string()),
|
||||
inspectorFindingId: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_package_created", ["packageId", "createdAt"])
|
||||
.index("by_release", ["releaseId"])
|
||||
.index("by_release_created", ["releaseId", "createdAt"])
|
||||
.index("by_owner_user_created", ["ownerUserId", "createdAt"])
|
||||
.index("by_owner_publisher_created", ["ownerPublisherId", "createdAt"]);
|
||||
|
||||
const packageInspectorFindingNotifications = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
releaseId: v.id("packageReleases"),
|
||||
ownerUserId: v.id("users"),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
packageName: v.string(),
|
||||
version: v.string(),
|
||||
email: v.string(),
|
||||
findingCount: v.number(),
|
||||
sentAt: v.number(),
|
||||
})
|
||||
.index("by_release", ["releaseId"])
|
||||
.index("by_owner_user_sent", ["ownerUserId", "sentAt"]);
|
||||
|
||||
const packageInspectorScanCursors = defineTable({
|
||||
name: v.string(),
|
||||
cursor: v.optional(v.union(v.string(), v.null())),
|
||||
leaseExpiresAt: v.optional(v.number()),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_name", ["name"]);
|
||||
|
||||
const securityScanJobs = defineTable({
|
||||
targetKind: securityScanTargetKindValidator,
|
||||
skillVersionId: v.optional(v.id("skillVersions")),
|
||||
@@ -2479,6 +2530,9 @@ export default defineSchema({
|
||||
skillSlugAliases,
|
||||
packages,
|
||||
packageReleases,
|
||||
packageInspectorWarnings,
|
||||
packageInspectorFindingNotifications,
|
||||
packageInspectorScanCursors,
|
||||
securityScanJobs,
|
||||
skillScanRequests,
|
||||
skillCardGenerationJobs,
|
||||
|
||||
+22
@@ -404,6 +404,28 @@ clawhub package verify ./example-plugin-1.2.3.tgz --package @openclaw/example-pl
|
||||
clawhub package verify ./example-plugin-1.2.3.tgz --sha256 <hex>
|
||||
```
|
||||
|
||||
### `package validate <source>`
|
||||
|
||||
- Runs the ClawHub CLI's bundled Plugin Inspector against a local plugin package
|
||||
folder.
|
||||
- Defaults to offline/static validation, without locating or importing a local
|
||||
OpenClaw checkout.
|
||||
- Hard compatibility errors exit non-zero. Warning-only findings are printed but
|
||||
exit zero.
|
||||
- Flags:
|
||||
- `--out <dir>`: write Plugin Inspector reports to this directory.
|
||||
- `--openclaw <path>`: inspect against an explicit local OpenClaw checkout.
|
||||
- `--runtime`: enable runtime capture; imports plugin code.
|
||||
- `--allow-execute`: allow runtime capture in an isolated workspace.
|
||||
- `--no-mock-sdk`: disable mocked OpenClaw SDK during runtime capture.
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
clawhub package validate ./example-plugin
|
||||
```
|
||||
|
||||
### `package delete <name>`
|
||||
|
||||
- Soft-deletes a package and all releases.
|
||||
|
||||
+194
-3
@@ -43,10 +43,77 @@ async function readRequestBody(req: IncomingMessage) {
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
async function startPackagePublishRegistry(
|
||||
handler: (req: IncomingMessage, body: string) => { status: number; body: unknown; text?: true },
|
||||
) {
|
||||
const server = createServer(async (req, res) => {
|
||||
const body = await readRequestBody(req);
|
||||
if (req.method !== "POST" || !req.url?.startsWith(ApiRoutes.packages)) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("not found");
|
||||
return;
|
||||
}
|
||||
const response = handler(req, body);
|
||||
res.writeHead(response.status, {
|
||||
"Content-Type": response.text ? "text/plain" : "application/json",
|
||||
});
|
||||
res.end(response.text ? String(response.body) : JSON.stringify(response.body));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
return {
|
||||
registry: `http://127.0.0.1:${address.port}`,
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve())),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function writeCodePluginFixture(root: string, name: string) {
|
||||
const folder = join(root, name);
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name,
|
||||
version: "1.0.0",
|
||||
type: "module",
|
||||
main: "dist/index.js",
|
||||
openclaw: {
|
||||
extensions: ["./dist/index.js"],
|
||||
compat: { pluginApi: ">=2026.3.24-beta.2" },
|
||||
build: { openclawVersion: "2026.3.24-beta.2" },
|
||||
configSchema: { type: "object", additionalProperties: false },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(folder, "openclaw.plugin.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id: name,
|
||||
name,
|
||||
configSchema: { type: "object", additionalProperties: false },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8");
|
||||
return folder;
|
||||
}
|
||||
|
||||
async function spawnCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv },
|
||||
options: { cwd: string; env: NodeJS.ProcessEnv; encoding?: BufferEncoding; timeoutMs?: number },
|
||||
) {
|
||||
return await new Promise<{
|
||||
status: number | null;
|
||||
@@ -54,6 +121,7 @@ async function spawnCommand(
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
@@ -69,8 +137,22 @@ async function spawnCommand(
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
const timeout =
|
||||
options.timeoutMs && options.timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
if (settled) return;
|
||||
child.kill("SIGTERM");
|
||||
reject(new Error(`${command} ${args.join(" ")} timed out`));
|
||||
}, options.timeoutMs)
|
||||
: null;
|
||||
child.on("error", (error) => {
|
||||
settled = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (status, signal) => {
|
||||
settled = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
resolve({ status, signal, stdout, stderr });
|
||||
});
|
||||
});
|
||||
@@ -109,7 +191,7 @@ describe("clawhub e2e", () => {
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
try {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-workdir-"));
|
||||
const result = spawnSync(
|
||||
const result = await spawnCommand(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
@@ -828,6 +910,115 @@ describe("clawhub e2e", () => {
|
||||
expect(output).not.toHaveProperty("releaseId");
|
||||
}, 30_000);
|
||||
|
||||
it("package publish exits non-zero when Plugin Inspector hard errors block publish", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "clawhub-cli-inspector-hard-"));
|
||||
const registry = await startPackagePublishRegistry(() => ({
|
||||
status: 400,
|
||||
text: true,
|
||||
body: "Plugin Inspector blocked publish: missing-expected-seam: missing expected registration registerTool",
|
||||
}));
|
||||
const cfg = await makeTempConfig(registry.registry, "test-token");
|
||||
try {
|
||||
const plugin = await writeCodePluginFixture(root, "cli-inspector-hard-plugin");
|
||||
const result = await spawnCommand(
|
||||
"node",
|
||||
[
|
||||
join(process.cwd(), "packages/clawhub/dist/cli.js"),
|
||||
"package",
|
||||
"publish",
|
||||
plugin,
|
||||
"--registry",
|
||||
registry.registry,
|
||||
"--site",
|
||||
registry.registry,
|
||||
"--source-repo",
|
||||
"openclaw/cli-inspector-hard-plugin",
|
||||
"--source-commit",
|
||||
"deadbeef",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWHUB_CONFIG_PATH: cfg.path,
|
||||
CLAWHUB_DISABLE_TELEMETRY: "1",
|
||||
NO_COLOR: "1",
|
||||
},
|
||||
timeoutMs: 25_000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(`${result.stdout}\n${result.stderr}`).toMatch(/Plugin Inspector blocked publish/);
|
||||
expect(`${result.stdout}\n${result.stderr}`).toMatch(/missing-expected-seam/);
|
||||
} finally {
|
||||
await registry.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("package publish exits zero and prints Plugin Inspector warnings", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "clawhub-cli-inspector-warning-"));
|
||||
const registry = await startPackagePublishRegistry(() => ({
|
||||
status: 200,
|
||||
body: {
|
||||
ok: true,
|
||||
packageId: "pkg_cli_warning",
|
||||
releaseId: "rel_cli_warning",
|
||||
inspectorFindings: [
|
||||
{
|
||||
findingKind: "warning",
|
||||
code: "legacy-before-agent-start",
|
||||
issueClass: "deprecation-warning",
|
||||
message: "legacy before_agent_start hook is deprecated",
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
const cfg = await makeTempConfig(registry.registry, "test-token");
|
||||
try {
|
||||
const plugin = await writeCodePluginFixture(root, "cli-inspector-warning-plugin");
|
||||
const result = await spawnCommand(
|
||||
"node",
|
||||
[
|
||||
join(process.cwd(), "packages/clawhub/dist/cli.js"),
|
||||
"package",
|
||||
"publish",
|
||||
plugin,
|
||||
"--registry",
|
||||
registry.registry,
|
||||
"--site",
|
||||
registry.registry,
|
||||
"--source-repo",
|
||||
"openclaw/cli-inspector-warning-plugin",
|
||||
"--source-commit",
|
||||
"abc123",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWHUB_CONFIG_PATH: cfg.path,
|
||||
CLAWHUB_DISABLE_TELEMETRY: "1",
|
||||
NO_COLOR: "1",
|
||||
},
|
||||
timeoutMs: 25_000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/Plugin Inspector findings: 1 warning/);
|
||||
expect(result.stdout).toMatch(
|
||||
/WARNING legacy-before-agent-start \(deprecation-warning\): legacy before_agent_start hook is deprecated/,
|
||||
);
|
||||
} finally {
|
||||
await registry.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("package publish help shows the new source argument and flags", async () => {
|
||||
const result = spawnSync("bun", ["clawhub", "package", "publish", "--help"], {
|
||||
cwd: process.cwd(),
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { expect, type Page, test, type TestInfo } from "@playwright/test";
|
||||
import { strToU8, zipSync } from "fflate";
|
||||
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "../helpers/runtimeErrors";
|
||||
import { signInAsLocalPersona } from "./helpers";
|
||||
|
||||
test.skip(
|
||||
process.env.VITE_ENABLE_DEV_AUTH !== "1",
|
||||
"local-auth plugin inspector tests require the local dev auth runner",
|
||||
);
|
||||
|
||||
if (process.env.CLAWHUB_CAPTURE_PLUGIN_INSPECTOR_PROOF === "1") {
|
||||
test.use({ video: "on" });
|
||||
}
|
||||
|
||||
type PluginFixtureKind = "hard-error" | "warning";
|
||||
|
||||
function pluginPackageJson(args: { name: string; displayName: string; kind: PluginFixtureKind }) {
|
||||
const pluginInspector =
|
||||
args.kind === "hard-error"
|
||||
? { version: 1, plugin: { id: "invalid.fixture.id" } }
|
||||
: { version: 1, plugin: { id: args.name, sourceRoot: "dist" } };
|
||||
return JSON.stringify(
|
||||
{
|
||||
name: args.name,
|
||||
version: "1.0.0",
|
||||
type: "module",
|
||||
main: "dist/index.js",
|
||||
repository: `https://github.com/openclaw/${args.name}.git`,
|
||||
pluginInspector,
|
||||
openclaw: {
|
||||
extensions: ["./dist/index.js"],
|
||||
compat: { pluginApi: ">=2026.3.24-beta.2" },
|
||||
build: { openclawVersion: "2026.3.24-beta.2" },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
async function writePluginZip(
|
||||
testInfo: TestInfo,
|
||||
args: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
kind: PluginFixtureKind;
|
||||
},
|
||||
) {
|
||||
const entrypoint =
|
||||
args.kind === "warning"
|
||||
? 'export function activate(api) { api.on("before_agent_start", () => {}); }\n'
|
||||
: "export const demo = true;\n";
|
||||
const zipBytes = zipSync({
|
||||
[`${args.name}/package.json`]: strToU8(pluginPackageJson(args)),
|
||||
[`${args.name}/openclaw.plugin.json`]: strToU8(
|
||||
JSON.stringify(
|
||||
{
|
||||
id: args.name,
|
||||
name: args.displayName,
|
||||
configSchema: { type: "object", additionalProperties: false },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
),
|
||||
[`${args.name}/dist/index.js`]: strToU8(entrypoint),
|
||||
[`${args.name}/README.md`]: strToU8(`# ${args.displayName}\n\nLocal Playwright fixture.\n`),
|
||||
});
|
||||
const zipPath = testInfo.outputPath(`${args.name}.zip`);
|
||||
await writeFile(zipPath, zipBytes);
|
||||
return zipPath;
|
||||
}
|
||||
|
||||
async function uploadPluginZip(page: Page, zipPath: string) {
|
||||
await page.locator('input[type="file"]').first().setInputFiles(zipPath);
|
||||
await waitForHydration(page);
|
||||
}
|
||||
|
||||
async function captureProof(page: Page, testInfo: TestInfo, name: string) {
|
||||
if (process.env.CLAWHUB_CAPTURE_PLUGIN_INSPECTOR_PROOF !== "1") return;
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`${name}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
test("plugin inspector blocks hard publish errors and publishes warning findings", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const badName = `pw-inspector-bad-${suffix}`;
|
||||
const warningName = `pw-inspector-warning-${suffix}`;
|
||||
const warningDisplayName = `Playwright Inspector Warning Plugin ${suffix}`;
|
||||
|
||||
await signInAsLocalPersona(page, "admin");
|
||||
|
||||
await page.goto("/plugins/publish", { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByRole("heading", { name: "Publish Plugin" })).toBeVisible();
|
||||
await uploadPluginZip(
|
||||
page,
|
||||
await writePluginZip(testInfo, {
|
||||
name: badName,
|
||||
displayName: "Playwright Inspector Bad Plugin",
|
||||
kind: "hard-error",
|
||||
}),
|
||||
);
|
||||
await expect(page.locator("#pluginName")).toHaveValue(badName);
|
||||
await page.locator("#pluginSourceCommit").fill("abc123");
|
||||
await page.getByRole("button", { name: "Publish plugin" }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("Plugin Inspector blocked publish", {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await captureProof(page, testInfo, "01-upload-hard-error");
|
||||
errors.length = 0;
|
||||
|
||||
await page.goto("/plugins/publish", { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await uploadPluginZip(
|
||||
page,
|
||||
await writePluginZip(testInfo, {
|
||||
name: warningName,
|
||||
displayName: warningDisplayName,
|
||||
kind: "warning",
|
||||
}),
|
||||
);
|
||||
await expect(page.locator("#pluginName")).toHaveValue(warningName);
|
||||
await page.locator("#pluginSourceCommit").fill("abc123");
|
||||
await page.getByRole("button", { name: "Publish plugin" }).click();
|
||||
await expect(page.getByText("Published. Pending security checks")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await captureProof(page, testInfo, "02-upload-warning-success");
|
||||
|
||||
await page.goto("/dashboard", { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
const dashboardWarningLink = page.locator(`a[href="/plugins/${warningName}#validation"]`);
|
||||
await expect(dashboardWarningLink).toBeVisible({ timeout: 30_000 });
|
||||
await captureProof(page, testInfo, "03-dashboard-warning-count");
|
||||
await dashboardWarningLink.click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/plugins/${warningName}#validation$`));
|
||||
await expect(page.getByRole("tab", { name: /Validation \(\d+\)/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.getByText("legacy-before-agent-start")).toBeVisible();
|
||||
await expect(page.getByText("deprecation-warning")).toBeVisible();
|
||||
await expect(page.getByText(/before_agent_start hook compatibility/i)).toBeVisible();
|
||||
await captureProof(page, testInfo, "04-plugin-public-warnings");
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
@@ -14,6 +14,8 @@ const config = {
|
||||
".vercel/**",
|
||||
"coverage/**",
|
||||
"dist/**",
|
||||
// Template contract for the planned Resend integration; intentionally not called yet.
|
||||
"src/lib/packageInspectorEmailTemplates.ts",
|
||||
"src/routeTree.gen.ts",
|
||||
"convex/_generated/**",
|
||||
"packages/*/dist/**",
|
||||
|
||||
+2
-1
@@ -12,7 +12,7 @@
|
||||
"check": "bun run lint",
|
||||
"check:peers": "bun scripts/check-peer-deps.ts",
|
||||
"check:secrets": "bun scripts/check-staged-secrets.mjs",
|
||||
"ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|cli scan rejects local folders|cli scan download fetches a stored submitted-version scan report|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish help shows|skill verify help omits the redundant json flag|skill verify accepts the legacy json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts",
|
||||
"ci:e2e-http": "bun run test:e2e:prod-http && bunx vitest run -c vitest.e2e.config.ts e2e/clawhub.e2e.test.ts --testNamePattern \"prints CLI version|search endpoint returns a results array|cli search does not error|cli scan rejects local folders|cli scan download fetches a stored submitted-version scan report|package publish --dry-run from a GitHub repo|package publish --dry-run --json|package publish exits non-zero when Plugin Inspector hard errors block publish|package publish exits zero and prints Plugin Inspector warnings|package publish help shows|skill verify help omits the redundant json flag|skill verify accepts the legacy json flag\" && bunx vitest run -c vitest.e2e.config.ts e2e/permissions.e2e.test.ts",
|
||||
"ci:packages": "bun run --cwd packages/schema build && bun run --cwd packages/clawhub verify && bun run --cwd packages/clawhub-mod verify",
|
||||
"ci:playwright": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw",
|
||||
"ci:playwright-smoke": "VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run build && VITE_CONVEX_URL=https://wry-manatee-359.convex.cloud VITE_CONVEX_SITE_URL=https://wry-manatee-359.convex.site bun run test:pw -- --project=chromium e2e/ci-smoke.pw.test.ts e2e/public-routes-smoke.pw.test.ts",
|
||||
@@ -78,6 +78,7 @@
|
||||
"@fontsource/manrope": "5.2.8",
|
||||
"@fontsource/noto-sans-sc": "5.2.9",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@openclaw/plugin-inspector": "0.3.11",
|
||||
"@radix-ui/react-avatar": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.16",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.17",
|
||||
|
||||
@@ -56,6 +56,7 @@ clawhub package explore --family code-plugin
|
||||
clawhub package inspect @openclaw/example-plugin
|
||||
clawhub package download @openclaw/example-plugin --tag latest
|
||||
clawhub package verify ./example-plugin-1.0.0.tgz --package @openclaw/example-plugin --version 1.0.0
|
||||
clawhub package validate ./example-plugin
|
||||
clawhub package publish openclaw/example-plugin
|
||||
clawhub package publish openclaw/example-plugin@v1.0.0
|
||||
clawhub package publish https://github.com/openclaw/example-plugin --dry-run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawhub",
|
||||
"version": "0.20.0",
|
||||
"version": "0.20.1",
|
||||
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
|
||||
"homepage": "https://clawhub.ai",
|
||||
"bugs": {
|
||||
@@ -38,6 +38,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "1.5.1",
|
||||
"@openclaw/plugin-inspector": "0.3.11",
|
||||
"arktype": "2.2.0",
|
||||
"commander": "15.0.0",
|
||||
"fflate": "0.8.3",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
cmdReportPackage,
|
||||
cmdTransferPackage,
|
||||
cmdUndeletePackage,
|
||||
cmdValidatePackage,
|
||||
cmdVerifyPackage,
|
||||
} from "./cli/commands/packages.js";
|
||||
import { cmdPublish } from "./cli/commands/publish.js";
|
||||
@@ -573,6 +574,20 @@ registerCommand(packageCmd, ["package", "verify"])
|
||||
});
|
||||
});
|
||||
|
||||
registerCommand(packageCmd, ["package", "validate"])
|
||||
.description("Validate a local plugin package with the bundled Plugin Inspector")
|
||||
.argument("<source>", "Package folder path")
|
||||
.option("--out <dir>", "Directory for Plugin Inspector reports", "reports")
|
||||
.option("--openclaw <path>", "Optional local OpenClaw checkout to inspect against")
|
||||
.option("--runtime", "Enable runtime capture; imports plugin code")
|
||||
.option("--allow-execute", "Allow runtime capture in an isolated workspace")
|
||||
.option("--no-mock-sdk", "Disable mocked OpenClaw SDK during runtime capture")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (source, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdValidatePackage(opts, source, options);
|
||||
});
|
||||
|
||||
registerCommand(packageCmd, ["package", "delete"])
|
||||
.description("Soft-delete a package and all releases")
|
||||
.argument("<name>", "Package name")
|
||||
|
||||
@@ -19,6 +19,18 @@ const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
const inspectorMocks = {
|
||||
pluginRoot: {
|
||||
runCheck: vi.fn(),
|
||||
},
|
||||
reports: {
|
||||
renderTextSummary: vi.fn((report: { status?: string }) => `Plugin Inspector: ${report.status}`),
|
||||
sanitizeArtifact: vi.fn((report: unknown) => report),
|
||||
},
|
||||
ci: {
|
||||
writeOutputs: vi.fn(),
|
||||
},
|
||||
};
|
||||
const originalOidcRequestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||
const originalOidcRequestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
|
||||
@@ -26,6 +38,7 @@ vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
vi.mock("@openclaw/plugin-inspector", () => inspectorMocks);
|
||||
|
||||
const {
|
||||
cmdDeletePackage,
|
||||
@@ -41,6 +54,7 @@ const {
|
||||
cmdReportPackage,
|
||||
cmdTransferPackage,
|
||||
cmdUndeletePackage,
|
||||
cmdValidatePackage,
|
||||
cmdVerifyPackage,
|
||||
} = await import("./packages");
|
||||
const {
|
||||
@@ -214,6 +228,100 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("package commands", () => {
|
||||
it("validates a local plugin package with bundled Plugin Inspector offline", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(join(folder, "package.json"), '{"name":"demo-plugin","version":"1.0.0"}\n');
|
||||
|
||||
inspectorMocks.pluginRoot.runCheck.mockResolvedValueOnce({
|
||||
report: { status: "pass", summary: { breakageCount: 0 } },
|
||||
paths: { jsonPath: join(folder, "reports", "plugin-inspector-report.json") },
|
||||
});
|
||||
|
||||
await cmdValidatePackage(makeOpts(workdir), "demo-plugin", {});
|
||||
|
||||
expect(inspectorMocks.pluginRoot.runCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowExecution: false,
|
||||
capture: false,
|
||||
configPath: expect.stringContaining("plugin-inspector.config.json"),
|
||||
mockSdk: true,
|
||||
openclawPath: false,
|
||||
outDir: "reports",
|
||||
pluginRoot: folder,
|
||||
}),
|
||||
);
|
||||
expect(inspectorMocks.ci.writeOutputs).toHaveBeenCalledWith(
|
||||
{ status: "pass", summary: { breakageCount: 0 } },
|
||||
{ cwd: join(folder, "reports"), outDir: "." },
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith("Plugin Inspector: pass");
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails package validation when Plugin Inspector reports hard breakages", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "broken-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(join(folder, "package.json"), '{"name":"broken-plugin","version":"1.0.0"}\n');
|
||||
inspectorMocks.pluginRoot.runCheck.mockResolvedValueOnce({
|
||||
report: { status: "fail", summary: { breakageCount: 1 } },
|
||||
paths: { jsonPath: join(folder, "reports", "plugin-inspector-report.json") },
|
||||
});
|
||||
|
||||
await expect(cmdValidatePackage(makeOpts(workdir), "broken-plugin", {})).rejects.toThrow(
|
||||
"Plugin Inspector found 1 hard error",
|
||||
);
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith("Plugin Inspector: fail");
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prints package validation JSON from the sanitized Plugin Inspector report", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "warning-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
'{"name":"warning-plugin","version":"1.0.0"}\n',
|
||||
);
|
||||
const report = {
|
||||
status: "pass",
|
||||
summary: { breakageCount: 0, warningCount: 1 },
|
||||
issues: [{ code: "legacy-hook", level: "warning" }],
|
||||
};
|
||||
inspectorMocks.pluginRoot.runCheck.mockResolvedValueOnce({
|
||||
report,
|
||||
paths: { jsonPath: join(folder, "reports", "plugin-inspector-report.json") },
|
||||
});
|
||||
inspectorMocks.reports.sanitizeArtifact.mockReturnValueOnce({
|
||||
status: "pass",
|
||||
issues: [{ code: "legacy-hook", level: "warning" }],
|
||||
});
|
||||
|
||||
await cmdValidatePackage(makeOpts(workdir), "warning-plugin", { json: true });
|
||||
|
||||
expect(mockWrite).toHaveBeenCalledWith(
|
||||
`${JSON.stringify(
|
||||
{ status: "pass", issues: [{ code: "legacy-hook", level: "warning" }] },
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("searches package catalog via /api/v1/packages/search", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
results: [
|
||||
@@ -2175,6 +2283,99 @@ describe("package commands", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("fails CLI publish on server Plugin Inspector hard errors", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "broken-plugin");
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "broken-plugin",
|
||||
displayName: "Broken Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(folder, "openclaw.plugin.json"),
|
||||
JSON.stringify({ id: "broken.plugin" }),
|
||||
);
|
||||
await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8");
|
||||
|
||||
httpMocks.apiRequestForm.mockRejectedValueOnce(
|
||||
new Error(
|
||||
"Plugin Inspector blocked publish: missing-expected-seam: missing expected registration registerTool",
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
cmdPublishPackage(makeOpts(workdir), "broken-plugin", {
|
||||
sourceRepo: "openclaw/broken-plugin",
|
||||
sourceCommit: "deadbeef",
|
||||
}),
|
||||
).rejects.toThrow("Plugin Inspector blocked publish");
|
||||
|
||||
expect(uiMocks.spinner.fail).toHaveBeenCalledWith(
|
||||
"Plugin Inspector blocked publish: missing-expected-seam: missing expected registration registerTool",
|
||||
);
|
||||
expect(uiMocks.spinner.succeed).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prints Plugin Inspector warnings for successful CLI publishes", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "warning-plugin");
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "warning-plugin",
|
||||
displayName: "Warning Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(folder, "openclaw.plugin.json"),
|
||||
JSON.stringify({ id: "warning.plugin" }),
|
||||
);
|
||||
await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8");
|
||||
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "pkg_1",
|
||||
releaseId: "rel_1",
|
||||
inspectorFindings: [
|
||||
{
|
||||
findingKind: "warning",
|
||||
code: "legacy-before-agent-start",
|
||||
issueClass: "deprecation-warning",
|
||||
message: "legacy before_agent_start hook is deprecated",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "warning-plugin", {
|
||||
sourceRepo: "openclaw/warning-plugin",
|
||||
sourceCommit: "abc123",
|
||||
});
|
||||
|
||||
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
|
||||
"OK. Published warning-plugin@1.0.0 (rel_1)",
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith("Plugin Inspector findings: 1 warning");
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"- WARNING legacy-before-agent-start (deprecation-warning): legacy before_agent_start hook is deprecated",
|
||||
);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-detects local git source metadata and matches the explicit payload", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(987_654_321);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
||||
import { ci, pluginRoot, reports } from "@openclaw/plugin-inspector";
|
||||
import ignore from "ignore";
|
||||
import mime from "mime";
|
||||
import semver from "semver";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
ApiV1PackageListResponseSchema,
|
||||
ApiV1PackageModerationStatusResponseSchema,
|
||||
ApiV1PackagePublishResponseSchema,
|
||||
type ApiV1PackagePublishResponse,
|
||||
ApiV1PackageReadinessResponseSchema,
|
||||
ApiV1PackageReportResponseSchema,
|
||||
ApiV1PackageResponseSchema,
|
||||
@@ -121,6 +123,15 @@ type PackagePackOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type PackageValidateOptions = {
|
||||
out?: string;
|
||||
openclaw?: string;
|
||||
runtime?: boolean;
|
||||
allowExecute?: boolean;
|
||||
mockSdk?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type PackageDownloadOptions = {
|
||||
version?: string;
|
||||
tag?: string;
|
||||
@@ -575,6 +586,124 @@ export async function cmdPackPackage(
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdValidatePackage(
|
||||
opts: GlobalOpts,
|
||||
sourceArg: string,
|
||||
options: PackageValidateOptions = {},
|
||||
) {
|
||||
if (!sourceArg?.trim()) fail("Path required");
|
||||
const resolvedSource = await resolveSourceInput(sourceArg, {
|
||||
workdir: opts.workdir,
|
||||
localWorkdirs: [process.cwd(), opts.workdir],
|
||||
});
|
||||
if (resolvedSource.kind !== "local") fail("Path must be a package folder");
|
||||
const sourcePath = resolvedSource.path;
|
||||
const sourceStat = await stat(sourcePath).catch(() => null);
|
||||
if (!sourceStat?.isDirectory()) fail("Path must be a package folder");
|
||||
|
||||
const outDir = options.out?.trim() || "reports";
|
||||
const openclawPath = options.openclaw?.trim() ? resolve(opts.workdir, options.openclaw) : false;
|
||||
const generatedConfig = await createPluginInspectorConfigIfNeeded(sourcePath);
|
||||
let report: Awaited<ReturnType<typeof pluginRoot.runCheck>>["report"];
|
||||
let paths: Awaited<ReturnType<typeof pluginRoot.runCheck>>["paths"];
|
||||
try {
|
||||
const result = await pluginRoot.runCheck({
|
||||
allowExecution: options.allowExecute === true,
|
||||
capture: options.runtime === true,
|
||||
configPath: generatedConfig?.path,
|
||||
mockSdk: options.mockSdk !== false,
|
||||
openclawPath,
|
||||
outDir,
|
||||
pluginRoot: sourcePath,
|
||||
});
|
||||
report = result.report;
|
||||
paths = result.paths;
|
||||
} finally {
|
||||
if (generatedConfig) {
|
||||
await rm(generatedConfig.dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await ci.writeOutputs(report, {
|
||||
cwd: dirname(paths.jsonPath),
|
||||
outDir: ".",
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(reports.sanitizeArtifact(report), null, 2)}\n`);
|
||||
} else {
|
||||
console.log(reports.renderTextSummary(report, { artifacts: paths }));
|
||||
}
|
||||
|
||||
if (reportStatus(report) !== "pass") {
|
||||
const breakageCount = reportBreakageCount(report);
|
||||
throw new Error(
|
||||
`Plugin Inspector found ${breakageCount} hard error${breakageCount === 1 ? "" : "s"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createPluginInspectorConfigIfNeeded(sourcePath: string) {
|
||||
if (
|
||||
(await fileExists(join(sourcePath, "plugin-inspector.config.json"))) ||
|
||||
(await fileExists(join(sourcePath, ".plugin-inspector.json")))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const packageJson = await readJsonFile(join(sourcePath, "package.json"));
|
||||
const pluginManifest = await readJsonFile(join(sourcePath, "openclaw.plugin.json"));
|
||||
if (!packageJson && !pluginManifest) {
|
||||
return null;
|
||||
}
|
||||
if (hasPackagePluginInspectorConfig(packageJson)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawName =
|
||||
packageJsonString(packageJson, "name") ??
|
||||
packageJsonString(pluginManifest, "id") ??
|
||||
basename(sourcePath);
|
||||
const configDir = await mkdtemp(join(tmpdir(), "clawhub-plugin-inspector-config-"));
|
||||
const configPath = join(configDir, "plugin-inspector.config.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
plugin: {
|
||||
id: pluginInspectorFixtureId(rawName),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
return { dir: configDir, path: configPath };
|
||||
}
|
||||
|
||||
async function fileExists(path: string) {
|
||||
return Boolean(await stat(path).catch(() => null));
|
||||
}
|
||||
|
||||
function hasPackagePluginInspectorConfig(packageJson: Record<string, unknown> | null) {
|
||||
if (!packageJson) return false;
|
||||
return (
|
||||
isPlainRecord(packageJson.pluginInspector) || isPlainRecord(packageJson["plugin-inspector"])
|
||||
);
|
||||
}
|
||||
|
||||
function pluginInspectorFixtureId(rawName: string) {
|
||||
return (
|
||||
rawName
|
||||
.split("/")
|
||||
.pop()
|
||||
?.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "published-plugin"
|
||||
);
|
||||
}
|
||||
|
||||
async function createClawPackFromFolder(options: {
|
||||
sourcePath: string;
|
||||
packDestination: string;
|
||||
@@ -726,12 +855,21 @@ export async function cmdPublishPackage(
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ...plan.output, releaseId: result.releaseId }, null, 2)}\n`,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
...plan.output,
|
||||
releaseId: result.releaseId,
|
||||
inspectorFindings: result.inspectorFindings,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
} else {
|
||||
spinner?.succeed(
|
||||
`OK. Published ${plan.payload.name}@${plan.payload.version} (${result.releaseId})`,
|
||||
);
|
||||
printPackageInspectorFindings(result);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner?.fail(formatError(error));
|
||||
@@ -742,6 +880,25 @@ export async function cmdPublishPackage(
|
||||
}
|
||||
}
|
||||
|
||||
function printPackageInspectorFindings(result: ApiV1PackagePublishResponse) {
|
||||
const findings = result.inspectorFindings ?? [];
|
||||
if (findings.length === 0) return;
|
||||
const errorCount = findings.filter((finding) => finding.findingKind === "error").length;
|
||||
const warningCount = findings.length - errorCount;
|
||||
const parts = [
|
||||
warningCount > 0 ? `${warningCount} warning${warningCount === 1 ? "" : "s"}` : null,
|
||||
errorCount > 0 ? `${errorCount} error${errorCount === 1 ? "" : "s"}` : null,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
console.log(`Plugin Inspector findings: ${parts.join(", ")}`);
|
||||
for (const finding of findings.slice(0, 10)) {
|
||||
const label = finding.issueClass ? `${finding.code} (${finding.issueClass})` : finding.code;
|
||||
console.log(`- ${finding.findingKind.toUpperCase()} ${label}: ${finding.message}`);
|
||||
}
|
||||
if (findings.length > 10) {
|
||||
console.log(`- ...and ${findings.length - 10} more findings`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdDownloadPackage(
|
||||
opts: GlobalOpts,
|
||||
packageName: string,
|
||||
@@ -1243,6 +1400,20 @@ function normalizePackageNameOrFail(raw: string) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function reportStatus(report: unknown): string | null {
|
||||
return isPlainRecord(report) && typeof report.status === "string" ? report.status : null;
|
||||
}
|
||||
|
||||
function reportBreakageCount(report: unknown): number {
|
||||
if (!isPlainRecord(report) || !isPlainRecord(report.summary)) return 0;
|
||||
const value = report.summary.breakageCount;
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function spinnerText(spinner: ReturnType<typeof createSpinner> | null, text: string) {
|
||||
if (spinner) spinner.text = text;
|
||||
}
|
||||
|
||||
@@ -841,6 +841,18 @@ export const ApiV1PackagePublishResponseSchema = type({
|
||||
ok: "true",
|
||||
packageId: "string",
|
||||
releaseId: "string",
|
||||
inspectorFindings: type({
|
||||
findingKind: '"warning"|"error"',
|
||||
code: "string",
|
||||
severity: "string?",
|
||||
level: "string?",
|
||||
issueClass: "string?",
|
||||
message: "string",
|
||||
inspectorVersion: "string?",
|
||||
targetOpenClawVersion: "string?",
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
});
|
||||
export type ApiV1PackagePublishResponse = (typeof ApiV1PackagePublishResponseSchema)[inferred];
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
declare module "@openclaw/plugin-inspector" {
|
||||
export type PluginInspectorReport = {
|
||||
status?: string;
|
||||
summary?: {
|
||||
breakageCount?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type PluginInspectorPaths = {
|
||||
jsonPath: string;
|
||||
markdownPath?: string;
|
||||
issuesPath?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export const pluginRoot: {
|
||||
runCheck(options: {
|
||||
allowExecution?: boolean;
|
||||
capture?: boolean;
|
||||
configPath?: string;
|
||||
mockSdk?: boolean;
|
||||
openclawPath?: string | false;
|
||||
outDir?: string;
|
||||
pluginRoot?: string;
|
||||
}): Promise<{
|
||||
report: PluginInspectorReport;
|
||||
paths: PluginInspectorPaths;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const reports: {
|
||||
renderTextSummary(report: PluginInspectorReport, options?: Record<string, unknown>): string;
|
||||
sanitizeArtifact(report: PluginInspectorReport): unknown;
|
||||
};
|
||||
|
||||
export const ci: {
|
||||
writeOutputs(
|
||||
report: PluginInspectorReport,
|
||||
options?: Record<string, unknown>,
|
||||
): Promise<unknown>;
|
||||
};
|
||||
}
|
||||
Vendored
+10
@@ -1050,6 +1050,16 @@ export declare const ApiV1PackagePublishResponseSchema: import("arktype/internal
|
||||
ok: true;
|
||||
packageId: string;
|
||||
releaseId: string;
|
||||
inspectorFindings?: {
|
||||
findingKind: "error" | "warning";
|
||||
code: string;
|
||||
message: string;
|
||||
severity?: string | undefined;
|
||||
level?: string | undefined;
|
||||
issueClass?: string | undefined;
|
||||
inspectorVersion?: string | undefined;
|
||||
targetOpenClawVersion?: string | undefined;
|
||||
}[] | undefined;
|
||||
}, {}>;
|
||||
export type ApiV1PackagePublishResponse = (typeof ApiV1PackagePublishResponseSchema)[inferred];
|
||||
export declare const PackageTrustedPublisherUpsertRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
|
||||
Vendored
+12
@@ -686,6 +686,18 @@ export const ApiV1PackagePublishResponseSchema = type({
|
||||
ok: "true",
|
||||
packageId: "string",
|
||||
releaseId: "string",
|
||||
inspectorFindings: type({
|
||||
findingKind: '"warning"|"error"',
|
||||
code: "string",
|
||||
severity: "string?",
|
||||
level: "string?",
|
||||
issueClass: "string?",
|
||||
message: "string",
|
||||
inspectorVersion: "string?",
|
||||
targetOpenClawVersion: "string?",
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
});
|
||||
export const PackageTrustedPublisherUpsertRequestSchema = type({
|
||||
repository: "string",
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -871,6 +871,18 @@ export const ApiV1PackagePublishResponseSchema = type({
|
||||
ok: "true",
|
||||
packageId: "string",
|
||||
releaseId: "string",
|
||||
inspectorFindings: type({
|
||||
findingKind: '"warning"|"error"',
|
||||
code: "string",
|
||||
severity: "string?",
|
||||
level: "string?",
|
||||
issueClass: "string?",
|
||||
message: "string",
|
||||
inspectorVersion: "string?",
|
||||
targetOpenClawVersion: "string?",
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
});
|
||||
export type ApiV1PackagePublishResponse = (typeof ApiV1PackagePublishResponseSchema)[inferred];
|
||||
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type ClaimItem = {
|
||||
packageId: string;
|
||||
releaseId: string;
|
||||
ownerUserId?: string;
|
||||
ownerPublisherId?: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
artifactKind: string;
|
||||
downloadUrl: string;
|
||||
};
|
||||
|
||||
type ClaimResponse = {
|
||||
ok: true;
|
||||
leased: boolean;
|
||||
dryRun?: boolean;
|
||||
nextCursor?: string | null;
|
||||
items: ClaimItem[];
|
||||
};
|
||||
|
||||
type NormalizedFinding = {
|
||||
id?: string;
|
||||
code: string;
|
||||
level: string;
|
||||
severity?: string;
|
||||
issueClass?: string;
|
||||
compatStatus?: string;
|
||||
deprecated?: boolean;
|
||||
message: string;
|
||||
evidence?: string[];
|
||||
fixture?: string;
|
||||
decision?: string;
|
||||
};
|
||||
|
||||
type ImpactEntry = {
|
||||
packageName: string;
|
||||
version: string;
|
||||
ownerUserId?: string;
|
||||
ownerPublisherId?: string;
|
||||
findingCount: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
targetOpenClawVersion?: string;
|
||||
findings: NormalizedFinding[];
|
||||
};
|
||||
|
||||
const siteUrl = (process.env.CLAWHUB_SITE_URL ?? "https://clawhub.ai").replace(/\/+$/, "");
|
||||
const token = process.env.CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN;
|
||||
const batchSize = process.env.PLUGIN_INSPECTOR_BATCH_SIZE ?? "25";
|
||||
const dryRun = parseBoolean(process.env.PLUGIN_INSPECTOR_DRY_RUN);
|
||||
const dryRunMaxBatches = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
Number.parseInt(process.env.PLUGIN_INSPECTOR_DRY_RUN_MAX_BATCHES ?? "20", 10) || 20,
|
||||
100,
|
||||
),
|
||||
);
|
||||
const inspectorVersion =
|
||||
process.env.PLUGIN_INSPECTOR_VERSION ?? resolveBundledPluginInspectorVersion();
|
||||
const artifactRoot =
|
||||
process.env.PLUGIN_INSPECTOR_ARTIFACT_DIR ?? "plugin-inspector-nightly-reports";
|
||||
const repoRoot = path.resolve(process.env.GITHUB_WORKSPACE ?? process.cwd());
|
||||
const clawhubCliEntry = path.join(repoRoot, "packages", "clawhub", "src", "cli.ts");
|
||||
|
||||
if (!token) throw new Error("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN is required");
|
||||
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
|
||||
let hadWorkerFailure = false;
|
||||
const impactEntries: ImpactEntry[] = [];
|
||||
let claimed = 0;
|
||||
let scanned = 0;
|
||||
let cursor: string | null = null;
|
||||
let batches = 0;
|
||||
let truncated = false;
|
||||
|
||||
do {
|
||||
const claim = await claimBatch(cursor);
|
||||
batches += 1;
|
||||
cursor = claim.nextCursor ?? null;
|
||||
claimed += claim.items.length;
|
||||
|
||||
for (const item of claim.items) {
|
||||
const workRoot = path.join(
|
||||
tmpdir(),
|
||||
`clawhub-plugin-inspector-nightly-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
const pluginRoot = path.join(workRoot, "plugin");
|
||||
const reportDir = path.resolve(
|
||||
artifactRoot,
|
||||
safeArtifactName(`${item.packageName}-${item.version}`),
|
||||
);
|
||||
await mkdir(pluginRoot, { recursive: true });
|
||||
await mkdir(reportDir, { recursive: true });
|
||||
try {
|
||||
const artifactPath = path.join(
|
||||
workRoot,
|
||||
item.artifactKind === "npm-pack" ? "plugin.tgz" : "plugin.zip",
|
||||
);
|
||||
const artifact = await fetch(item.downloadUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!artifact.ok) {
|
||||
throw new Error(`download failed ${artifact.status}: ${await artifact.text()}`);
|
||||
}
|
||||
await writeFile(artifactPath, Buffer.from(await artifact.arrayBuffer()));
|
||||
if (item.artifactKind === "npm-pack") {
|
||||
run("tar", ["-xzf", artifactPath, "-C", pluginRoot, "--strip-components=1"]);
|
||||
} else {
|
||||
run("unzip", ["-q", artifactPath, "-d", pluginRoot]);
|
||||
}
|
||||
const scanRoot =
|
||||
item.artifactKind === "legacy-zip" && existsSync(path.join(pluginRoot, "package"))
|
||||
? path.join(pluginRoot, "package")
|
||||
: pluginRoot;
|
||||
await writeSyntheticConfigIfNeeded(scanRoot, item.packageName);
|
||||
const scan = spawnSync(
|
||||
"bun",
|
||||
[clawhubCliEntry, "package", "validate", scanRoot, "--out", reportDir, "--json"],
|
||||
{ cwd: repoRoot, encoding: "utf8" },
|
||||
);
|
||||
await writeFile(path.join(reportDir, "stdout.txt"), scan.stdout ?? "");
|
||||
await writeFile(path.join(reportDir, "stderr.txt"), scan.stderr ?? "");
|
||||
const reportPath = path.join(reportDir, "plugin-inspector-report.json");
|
||||
if (!existsSync(reportPath)) {
|
||||
throw new Error(
|
||||
scan.stderr || scan.stdout || `clawhub package validate exited ${scan.status}`,
|
||||
);
|
||||
}
|
||||
const report = JSON.parse(await readFile(reportPath, "utf8"));
|
||||
const findings = normalizeFindings(report);
|
||||
const targetOpenClawVersion = extractTargetOpenClawVersion(report.targetOpenClaw);
|
||||
scanned += 1;
|
||||
if (dryRun) {
|
||||
impactEntries.push(toImpactEntry(item, findings, targetOpenClawVersion));
|
||||
}
|
||||
if (!dryRun) {
|
||||
await postJson(`${siteUrl}/api/v1/package-inspector/results`, {
|
||||
packageId: item.packageId,
|
||||
releaseId: item.releaseId,
|
||||
inspectorVersion,
|
||||
targetOpenClawVersion,
|
||||
findings,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
hadWorkerFailure = true;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await writeFile(path.join(reportDir, "error.txt"), message);
|
||||
console.error(
|
||||
`Nightly Plugin Inspector worker failed for ${item.packageName}@${item.version}`,
|
||||
);
|
||||
console.error(message);
|
||||
} finally {
|
||||
await rm(workRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun) break;
|
||||
if (cursor && batches >= dryRunMaxBatches) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
} while (dryRun && cursor);
|
||||
|
||||
if (dryRun) {
|
||||
const summary = summarizeImpact({
|
||||
claimed,
|
||||
scanned,
|
||||
batches,
|
||||
truncated,
|
||||
nextCursor: cursor,
|
||||
entries: impactEntries,
|
||||
});
|
||||
await writeFile(
|
||||
path.join(artifactRoot, "impact-summary.json"),
|
||||
`${JSON.stringify(summary, null, 2)}\n`,
|
||||
);
|
||||
await writeFile(path.join(artifactRoot, "impact-summary.md"), renderImpactMarkdown(summary));
|
||||
console.log(
|
||||
`Dry run scanned ${summary.scannedReleases} latest plugin releases: ${summary.pluginsWithErrors} with errors, ${summary.pluginsWithWarnings} with warnings, ${summary.impactedOwners} owner(s) impacted.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (hadWorkerFailure) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function claimBatch(cursor: string | null) {
|
||||
const url = new URL(`${siteUrl}/api/v1/package-inspector/claim`);
|
||||
url.searchParams.set("batchSize", batchSize);
|
||||
url.searchParams.set("dryRun", dryRun ? "true" : "false");
|
||||
if (dryRun && cursor) url.searchParams.set("cursor", cursor);
|
||||
return await postJson<ClaimResponse>(url.toString(), {});
|
||||
}
|
||||
|
||||
async function postJson<T = unknown>(url: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`POST ${url} failed ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function writeSyntheticConfigIfNeeded(root: string, packageName: string) {
|
||||
if (
|
||||
existsSync(path.join(root, "plugin-inspector.config.json")) ||
|
||||
existsSync(path.join(root, ".plugin-inspector.json"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const packageJson = await readJsonIfExists(path.join(root, "package.json"));
|
||||
if (hasInspectorConfig(packageJson)) {
|
||||
return;
|
||||
}
|
||||
await writeFile(
|
||||
path.join(root, ".plugin-inspector.json"),
|
||||
`${JSON.stringify({ version: 1, plugin: { id: safeArtifactName(packageName) } }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function readJsonIfExists(filePath: string) {
|
||||
if (!existsSync(filePath)) return null;
|
||||
return JSON.parse(await readFile(filePath, "utf8")) as unknown;
|
||||
}
|
||||
|
||||
function hasInspectorConfig(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return isPlainObject(record.pluginInspector) || isPlainObject(record["plugin-inspector"]);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown) {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function normalizeFindings(report: Record<string, unknown>): NormalizedFinding[] {
|
||||
const issues = Array.isArray(report.issues)
|
||||
? report.issues.map((issue) => normalizeFinding(issue, "warning")).filter(isFinding)
|
||||
: [];
|
||||
if (issues.length > 0) return issues;
|
||||
return [
|
||||
...normalizeFindingArray(report.breakages, "breakage"),
|
||||
...normalizeFindingArray(report.warnings, "warning"),
|
||||
...normalizeFindingArray(report.suggestions, "warning"),
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeFindingArray(value: unknown, fallbackLevel: string) {
|
||||
return Array.isArray(value)
|
||||
? value.map((finding) => normalizeFinding(finding, fallbackLevel)).filter(isFinding)
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeFinding(value: unknown, fallbackLevel: string): NormalizedFinding | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const message = stringValue(record.message) ?? stringValue(record.title);
|
||||
const code = stringValue(record.code) ?? "plugin-inspector-finding";
|
||||
if (!message) return null;
|
||||
const level =
|
||||
stringValue(record.level) ??
|
||||
(record.status === "blocking" || fallbackLevel === "breakage" ? "breakage" : "warning");
|
||||
return {
|
||||
id: stringValue(record.id),
|
||||
code,
|
||||
level,
|
||||
severity: stringValue(record.severity),
|
||||
issueClass: stringValue(record.issueClass),
|
||||
compatStatus: stringValue(record.compatStatus),
|
||||
deprecated: typeof record.deprecated === "boolean" ? record.deprecated : undefined,
|
||||
message,
|
||||
evidence: Array.isArray(record.evidence) ? record.evidence.map(String).slice(0, 12) : undefined,
|
||||
fixture: stringValue(record.fixture),
|
||||
decision: stringValue(record.decision),
|
||||
};
|
||||
}
|
||||
|
||||
function isFinding(value: NormalizedFinding | null): value is NormalizedFinding {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
function toImpactEntry(
|
||||
item: ClaimItem,
|
||||
findings: NormalizedFinding[],
|
||||
targetOpenClawVersion: string | undefined,
|
||||
): ImpactEntry {
|
||||
let errorCount = 0;
|
||||
let warningCount = 0;
|
||||
for (const finding of findings) {
|
||||
if (isErrorFinding(finding)) errorCount += 1;
|
||||
else warningCount += 1;
|
||||
}
|
||||
return {
|
||||
packageName: item.packageName,
|
||||
version: item.version,
|
||||
ownerUserId: item.ownerUserId,
|
||||
ownerPublisherId: item.ownerPublisherId,
|
||||
findingCount: findings.length,
|
||||
errorCount,
|
||||
warningCount,
|
||||
targetOpenClawVersion,
|
||||
findings,
|
||||
};
|
||||
}
|
||||
|
||||
function isErrorFinding(finding: Pick<NormalizedFinding, "level" | "severity">) {
|
||||
return finding.level === "breakage" || finding.level === "error" || finding.severity === "P0";
|
||||
}
|
||||
|
||||
function summarizeImpact(args: {
|
||||
claimed: number;
|
||||
scanned: number;
|
||||
batches: number;
|
||||
truncated: boolean;
|
||||
nextCursor: string | null;
|
||||
entries: ImpactEntry[];
|
||||
}) {
|
||||
const impactedOwners = new Set<string>();
|
||||
const frequency = new Map<
|
||||
string,
|
||||
{ code: string; count: number; errorCount: number; warningCount: number }
|
||||
>();
|
||||
let pluginsWithErrors = 0;
|
||||
let pluginsWithWarnings = 0;
|
||||
let totalErrors = 0;
|
||||
let totalWarnings = 0;
|
||||
for (const entry of args.entries) {
|
||||
if (entry.findingCount > 0 && entry.ownerUserId) impactedOwners.add(entry.ownerUserId);
|
||||
if (entry.errorCount > 0) pluginsWithErrors += 1;
|
||||
if (entry.warningCount > 0) pluginsWithWarnings += 1;
|
||||
totalErrors += entry.errorCount;
|
||||
totalWarnings += entry.warningCount;
|
||||
for (const finding of entry.findings) {
|
||||
const current = frequency.get(finding.code) ?? {
|
||||
code: finding.code,
|
||||
count: 0,
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
};
|
||||
current.count += 1;
|
||||
if (isErrorFinding(finding)) current.errorCount += 1;
|
||||
else current.warningCount += 1;
|
||||
frequency.set(finding.code, current);
|
||||
}
|
||||
}
|
||||
return {
|
||||
dryRun,
|
||||
generatedAt: new Date().toISOString(),
|
||||
siteUrl,
|
||||
inspectorVersion,
|
||||
batchSize: Number.parseInt(batchSize, 10) || batchSize,
|
||||
batches: args.batches,
|
||||
truncated: args.truncated,
|
||||
nextCursor: args.nextCursor,
|
||||
claimedReleases: args.claimed,
|
||||
scannedReleases: args.scanned,
|
||||
pluginsWithFindings: args.entries.filter((entry) => entry.findingCount > 0).length,
|
||||
pluginsWithErrors,
|
||||
pluginsWithWarnings,
|
||||
impactedOwners: impactedOwners.size,
|
||||
totalErrors,
|
||||
totalWarnings,
|
||||
findingFrequency: [...frequency.values()].sort((a, b) => b.count - a.count),
|
||||
packages: args.entries.filter((entry) => entry.findingCount > 0),
|
||||
};
|
||||
}
|
||||
|
||||
function renderImpactMarkdown(summary: ReturnType<typeof summarizeImpact>) {
|
||||
const lines = [
|
||||
"# Plugin Inspector Nightly Dry Run",
|
||||
"",
|
||||
`- Generated: ${summary.generatedAt}`,
|
||||
`- Site: ${summary.siteUrl}`,
|
||||
`- Inspector: ${summary.inspectorVersion}`,
|
||||
`- Scanned latest releases: ${summary.scannedReleases}`,
|
||||
`- Plugins with errors: ${summary.pluginsWithErrors}`,
|
||||
`- Plugins with warnings: ${summary.pluginsWithWarnings}`,
|
||||
`- Impacted owners: ${summary.impactedOwners}`,
|
||||
`- Truncated: ${summary.truncated ? "yes" : "no"}`,
|
||||
"",
|
||||
"## Finding Frequency",
|
||||
"",
|
||||
];
|
||||
if (summary.findingFrequency.length === 0) {
|
||||
lines.push("No findings.");
|
||||
} else {
|
||||
lines.push("| Code | Count | Errors | Warnings |", "| --- | ---: | ---: | ---: |");
|
||||
for (const finding of summary.findingFrequency) {
|
||||
lines.push(
|
||||
`| ${finding.code} | ${finding.count} | ${finding.errorCount} | ${finding.warningCount} |`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("", "## Impacted Plugins", "");
|
||||
if (summary.packages.length === 0) {
|
||||
lines.push("No impacted plugins.");
|
||||
} else {
|
||||
lines.push(
|
||||
"| Plugin | Version | Errors | Warnings | Target OpenClaw |",
|
||||
"| --- | --- | ---: | ---: | --- |",
|
||||
);
|
||||
for (const entry of summary.packages) {
|
||||
lines.push(
|
||||
`| ${entry.packageName} | ${entry.version} | ${entry.errorCount} | ${entry.warningCount} | ${entry.targetOpenClawVersion ?? ""} |`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function extractTargetOpenClawVersion(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
stringValue(record.version) ??
|
||||
stringValue(record.openclawVersion) ??
|
||||
stringValue(record.label) ??
|
||||
stringValue(record.status)
|
||||
);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function safeArtifactName(value: string) {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "plugin"
|
||||
);
|
||||
}
|
||||
|
||||
function parseBoolean(value: string | undefined) {
|
||||
return ["1", "true", "yes", "on"].includes((value ?? "").trim().toLowerCase());
|
||||
}
|
||||
|
||||
function run(command: string, args: string[]) {
|
||||
const result = spawnSync(command, args, { stdio: "pipe", encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBundledPluginInspectorVersion() {
|
||||
const require = createRequire(import.meta.url);
|
||||
const entry = require.resolve("@openclaw/plugin-inspector");
|
||||
const packageJsonPath = path.resolve(path.dirname(entry), "..", "package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
version?: unknown;
|
||||
};
|
||||
if (typeof packageJson.version !== "string" || !packageJson.version.trim()) {
|
||||
throw new Error("Unable to resolve bundled @openclaw/plugin-inspector version");
|
||||
}
|
||||
return packageJson.version.trim();
|
||||
}
|
||||
@@ -92,14 +92,6 @@ async function resolveAppPort() {
|
||||
throw new Error(`No available preview port found starting at ${requested}.`);
|
||||
}
|
||||
|
||||
function requireLocalUrlPort(url: string, label: string) {
|
||||
const parsed = new URL(url);
|
||||
if (!parsed.port) {
|
||||
throw new Error(`${label} must include an explicit port: ${url}`);
|
||||
}
|
||||
return parsed.port;
|
||||
}
|
||||
|
||||
function buildAuthKeys() {
|
||||
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const privatePem = privateKey.export({ type: "pkcs8", format: "pem" });
|
||||
@@ -137,6 +129,24 @@ function devAuthDeploymentMarker(deployment: string) {
|
||||
return deployment.startsWith("anonymous-") ? `anonymous:${deployment}` : deployment;
|
||||
}
|
||||
|
||||
function getLocalUrlPort(url: string, label: string) {
|
||||
const parsed = new URL(url);
|
||||
const isLocalhost =
|
||||
parsed.hostname === "127.0.0.1" ||
|
||||
parsed.hostname === "localhost" ||
|
||||
parsed.hostname === "::1" ||
|
||||
parsed.hostname === "[::1]";
|
||||
if (!isLocalhost) {
|
||||
throw new Error(`${label} must be a localhost URL for the local-auth runner: ${url}`);
|
||||
}
|
||||
|
||||
const port = Number(parsed.port);
|
||||
if (!Number.isInteger(port) || port <= 0) {
|
||||
throw new Error(`${label} must include an explicit port: ${url}`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function spawnManaged(command: string, args: string[], env: NodeJS.ProcessEnv) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
@@ -320,10 +330,9 @@ async function main() {
|
||||
const appUrl = `http://127.0.0.1:${appPort}`;
|
||||
const convexUrl = runnerConfig.convexUrl;
|
||||
const convexSiteUrl = runnerConfig.convexSiteUrl;
|
||||
const convexCloudPort = requireLocalUrlPort(convexUrl, "PLAYWRIGHT_LOCAL_AUTH_CONVEX_URL");
|
||||
const convexSitePort = requireLocalUrlPort(
|
||||
convexSiteUrl,
|
||||
"PLAYWRIGHT_LOCAL_AUTH_CONVEX_SITE_URL",
|
||||
const convexCloudPort = String(getLocalUrlPort(convexUrl, "PLAYWRIGHT_LOCAL_AUTH_CONVEX_URL"));
|
||||
const convexSitePort = String(
|
||||
getLocalUrlPort(convexSiteUrl, "PLAYWRIGHT_LOCAL_AUTH_CONVEX_SITE_URL"),
|
||||
);
|
||||
if (await isReachable(convexUrl)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { getFunctionName } from "convex/server";
|
||||
import type { AnchorHTMLAttributes, ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ const isRateLimitedPackageApiErrorMock = vi.fn(
|
||||
);
|
||||
const useQueryMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
let pathnameMock = "/plugins/demo-plugin";
|
||||
|
||||
type PluginDetailLoaderData = {
|
||||
detail: PackageDetailResponse;
|
||||
@@ -63,10 +65,8 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
select,
|
||||
}: {
|
||||
select?: (state: { location: { pathname: string } }) => string;
|
||||
}) =>
|
||||
select
|
||||
? select({ location: { pathname: `/plugins/${paramsMock.name}` } })
|
||||
: `/plugins/${paramsMock.name}`,
|
||||
}) => (select ? select({ location: { pathname: pathnameMock } }) : pathnameMock),
|
||||
Outlet: () => <div data-testid="nested-plugin-route" />,
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
@@ -128,6 +128,8 @@ async function loadRoute() {
|
||||
describe("plugin detail route", () => {
|
||||
beforeEach(() => {
|
||||
paramsMock = { name: "demo-plugin" };
|
||||
pathnameMock = "/plugins/demo-plugin";
|
||||
window.location.hash = "";
|
||||
vi.mocked(fetchPackageDetail).mockReset();
|
||||
vi.mocked(fetchPackageReadme).mockReset();
|
||||
vi.mocked(fetchPackageVersion).mockReset();
|
||||
@@ -419,14 +421,13 @@ describe("plugin detail route", () => {
|
||||
sidebarMetadata?.querySelectorAll(".sidebar-metadata-label") ?? [],
|
||||
(label) => label.textContent?.trim(),
|
||||
);
|
||||
const capabilitiesTab = screen.getByRole("tab", { name: "Capabilities" });
|
||||
const securityAuditLabelIndex = sidebarLabels.findIndex((label) =>
|
||||
label?.startsWith("Security audit"),
|
||||
);
|
||||
expect(securityAuditLabelIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(securityAuditLabelIndex).toBeGreaterThan(sidebarLabels.indexOf("Downloads"));
|
||||
fireEvent.click(capabilitiesTab);
|
||||
expect(screen.getByText("Tags")).toBeTruthy();
|
||||
expect(screen.queryByRole("tab", { name: "Capabilities" })).toBeNull();
|
||||
expect(screen.queryByRole("tab", { name: "Verification" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render owner-only plugin scanner rerun state in the detail security summary", async () => {
|
||||
@@ -596,6 +597,205 @@ describe("plugin detail route", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a public incompatibility alert without exposing validation outputs", async () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
});
|
||||
useQueryMock.mockImplementation((query: unknown) => {
|
||||
const name = getFunctionName(query as never);
|
||||
if (name === "packages:getPackageInspectorValidationSummaryPublic") {
|
||||
return {
|
||||
findingCount: 2,
|
||||
errorCount: 1,
|
||||
warningCount: 1,
|
||||
incompatibleAfterOpenClawVersion: "0.9.0",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
loaderDataMock = {
|
||||
detail: {
|
||||
package: {
|
||||
...loaderDataMock.detail.package!,
|
||||
latestVersion: "1.0.0",
|
||||
},
|
||||
owner: null,
|
||||
},
|
||||
version: {
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "Initial release",
|
||||
distTags: ["latest"],
|
||||
files: [],
|
||||
compatibility: null,
|
||||
capabilities: null,
|
||||
verification: null,
|
||||
artifact: null,
|
||||
sha256hash: null,
|
||||
vtAnalysis: null,
|
||||
llmAnalysis: null,
|
||||
staticScan: null,
|
||||
},
|
||||
},
|
||||
readme: null,
|
||||
rateLimited: null,
|
||||
};
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(
|
||||
screen.getByText("This plugin is incompatible with OpenClaw versions greater than 0.9.0."),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole("tab", { name: /Validation/ })).toBeNull();
|
||||
expect(screen.queryByText("missing-expected-seam")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows validation outputs to plugin managers on the validation tab", async () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:owner" },
|
||||
});
|
||||
useQueryMock.mockImplementation((query: unknown) => {
|
||||
const name = getFunctionName(query as never);
|
||||
if (name === "packages:getManageContext") {
|
||||
return {
|
||||
package: { name: "demo-plugin", displayName: "Demo Plugin" },
|
||||
latestRelease: { version: "1.0.0" },
|
||||
};
|
||||
}
|
||||
if (name === "packages:getPackageInspectorValidationSummaryPublic") {
|
||||
return {
|
||||
findingCount: 2,
|
||||
errorCount: 1,
|
||||
warningCount: 1,
|
||||
incompatibleAfterOpenClawVersion: "0.9.0",
|
||||
};
|
||||
}
|
||||
if (name === "packages:listPackageInspectorWarningsForManager") {
|
||||
return [
|
||||
{
|
||||
packageName: "demo-plugin",
|
||||
version: "1.0.0",
|
||||
findingKind: "warning",
|
||||
code: "legacy-before-agent-start",
|
||||
issueClass: "deprecation-warning",
|
||||
severity: "P2",
|
||||
message: "legacy before_agent_start hook is deprecated",
|
||||
evidence: ["src/index.ts:4"],
|
||||
inspectorVersion: "0.4.0",
|
||||
targetOpenClawVersion: "0.9.0",
|
||||
scanSource: "nightly",
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
packageName: "demo-plugin",
|
||||
version: "1.0.0",
|
||||
findingKind: "error",
|
||||
code: "missing-expected-seam",
|
||||
issueClass: "compatibility-error",
|
||||
severity: "P0",
|
||||
message: "registerTool is no longer available",
|
||||
evidence: ["dist/index.js:2"],
|
||||
inspectorVersion: "0.4.0",
|
||||
targetOpenClawVersion: "0.9.0",
|
||||
scanSource: "nightly",
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
loaderDataMock = {
|
||||
detail: {
|
||||
package: {
|
||||
...loaderDataMock.detail.package!,
|
||||
latestVersion: "1.0.0",
|
||||
},
|
||||
owner: null,
|
||||
},
|
||||
version: {
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "Initial release",
|
||||
distTags: ["latest"],
|
||||
files: [],
|
||||
compatibility: null,
|
||||
capabilities: null,
|
||||
verification: null,
|
||||
artifact: null,
|
||||
sha256hash: null,
|
||||
vtAnalysis: null,
|
||||
llmAnalysis: null,
|
||||
staticScan: null,
|
||||
},
|
||||
},
|
||||
readme: null,
|
||||
rateLimited: null,
|
||||
};
|
||||
window.location.hash = "#validation";
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByRole("tab", { name: "Validation (2)" })).toBeTruthy();
|
||||
expect(screen.queryByRole("link", { name: "2 warnings" })).toBeNull();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Validation outputs are only visible to plugin owners and admins. Run locally using the CLI:/,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("clawhub package validate <path-to-plugin>")).toBeTruthy();
|
||||
expect(screen.getByText("legacy-before-agent-start")).toBeTruthy();
|
||||
expect(screen.getByText("missing-expected-seam")).toBeTruthy();
|
||||
expect(screen.getByText("registerTool is no longer available")).toBeTruthy();
|
||||
expect(screen.getAllByText("OpenClaw 0.9.0").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not show validation outputs to signed-out viewers when the hash changes", async () => {
|
||||
useQueryMock.mockImplementation((query: unknown) => {
|
||||
const name = getFunctionName(query as never);
|
||||
if (name === "packages:getPackageInspectorValidationSummaryPublic") {
|
||||
return {
|
||||
findingCount: 1,
|
||||
errorCount: 0,
|
||||
warningCount: 1,
|
||||
incompatibleAfterOpenClawVersion: null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
expect(screen.queryByText("legacy-before-agent-start")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
window.location.hash = "#validation";
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(screen.queryByText("legacy-before-agent-start")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a retryable empty state when the detail lookup is rate limited", async () => {
|
||||
loaderDataMock = {
|
||||
detail: { package: null, owner: null },
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("package publish workflow", () => {
|
||||
it("runs plugin-inspector before publishing and uploads inspector artifacts", () => {
|
||||
const workflow = readFileSync(resolve(".github/workflows/package-publish.yml"), "utf8");
|
||||
|
||||
const inspectorIndex = workflow.indexOf("Run plugin validation");
|
||||
const publishIndex = workflow.indexOf("Run package publish");
|
||||
const checkoutPublishSourceIndex = workflow.indexOf(
|
||||
"Checkout publish source for plugin inspector",
|
||||
);
|
||||
|
||||
expect(inspectorIndex).toBeGreaterThan(-1);
|
||||
expect(publishIndex).toBeGreaterThan(-1);
|
||||
expect(checkoutPublishSourceIndex).toBeGreaterThan(-1);
|
||||
expect(checkoutPublishSourceIndex).toBeLessThan(inspectorIndex);
|
||||
expect(inspectorIndex).toBeLessThan(publishIndex);
|
||||
expect(workflow).toContain("inspect_checkout_repository");
|
||||
expect(workflow).toContain("clawhub-publish-source");
|
||||
expect(workflow).toContain("INSPECT_LOCAL_ROOT");
|
||||
expect(workflow).toContain("source_ref_differs_from_checkout");
|
||||
expect(workflow).toContain("resolve_github_url_ref_and_path");
|
||||
expect(workflow).toContain("quote(ref, safe='')");
|
||||
expect(workflow).toContain("error.code in (404, 422)");
|
||||
expect(workflow).toContain("package validate");
|
||||
expect(workflow).not.toContain('config_path = root / ".plugin-inspector.json"');
|
||||
expect(workflow).not.toContain("generated_config_path.write_text(str(config_path)");
|
||||
expect(workflow).not.toContain("cleanup_generated_inspector_config");
|
||||
expect(workflow).toContain("plugin-inspector-report");
|
||||
expect(workflow).toContain("actions/upload-artifact");
|
||||
});
|
||||
|
||||
it("runs nightly plugin inspector rescans with the bundled CLI validator", () => {
|
||||
const workflow = readFileSync(
|
||||
resolve(".github/workflows/plugin-inspector-nightly.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const script = readFileSync(resolve("scripts/package-inspector-nightly-scan.ts"), "utf8");
|
||||
const http = readFileSync(resolve("convex/packageInspectorHttp.ts"), "utf8");
|
||||
|
||||
expect(workflow).toContain("schedule:");
|
||||
expect(workflow).toContain("bun install --frozen-lockfile");
|
||||
expect(workflow).toContain("CLAWHUB_PLUGIN_INSPECTOR_WORKER_TOKEN");
|
||||
expect(script).toContain("package-inspector/claim");
|
||||
expect(script).toContain('"package", "validate"');
|
||||
expect(script).toContain("resolveBundledPluginInspectorVersion");
|
||||
expect(http).toContain("package-inspector/artifact");
|
||||
expect(script).toContain("package-inspector/results");
|
||||
expect(script).toContain("Authorization: `Bearer ${token}`");
|
||||
expect(script).toContain('path.join(pluginRoot, "package")');
|
||||
expect(script).not.toContain("plugin-inspector-nightly-error");
|
||||
expect(script).toContain("pluginInspector");
|
||||
expect(workflow).toContain("dry_run:");
|
||||
expect(workflow).toContain("PLUGIN_INSPECTOR_DRY_RUN");
|
||||
expect(workflow).toContain("PLUGIN_INSPECTOR_DRY_RUN_MAX_BATCHES");
|
||||
expect(script).toContain("const dryRun =");
|
||||
expect(script).toContain('dryRun ? "true" : "false"');
|
||||
expect(script).toContain("impact-summary.json");
|
||||
expect(script).toContain("summarizeImpact");
|
||||
expect(script).toContain("if (!dryRun) {");
|
||||
expect(workflow).toContain("actions/upload-artifact");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,10 @@ import { DocsLinks } from "clawhub-schema";
|
||||
import { createElement } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { toastErrorMock } = vi.hoisted(() => ({
|
||||
toastErrorMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: (path: string) => (config: { component: unknown }) => ({
|
||||
__config: config,
|
||||
@@ -24,6 +28,12 @@ vi.mock("@convex-dev/auth/react", () => ({
|
||||
useAuthActions: () => ({ signIn: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: toastErrorMock,
|
||||
},
|
||||
}));
|
||||
|
||||
const generateUploadUrl = vi.fn();
|
||||
const publishRelease = vi.fn();
|
||||
const fetchMock = vi.fn();
|
||||
@@ -88,6 +98,7 @@ describe("plugins publish route", () => {
|
||||
fetchMock.mockReset();
|
||||
useAuthStatusMock.mockReset();
|
||||
useQueryMock.mockReset();
|
||||
toastErrorMock.mockReset();
|
||||
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
@@ -282,6 +293,59 @@ describe("plugins publish route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows backend publish failures inline on the upload form", async () => {
|
||||
publishRelease.mockRejectedValueOnce(
|
||||
new Error(
|
||||
"Plugin Inspector blocked publish: 1 breakage. missing-expected-seam: missing expected registration registerTool",
|
||||
),
|
||||
);
|
||||
renderPublishRoute();
|
||||
|
||||
const packageJson = withRelativePath(
|
||||
new File(
|
||||
[
|
||||
makeCodePluginPackageJson({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.2.3",
|
||||
repository: "https://github.com/openclaw/demo-plugin.git",
|
||||
}),
|
||||
],
|
||||
"package.json",
|
||||
{ type: "application/json" },
|
||||
),
|
||||
"demo-plugin/package.json",
|
||||
);
|
||||
const manifest = withRelativePath(
|
||||
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
|
||||
"demo-plugin/openclaw.plugin.json",
|
||||
);
|
||||
const dist = withRelativePath(
|
||||
new File(["export const demo = true;\n"], "index.js", { type: "text/javascript" }),
|
||||
"demo-plugin/dist/index.js",
|
||||
);
|
||||
|
||||
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, dist] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("demo-plugin")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
|
||||
target: { value: "abc123" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Publish plugin" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert").textContent).toContain("Plugin Inspector blocked publish");
|
||||
});
|
||||
expect(screen.getByRole("columnheader", { name: "Code" })).toBeTruthy();
|
||||
expect(screen.getByRole("columnheader", { name: "Message" })).toBeTruthy();
|
||||
expect(screen.getByText("missing-expected-seam")).toBeTruthy();
|
||||
expect(screen.getByText("missing expected registration registerTool")).toBeTruthy();
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces missing OpenClaw compatibility metadata before publish", async () => {
|
||||
renderPublishRoute();
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
renderPluginInspectorBlockedPublishEmail,
|
||||
renderPluginInspectorWarningsEmail,
|
||||
} from "./packageInspectorEmailTemplates";
|
||||
|
||||
describe("package inspector email templates", () => {
|
||||
it("renders blocked publish copy with hard findings", () => {
|
||||
const email = renderPluginInspectorBlockedPublishEmail({
|
||||
packageName: "demo-plugin",
|
||||
version: "1.0.0",
|
||||
findings: [
|
||||
{
|
||||
code: "missing-expected-seam",
|
||||
message: "missing expected registration registerTool",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(email.subject).toContain("demo-plugin");
|
||||
expect(email.text).toContain("blocked");
|
||||
expect(email.text).toContain("missing-expected-seam");
|
||||
expect(email.text).toContain("missing expected registration registerTool");
|
||||
});
|
||||
|
||||
it("renders warning-only publish copy with non-blocking findings", () => {
|
||||
const email = renderPluginInspectorWarningsEmail({
|
||||
packageName: "demo-plugin",
|
||||
version: "1.0.0",
|
||||
warningUrl: "https://clawhub.ai/plugins/demo-plugin#validation",
|
||||
inspectorVersion: "0.4.0",
|
||||
targetOpenClawVersion: "0.9.0",
|
||||
warnings: [
|
||||
{
|
||||
code: "legacy-before-agent-start",
|
||||
issueClass: "deprecation-warning",
|
||||
message: "legacy before_agent_start hook is deprecated",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(email.subject).toContain("findings");
|
||||
expect(email.text).toContain("published");
|
||||
expect(email.text).toContain("legacy-before-agent-start");
|
||||
expect(email.text).toContain("https://clawhub.ai/plugins/demo-plugin#validation");
|
||||
expect(email.html).toContain("<html");
|
||||
expect(email.html).toContain("Plugin Inspector findings");
|
||||
expect(email.html).toContain("0.4.0");
|
||||
expect(email.html).toContain("0.9.0");
|
||||
});
|
||||
|
||||
it("renders nightly warning and error findings as rich HTML", () => {
|
||||
const email = renderPluginInspectorWarningsEmail({
|
||||
packageName: "demo-plugin",
|
||||
version: "1.0.1",
|
||||
warningUrl: "https://clawhub.ai/plugins/demo-plugin#validation",
|
||||
inspectorVersion: "0.5.0",
|
||||
targetOpenClawVersion: "0.10.0",
|
||||
intro:
|
||||
"A nightly Plugin Inspector rescan found compatibility findings for an already published plugin.",
|
||||
warnings: [
|
||||
{
|
||||
code: "legacy-before-agent-start",
|
||||
issueClass: "deprecation-warning",
|
||||
message: "legacy before_agent_start hook is deprecated",
|
||||
},
|
||||
{
|
||||
code: "missing-expected-seam",
|
||||
issueClass: "compatibility-error",
|
||||
level: "breakage",
|
||||
message: "registerTool is no longer available",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(email.subject).toContain("findings");
|
||||
expect(email.text).toContain("nightly Plugin Inspector rescan");
|
||||
expect(email.text).toContain("missing-expected-seam");
|
||||
expect(email.html).toContain("compatibility-error");
|
||||
expect(email.html).toContain("registerTool is no longer available");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
type EmailFinding = {
|
||||
code: string;
|
||||
issueClass?: string;
|
||||
level?: string;
|
||||
severity?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type InspectorEmail = {
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
};
|
||||
|
||||
export function renderPluginInspectorBlockedPublishEmail(args: {
|
||||
packageName: string;
|
||||
version: string;
|
||||
findings: EmailFinding[];
|
||||
}): InspectorEmail {
|
||||
const text = [
|
||||
`Your ClawHub publish for ${args.packageName}@${args.version} was blocked by Plugin Inspector.`,
|
||||
"",
|
||||
"Fix the hard findings below and publish again:",
|
||||
"",
|
||||
...formatFindings(args.findings),
|
||||
].join("\n");
|
||||
return {
|
||||
subject: `Plugin publish blocked for ${args.packageName}@${args.version}`,
|
||||
text,
|
||||
html: renderHtml({
|
||||
title: "Plugin publish blocked",
|
||||
intro: `Your ClawHub publish for ${args.packageName}@${args.version} was blocked by Plugin Inspector.`,
|
||||
packageName: args.packageName,
|
||||
version: args.version,
|
||||
findings: args.findings,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderPluginInspectorWarningsEmail(args: {
|
||||
packageName: string;
|
||||
version: string;
|
||||
warningUrl: string;
|
||||
inspectorVersion?: string;
|
||||
targetOpenClawVersion?: string;
|
||||
intro?: string;
|
||||
warnings: EmailFinding[];
|
||||
}): InspectorEmail {
|
||||
const intro =
|
||||
args.intro ??
|
||||
`Your ClawHub publish for ${args.packageName}@${args.version} was published, but Plugin Inspector found non-blocking warnings.`;
|
||||
const text = [
|
||||
intro,
|
||||
"",
|
||||
`Plugin: ${args.packageName}@${args.version}`,
|
||||
args.inspectorVersion ? `Plugin Inspector: ${args.inspectorVersion}` : null,
|
||||
args.targetOpenClawVersion ? `Target OpenClaw: ${args.targetOpenClawVersion}` : null,
|
||||
"",
|
||||
"Review the findings:",
|
||||
args.warningUrl,
|
||||
"",
|
||||
...formatFindings(args.warnings),
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join("\n");
|
||||
return {
|
||||
subject: `Plugin Inspector findings for ${args.packageName}@${args.version}`,
|
||||
text,
|
||||
html: renderHtml({
|
||||
title: "Plugin Inspector findings",
|
||||
intro,
|
||||
packageName: args.packageName,
|
||||
version: args.version,
|
||||
inspectorVersion: args.inspectorVersion,
|
||||
targetOpenClawVersion: args.targetOpenClawVersion,
|
||||
warningUrl: args.warningUrl,
|
||||
findings: args.warnings,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function formatFindings(findings: EmailFinding[]) {
|
||||
if (findings.length === 0) return ["- No findings were included."];
|
||||
return findings.map((finding) => {
|
||||
const label = finding.issueClass ? `${finding.code} (${finding.issueClass})` : finding.code;
|
||||
return `- ${label}: ${finding.message}`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderHtml(args: {
|
||||
title: string;
|
||||
intro: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
warningUrl?: string;
|
||||
inspectorVersion?: string;
|
||||
targetOpenClawVersion?: string;
|
||||
findings: EmailFinding[];
|
||||
}) {
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;background:#f6f7f9;color:#1f2933;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
||||
<main style="max-width:640px;margin:0 auto;padding:32px 18px;">
|
||||
<section style="background:#ffffff;border:1px solid #d8dee6;border-radius:8px;padding:24px;">
|
||||
<p style="margin:0 0 8px;color:#5b6472;font-size:13px;">ClawHub Plugin Inspector</p>
|
||||
<h1 style="margin:0 0 14px;font-size:24px;line-height:1.25;">${escapeHtml(args.title)}</h1>
|
||||
<p style="margin:0 0 18px;line-height:1.55;">${escapeHtml(args.intro)}</p>
|
||||
<div style="margin:0 0 18px;padding:12px;border:1px solid #e2e8f0;border-radius:6px;background:#f8fafc;">
|
||||
<p style="margin:0 0 6px;"><strong>Plugin:</strong> ${escapeHtml(args.packageName)}@${escapeHtml(args.version)}</p>
|
||||
${args.inspectorVersion ? `<p style="margin:0 0 6px;"><strong>Plugin Inspector:</strong> ${escapeHtml(args.inspectorVersion)}</p>` : ""}
|
||||
${args.targetOpenClawVersion ? `<p style="margin:0;"><strong>Target OpenClaw:</strong> ${escapeHtml(args.targetOpenClawVersion)}</p>` : ""}
|
||||
</div>
|
||||
<ul style="margin:0 0 20px;padding:0;list-style:none;">
|
||||
${args.findings.map(renderFindingHtml).join("")}
|
||||
</ul>
|
||||
${args.warningUrl ? `<p style="margin:0;"><a href="${escapeHtml(args.warningUrl)}" style="display:inline-block;border-radius:6px;background:#111827;color:#ffffff;text-decoration:none;padding:10px 14px;font-weight:700;">View plugin validation</a></p>` : ""}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderFindingHtml(finding: EmailFinding) {
|
||||
const kind = finding.level === "breakage" || finding.severity === "P0" ? "error" : "warning";
|
||||
const color = kind === "error" ? "#b42318" : "#a15c07";
|
||||
return `<li style="margin:0 0 10px;padding:12px;border:1px solid #e2e8f0;border-radius:6px;">
|
||||
<p style="margin:0 0 6px;"><span style="display:inline-block;margin-right:8px;color:${color};font-weight:700;text-transform:uppercase;">${kind}</span><code>${escapeHtml(finding.code)}</code>${finding.issueClass ? ` <span style="color:#5b6472;">${escapeHtml(finding.issueClass)}</span>` : ""}</p>
|
||||
<p style="margin:0;line-height:1.5;">${escapeHtml(finding.message)}</p>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
@@ -25,6 +25,10 @@ export function buildPluginSecurityAuditHref(name: string) {
|
||||
return `${buildPluginDetailHref(name)}/security-audit`;
|
||||
}
|
||||
|
||||
export function buildPluginValidationHref(name: string) {
|
||||
return `${buildPluginDetailHref(name)}#validation`;
|
||||
}
|
||||
|
||||
export function packageNameFromScopedRoute(scope: string, name: string) {
|
||||
if (!scope.startsWith("@") || !name || name.includes("/")) return null;
|
||||
return `${scope}/${name}`;
|
||||
|
||||
@@ -45,12 +45,10 @@ import { Route as CliDeviceRouteImport } from './routes/cli/device'
|
||||
import { Route as CliAuthRouteImport } from './routes/cli/auth'
|
||||
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
|
||||
import { Route as PluginsScopeNameRouteImport } from './routes/plugins/$scope/$name'
|
||||
import { Route as PluginsNameSettingsRouteImport } from './routes/plugins/$name/settings'
|
||||
import { Route as PluginsNameSecurityAuditRouteImport } from './routes/plugins/$name/security-audit'
|
||||
import { Route as PackagesScopeNameRouteImport } from './routes/packages/$scope/$name'
|
||||
import { Route as OwnerSlugSettingsRouteImport } from './routes/$owner/$slug/settings'
|
||||
import { Route as OwnerSlugSecurityAuditRouteImport } from './routes/$owner/$slug/security-audit'
|
||||
import { Route as PluginsScopeNameSettingsRouteImport } from './routes/plugins/$scope/$name/settings'
|
||||
import { Route as PluginsScopeNameSecurityAuditRouteImport } from './routes/plugins/$scope/$name/security-audit'
|
||||
import { Route as PluginsNameSecurityScannerRouteImport } from './routes/plugins/$name/security/$scanner'
|
||||
import { Route as OwnerSlugSecurityScannerRouteImport } from './routes/$owner/$slug/security/$scanner'
|
||||
@@ -236,11 +234,6 @@ const PluginsScopeNameRoute = PluginsScopeNameRouteImport.update({
|
||||
path: '/plugins/$scope/$name',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PluginsNameSettingsRoute = PluginsNameSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => PluginsNameRoute,
|
||||
} as any)
|
||||
const PluginsNameSecurityAuditRoute =
|
||||
PluginsNameSecurityAuditRouteImport.update({
|
||||
id: '/security-audit',
|
||||
@@ -262,12 +255,6 @@ const OwnerSlugSecurityAuditRoute = OwnerSlugSecurityAuditRouteImport.update({
|
||||
path: '/security-audit',
|
||||
getParentRoute: () => OwnerSlugRoute,
|
||||
} as any)
|
||||
const PluginsScopeNameSettingsRoute =
|
||||
PluginsScopeNameSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => PluginsScopeNameRoute,
|
||||
} as any)
|
||||
const PluginsScopeNameSecurityAuditRoute =
|
||||
PluginsScopeNameSecurityAuditRouteImport.update({
|
||||
id: '/security-audit',
|
||||
@@ -333,12 +320,10 @@ export interface FileRoutesByFullPath {
|
||||
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
|
||||
'/packages/$scope/$name': typeof PackagesScopeNameRoute
|
||||
'/plugins/$name/security-audit': typeof PluginsNameSecurityAuditRoute
|
||||
'/plugins/$name/settings': typeof PluginsNameSettingsRoute
|
||||
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
|
||||
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
|
||||
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
|
||||
'/plugins/$scope/$name/security-audit': typeof PluginsScopeNameSecurityAuditRoute
|
||||
'/plugins/$scope/$name/settings': typeof PluginsScopeNameSettingsRoute
|
||||
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -381,12 +366,10 @@ export interface FileRoutesByTo {
|
||||
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
|
||||
'/packages/$scope/$name': typeof PackagesScopeNameRoute
|
||||
'/plugins/$name/security-audit': typeof PluginsNameSecurityAuditRoute
|
||||
'/plugins/$name/settings': typeof PluginsNameSettingsRoute
|
||||
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
|
||||
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
|
||||
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
|
||||
'/plugins/$scope/$name/security-audit': typeof PluginsScopeNameSecurityAuditRoute
|
||||
'/plugins/$scope/$name/settings': typeof PluginsScopeNameSettingsRoute
|
||||
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
@@ -430,12 +413,10 @@ export interface FileRoutesById {
|
||||
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
|
||||
'/packages/$scope/$name': typeof PackagesScopeNameRoute
|
||||
'/plugins/$name/security-audit': typeof PluginsNameSecurityAuditRoute
|
||||
'/plugins/$name/settings': typeof PluginsNameSettingsRoute
|
||||
'/plugins/$scope/$name': typeof PluginsScopeNameRouteWithChildren
|
||||
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
|
||||
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
|
||||
'/plugins/$scope/$name/security-audit': typeof PluginsScopeNameSecurityAuditRoute
|
||||
'/plugins/$scope/$name/settings': typeof PluginsScopeNameSettingsRoute
|
||||
'/plugins/$scope/$name/security/$scanner': typeof PluginsScopeNameSecurityScannerRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
@@ -480,12 +461,10 @@ export interface FileRouteTypes {
|
||||
| '/$owner/$slug/settings'
|
||||
| '/packages/$scope/$name'
|
||||
| '/plugins/$name/security-audit'
|
||||
| '/plugins/$name/settings'
|
||||
| '/plugins/$scope/$name'
|
||||
| '/$owner/$slug/security/$scanner'
|
||||
| '/plugins/$name/security/$scanner'
|
||||
| '/plugins/$scope/$name/security-audit'
|
||||
| '/plugins/$scope/$name/settings'
|
||||
| '/plugins/$scope/$name/security/$scanner'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -528,12 +507,10 @@ export interface FileRouteTypes {
|
||||
| '/$owner/$slug/settings'
|
||||
| '/packages/$scope/$name'
|
||||
| '/plugins/$name/security-audit'
|
||||
| '/plugins/$name/settings'
|
||||
| '/plugins/$scope/$name'
|
||||
| '/$owner/$slug/security/$scanner'
|
||||
| '/plugins/$name/security/$scanner'
|
||||
| '/plugins/$scope/$name/security-audit'
|
||||
| '/plugins/$scope/$name/settings'
|
||||
| '/plugins/$scope/$name/security/$scanner'
|
||||
id:
|
||||
| '__root__'
|
||||
@@ -576,12 +553,10 @@ export interface FileRouteTypes {
|
||||
| '/$owner/$slug/settings'
|
||||
| '/packages/$scope/$name'
|
||||
| '/plugins/$name/security-audit'
|
||||
| '/plugins/$name/settings'
|
||||
| '/plugins/$scope/$name'
|
||||
| '/$owner/$slug/security/$scanner'
|
||||
| '/plugins/$name/security/$scanner'
|
||||
| '/plugins/$scope/$name/security-audit'
|
||||
| '/plugins/$scope/$name/settings'
|
||||
| '/plugins/$scope/$name/security/$scanner'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
@@ -879,13 +854,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PluginsScopeNameRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/plugins/$name/settings': {
|
||||
id: '/plugins/$name/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/plugins/$name/settings'
|
||||
preLoaderRoute: typeof PluginsNameSettingsRouteImport
|
||||
parentRoute: typeof PluginsNameRoute
|
||||
}
|
||||
'/plugins/$name/security-audit': {
|
||||
id: '/plugins/$name/security-audit'
|
||||
path: '/security-audit'
|
||||
@@ -914,13 +882,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof OwnerSlugSecurityAuditRouteImport
|
||||
parentRoute: typeof OwnerSlugRoute
|
||||
}
|
||||
'/plugins/$scope/$name/settings': {
|
||||
id: '/plugins/$scope/$name/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/plugins/$scope/$name/settings'
|
||||
preLoaderRoute: typeof PluginsScopeNameSettingsRouteImport
|
||||
parentRoute: typeof PluginsScopeNameRoute
|
||||
}
|
||||
'/plugins/$scope/$name/security-audit': {
|
||||
id: '/plugins/$scope/$name/security-audit'
|
||||
path: '/security-audit'
|
||||
@@ -970,13 +931,11 @@ const OwnerSlugRouteWithChildren = OwnerSlugRoute._addFileChildren(
|
||||
|
||||
interface PluginsNameRouteChildren {
|
||||
PluginsNameSecurityAuditRoute: typeof PluginsNameSecurityAuditRoute
|
||||
PluginsNameSettingsRoute: typeof PluginsNameSettingsRoute
|
||||
PluginsNameSecurityScannerRoute: typeof PluginsNameSecurityScannerRoute
|
||||
}
|
||||
|
||||
const PluginsNameRouteChildren: PluginsNameRouteChildren = {
|
||||
PluginsNameSecurityAuditRoute: PluginsNameSecurityAuditRoute,
|
||||
PluginsNameSettingsRoute: PluginsNameSettingsRoute,
|
||||
PluginsNameSecurityScannerRoute: PluginsNameSecurityScannerRoute,
|
||||
}
|
||||
|
||||
@@ -986,13 +945,11 @@ const PluginsNameRouteWithChildren = PluginsNameRoute._addFileChildren(
|
||||
|
||||
interface PluginsScopeNameRouteChildren {
|
||||
PluginsScopeNameSecurityAuditRoute: typeof PluginsScopeNameSecurityAuditRoute
|
||||
PluginsScopeNameSettingsRoute: typeof PluginsScopeNameSettingsRoute
|
||||
PluginsScopeNameSecurityScannerRoute: typeof PluginsScopeNameSecurityScannerRoute
|
||||
}
|
||||
|
||||
const PluginsScopeNameRouteChildren: PluginsScopeNameRouteChildren = {
|
||||
PluginsScopeNameSecurityAuditRoute: PluginsScopeNameSecurityAuditRoute,
|
||||
PluginsScopeNameSettingsRoute: PluginsScopeNameSettingsRoute,
|
||||
PluginsScopeNameSecurityScannerRoute: PluginsScopeNameSecurityScannerRoute,
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ type TestPackage = {
|
||||
sourceRepo: string | null;
|
||||
summary: string;
|
||||
latestVersion: string;
|
||||
inspectorWarningCount?: number;
|
||||
updatedAt: number;
|
||||
stats: {
|
||||
downloads: number;
|
||||
@@ -212,6 +213,7 @@ function createPackage(overrides?: Partial<TestPackage>): TestPackage {
|
||||
sourceRepo: null,
|
||||
summary: "Flagged plugin fixture.",
|
||||
latestVersion: "1.0.0",
|
||||
inspectorWarningCount: 0,
|
||||
updatedAt: 1,
|
||||
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
|
||||
verification: null,
|
||||
@@ -309,6 +311,33 @@ describe("Dashboard rows", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("links public plugin finding counts to the plugin validation tab", () => {
|
||||
arrangeDashboard({
|
||||
packages: [
|
||||
createPackage({
|
||||
inspectorWarningCount: 2,
|
||||
scanStatus: "clean",
|
||||
latestRelease: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
vtStatus: "clean",
|
||||
llmStatus: "clean",
|
||||
staticScanStatus: "clean",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
renderDashboard();
|
||||
|
||||
const validationLink = screen.getByRole("link", {
|
||||
name: "View 2 validation findings for Local Flagged Runtime Plugin",
|
||||
});
|
||||
expect(validationLink.getAttribute("href")).toBe(
|
||||
"/plugins/local-flagged-runtime-plugin#validation",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a publisher selector and loads org packages when switching publishers", async () => {
|
||||
const orgPublishers = [
|
||||
publishers[0],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { usePaginatedQuery, useQuery } from "convex/react";
|
||||
import { Box, Loader2, Package, Plus, Settings } from "lucide-react";
|
||||
import { AlertTriangle, Box, Loader2, Package, Plus, Settings } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../components/ui/select";
|
||||
import { buildPluginDetailHref } from "../lib/pluginRoutes";
|
||||
import { buildPluginDetailHref, buildPluginValidationHref } from "../lib/pluginRoutes";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
|
||||
const emptyPluginPublishSearch = {
|
||||
@@ -78,6 +78,7 @@ type DashboardPackage = {
|
||||
sourceRepo?: string | null;
|
||||
summary?: string | null;
|
||||
latestVersion?: string | null;
|
||||
inspectorWarningCount?: number;
|
||||
updatedAt: number;
|
||||
stats: {
|
||||
downloads: number;
|
||||
@@ -341,6 +342,7 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
|
||||
function PackageRow({ pkg }: { pkg: DashboardPackage }) {
|
||||
const status = packageArtifactStatus(pkg);
|
||||
const detailHref = buildPluginDetailHref(pkg.name);
|
||||
const validationCount = pkg.inspectorWarningCount ?? 0;
|
||||
const titleId = `dashboard-package-title-${pkg._id}`;
|
||||
const stats = [
|
||||
{ label: "Downloads", value: formatCompactNumber(pkg.stats.downloads ?? 0) },
|
||||
@@ -356,6 +358,22 @@ function PackageRow({ pkg }: { pkg: DashboardPackage }) {
|
||||
icon={<Package className="h-5 w-5" />}
|
||||
status={status}
|
||||
stats={stats}
|
||||
actions={
|
||||
validationCount > 0 ? (
|
||||
<div className="dashboard-row-action">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<a
|
||||
href={buildPluginValidationHref(pkg.name)}
|
||||
aria-label={`View ${validationCount} validation findings for ${pkg.displayName}`}
|
||||
title="Validation"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
|
||||
{validationCount}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+163
-156
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, Link, Outlet, redirect, useRouterState } from "@tanstack/react-router";
|
||||
import { createFileRoute, Outlet, redirect, useRouterState } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import { AlertTriangle, Download, Upload } from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { AlertTriangle, Download, Info, Upload } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { DetailHero, DetailPageShell } from "../../components/DetailPageShell";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import { MarkdownPreview } from "../../components/MarkdownPreview";
|
||||
import { OfficialTag } from "../../components/OfficialBadge";
|
||||
import { SidebarMetadata } from "../../components/SidebarMetadata";
|
||||
import { SkillDetailSkeleton } from "../../components/skeletons/SkillDetailSkeleton";
|
||||
import { Alert, AlertDescription } from "../../components/ui/alert";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
|
||||
@@ -46,7 +47,30 @@ type PluginDetailRateLimitState = {
|
||||
retryAfterSeconds: number | null;
|
||||
} | null;
|
||||
|
||||
type PluginDetailTab = "readme" | "capabilities" | "compatibility" | "verification";
|
||||
type PluginDetailTab = "readme" | "compatibility" | "validation";
|
||||
|
||||
type PluginInspectorFinding = {
|
||||
packageName: string;
|
||||
version: string;
|
||||
findingKind?: "warning" | "error";
|
||||
code: string;
|
||||
severity?: string;
|
||||
level?: string;
|
||||
issueClass?: string;
|
||||
message: string;
|
||||
evidence?: string[];
|
||||
inspectorVersion?: string;
|
||||
targetOpenClawVersion?: string;
|
||||
scanSource?: "publish" | "nightly";
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type PluginInspectorValidationSummary = {
|
||||
findingCount: number;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
incompatibleAfterOpenClawVersion: string | null;
|
||||
};
|
||||
|
||||
export type PluginDetailLoaderData = {
|
||||
detail: PackageDetailResponse;
|
||||
@@ -174,24 +198,6 @@ export const Route = createFileRoute("/plugins/$name")({
|
||||
component: PluginDetailRoute,
|
||||
});
|
||||
|
||||
const CAPABILITY_LABELS: Record<string, string> = {
|
||||
executesCode: "Executes code",
|
||||
runtimeId: "Runtime ID",
|
||||
pluginKind: "Plugin kind",
|
||||
channels: "Channels",
|
||||
providers: "Providers",
|
||||
hooks: "Hooks",
|
||||
bundledSkills: "Bundled skills",
|
||||
setupEntry: "Setup entry",
|
||||
toolNames: "Tools",
|
||||
commandNames: "Commands",
|
||||
serviceNames: "Services",
|
||||
capabilityTags: "Tags",
|
||||
httpRouteCount: "HTTP routes",
|
||||
bundleFormat: "Bundle format",
|
||||
hostTargets: "Host targets",
|
||||
};
|
||||
|
||||
function formatCapabilityValue(value: unknown): string {
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
if (typeof value === "number") return String(value);
|
||||
@@ -200,14 +206,6 @@ function formatCapabilityValue(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function formatDisplayValue(value: string): string {
|
||||
return value
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function formatArtifactSize(value: number | null | undefined): string | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
|
||||
if (value < 1024) return `${value} B`;
|
||||
@@ -221,25 +219,27 @@ function formatArtifactSize(value: number | null | undefined): string | null {
|
||||
return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function isEmptyObject(obj: unknown): boolean {
|
||||
if (!obj || typeof obj !== "object") return true;
|
||||
return Object.keys(obj).length === 0;
|
||||
function pluginDetailTabFromHash(hashValue: string): PluginDetailTab {
|
||||
const hash = hashValue.replace("#", "");
|
||||
if (hash === "warnings") return "validation";
|
||||
if (hash === "capabilities" || hash === "verification") return "compatibility";
|
||||
return hash === "compatibility" || hash === "validation" ? hash : "readme";
|
||||
}
|
||||
|
||||
function PluginDetailTabs({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
readmePanel,
|
||||
capabilitiesPanel,
|
||||
compatibilityPanel,
|
||||
verificationPanel,
|
||||
validationPanel,
|
||||
validationCount,
|
||||
}: {
|
||||
activeTab: PluginDetailTab;
|
||||
setActiveTab: (tab: PluginDetailTab) => void;
|
||||
readmePanel: ReactNode;
|
||||
capabilitiesPanel: ReactNode | null;
|
||||
compatibilityPanel: ReactNode | null;
|
||||
verificationPanel: ReactNode | null;
|
||||
validationPanel: ReactNode | null;
|
||||
validationCount: number;
|
||||
}) {
|
||||
const selectTab = (tab: PluginDetailTab) => {
|
||||
setActiveTab(tab);
|
||||
@@ -253,21 +253,17 @@ function PluginDetailTabs({
|
||||
};
|
||||
|
||||
const effectiveActiveTab =
|
||||
activeTab === "capabilities" && capabilitiesPanel
|
||||
? "capabilities"
|
||||
: activeTab === "compatibility" && compatibilityPanel
|
||||
? "compatibility"
|
||||
: activeTab === "verification" && verificationPanel
|
||||
? "verification"
|
||||
: "readme";
|
||||
activeTab === "compatibility" && compatibilityPanel
|
||||
? "compatibility"
|
||||
: activeTab === "validation" && validationPanel
|
||||
? "validation"
|
||||
: "readme";
|
||||
const activePanel =
|
||||
effectiveActiveTab === "capabilities" && capabilitiesPanel
|
||||
? capabilitiesPanel
|
||||
: effectiveActiveTab === "compatibility" && compatibilityPanel
|
||||
? compatibilityPanel
|
||||
: effectiveActiveTab === "verification" && verificationPanel
|
||||
? verificationPanel
|
||||
: readmePanel;
|
||||
effectiveActiveTab === "compatibility" && compatibilityPanel
|
||||
? compatibilityPanel
|
||||
: effectiveActiveTab === "validation" && validationPanel
|
||||
? validationPanel
|
||||
: readmePanel;
|
||||
|
||||
return (
|
||||
<div className="tab-card">
|
||||
@@ -281,17 +277,6 @@ function PluginDetailTabs({
|
||||
>
|
||||
README
|
||||
</button>
|
||||
{capabilitiesPanel ? (
|
||||
<button
|
||||
className={`tab-button${effectiveActiveTab === "capabilities" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={effectiveActiveTab === "capabilities"}
|
||||
onClick={() => selectTab("capabilities")}
|
||||
>
|
||||
Capabilities
|
||||
</button>
|
||||
) : null}
|
||||
{compatibilityPanel ? (
|
||||
<button
|
||||
className={`tab-button${effectiveActiveTab === "compatibility" ? " is-active" : ""}`}
|
||||
@@ -303,15 +288,15 @@ function PluginDetailTabs({
|
||||
Compatibility
|
||||
</button>
|
||||
) : null}
|
||||
{verificationPanel ? (
|
||||
{validationPanel ? (
|
||||
<button
|
||||
className={`tab-button${effectiveActiveTab === "verification" ? " is-active" : ""}`}
|
||||
className={`tab-button${effectiveActiveTab === "validation" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={effectiveActiveTab === "verification"}
|
||||
onClick={() => selectTab("verification")}
|
||||
aria-selected={effectiveActiveTab === "validation"}
|
||||
onClick={() => selectTab("validation")}
|
||||
>
|
||||
Verification
|
||||
Validation ({validationCount})
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -359,13 +344,24 @@ export function PluginDetailPage({
|
||||
? { name: manageLookupName, candidateNames: manageCandidateNames }
|
||||
: "skip",
|
||||
);
|
||||
const validationSummary = useQuery(
|
||||
api.packages.getPackageInspectorValidationSummaryPublic,
|
||||
detail.package ? { name: detail.package.name } : "skip",
|
||||
) as PluginInspectorValidationSummary | undefined;
|
||||
const inspectorFindings = useQuery(
|
||||
api.packages.listPackageInspectorWarningsForManager,
|
||||
manageContext ? { name: manageContext.package.name, limit: 100 } : "skip",
|
||||
) as PluginInspectorFinding[] | undefined;
|
||||
const [activeTab, setActiveTab] = useState<PluginDetailTab>(() => {
|
||||
if (typeof window === "undefined") return "readme";
|
||||
const hash = window.location.hash.replace("#", "");
|
||||
return hash === "capabilities" || hash === "compatibility" || hash === "verification"
|
||||
? hash
|
||||
: "readme";
|
||||
return pluginDetailTabFromHash(window.location.hash);
|
||||
});
|
||||
useEffect(() => {
|
||||
const syncTabFromHash = () => setActiveTab(pluginDetailTabFromHash(window.location.hash));
|
||||
window.addEventListener("hashchange", syncTabFromHash);
|
||||
syncTabFromHash();
|
||||
return () => window.removeEventListener("hashchange", syncTabFromHash);
|
||||
}, []);
|
||||
if (isNestedPluginRoute) {
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -435,18 +431,10 @@ export function PluginDetailPage({
|
||||
displayName: pkg.displayName,
|
||||
}).toString()}`
|
||||
: null;
|
||||
const capEntries = capabilities
|
||||
? Object.entries(capabilities).filter(
|
||||
([, v]) =>
|
||||
v !== undefined && v !== null && v !== false && !(Array.isArray(v) && v.length === 0),
|
||||
)
|
||||
: [];
|
||||
const executesCodeValue =
|
||||
typeof capabilities?.executesCode === "boolean"
|
||||
? formatCapabilityValue(capabilities.executesCode)
|
||||
: null;
|
||||
const tabCapEntries = capEntries.filter(([key]) => key !== "executesCode");
|
||||
|
||||
const compatEntries = compatibility
|
||||
? Object.entries(compatibility).filter(([, v]) => v !== undefined && v !== null)
|
||||
: [];
|
||||
@@ -458,39 +446,6 @@ export function PluginDetailPage({
|
||||
<p className="empty-state-body">This plugin doesn't have a README yet.</p>
|
||||
</div>
|
||||
);
|
||||
const capabilitiesPanel =
|
||||
tabCapEntries.length > 0 ? (
|
||||
<div className="plugin-tab-panel">
|
||||
<dl className="plugin-kv-grid">
|
||||
{tabCapEntries.map(([key, value]) => (
|
||||
<div key={key} className="plugin-kv-row">
|
||||
<dt className="plugin-kv-label">{CAPABILITY_LABELS[key] ?? key}</dt>
|
||||
<dd className="plugin-kv-value">
|
||||
{key === "capabilityTags" && Array.isArray(value) ? (
|
||||
<div className="plugin-tag-list">
|
||||
{(value as string[]).map((tag) => (
|
||||
<Link key={tag} to="/plugins" search={{ q: tag }}>
|
||||
<Badge variant="compact">{tag}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : key === "hostTargets" && Array.isArray(value) ? (
|
||||
<div className="plugin-tag-list">
|
||||
{(value as string[]).map((target) => (
|
||||
<Badge key={target} variant="compact">
|
||||
{target}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
formatCapabilityValue(value)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null;
|
||||
const compatibilityPanel =
|
||||
compatEntries.length > 0 || artifact ? (
|
||||
<div className="plugin-tab-panel">
|
||||
@@ -549,29 +504,78 @@ export function PluginDetailPage({
|
||||
</dl>
|
||||
</div>
|
||||
) : null;
|
||||
const verificationPanel =
|
||||
verification && !isEmptyObject(verification) ? (
|
||||
<div className="plugin-tab-panel">
|
||||
<dl className="plugin-kv-grid">
|
||||
{verification.tier ? (
|
||||
<div className="plugin-kv-row">
|
||||
<dt className="plugin-kv-label">Tier</dt>
|
||||
<dd className="plugin-kv-value">{formatDisplayValue(verification.tier)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scope ? (
|
||||
<div className="plugin-kv-row">
|
||||
<dt className="plugin-kv-label">Scope</dt>
|
||||
<dd className="plugin-kv-value">{formatDisplayValue(verification.scope)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.summary ? (
|
||||
<div className="plugin-kv-row">
|
||||
<dt className="plugin-kv-label">Summary</dt>
|
||||
<dd className="plugin-kv-value">{verification.summary}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
const validationCount = inspectorFindings?.length ?? validationSummary?.findingCount ?? 0;
|
||||
const incompatibilityAlert =
|
||||
validationSummary &&
|
||||
validationSummary.errorCount > 0 &&
|
||||
validationSummary.incompatibleAfterOpenClawVersion ? (
|
||||
<Alert variant="destructive" className="plugin-validation-alert">
|
||||
<AlertTriangle size={16} aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
This plugin is incompatible with OpenClaw versions greater than{" "}
|
||||
{validationSummary.incompatibleAfterOpenClawVersion}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null;
|
||||
const validationPanel =
|
||||
inspectorFindings && inspectorFindings.length > 0 ? (
|
||||
<div className="plugin-tab-panel plugin-warnings-panel">
|
||||
<Alert variant="info" role="status">
|
||||
<Info size={16} aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
Validation outputs are only visible to plugin owners and admins. Run locally using the
|
||||
CLI: <code>clawhub package validate <path-to-plugin></code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="plugin-warning-list">
|
||||
{inspectorFindings.map((finding) => (
|
||||
<article
|
||||
key={`${finding.version}:${finding.code}:${finding.message}`}
|
||||
className={`plugin-warning-item is-${finding.findingKind ?? "warning"}`}
|
||||
>
|
||||
<div className="plugin-warning-item-header">
|
||||
<Badge variant={finding.findingKind === "error" ? "destructive" : "warning"}>
|
||||
{finding.findingKind === "error" ? "Error" : "Warning"}
|
||||
</Badge>
|
||||
<code>{finding.code}</code>
|
||||
{finding.issueClass ? <span>{finding.issueClass}</span> : null}
|
||||
{finding.severity ? <span>{finding.severity}</span> : null}
|
||||
</div>
|
||||
<p>{finding.message}</p>
|
||||
<dl className="plugin-warning-meta">
|
||||
<div>
|
||||
<dt>Plugin version</dt>
|
||||
<dd>v{finding.version}</dd>
|
||||
</div>
|
||||
{finding.targetOpenClawVersion ? (
|
||||
<div>
|
||||
<dt>Target</dt>
|
||||
<dd>OpenClaw {finding.targetOpenClawVersion}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{finding.inspectorVersion ? (
|
||||
<div>
|
||||
<dt>Inspector</dt>
|
||||
<dd>{finding.inspectorVersion}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{finding.scanSource ? (
|
||||
<div>
|
||||
<dt>Scan</dt>
|
||||
<dd>{finding.scanSource}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
{finding.evidence && finding.evidence.length > 0 ? (
|
||||
<ul className="plugin-warning-evidence">
|
||||
{finding.evidence.slice(0, 4).map((entry) => (
|
||||
<li key={entry}>{entry}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
const sourceRepoLink = verification?.sourceRepo
|
||||
@@ -733,33 +737,36 @@ export function PluginDetailPage({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card className="skill-install-command-card">
|
||||
<CardHeader>
|
||||
<CardTitle>Install</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="skill-install-command-wrap">
|
||||
<div className="skill-install-command-shell">
|
||||
<pre className="skill-install-command">
|
||||
<code>{installSnippet}</code>
|
||||
</pre>
|
||||
<InstallCopyButton
|
||||
text={installSnippet}
|
||||
ariaLabel="Copy plugin install command"
|
||||
showLabel={false}
|
||||
className="skill-install-command-inline-button"
|
||||
/>
|
||||
<div className="plugin-install-stack">
|
||||
{incompatibilityAlert}
|
||||
<Card className="skill-install-command-card">
|
||||
<CardHeader>
|
||||
<CardTitle>Install</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="skill-install-command-wrap">
|
||||
<div className="skill-install-command-shell">
|
||||
<pre className="skill-install-command">
|
||||
<code>{installSnippet}</code>
|
||||
</pre>
|
||||
<InstallCopyButton
|
||||
text={installSnippet}
|
||||
ariaLabel="Copy plugin install command"
|
||||
showLabel={false}
|
||||
className="skill-install-command-inline-button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<PluginDetailTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
readmePanel={readmePanel}
|
||||
capabilitiesPanel={capabilitiesPanel}
|
||||
compatibilityPanel={compatibilityPanel}
|
||||
verificationPanel={verificationPanel}
|
||||
validationPanel={validationPanel}
|
||||
validationCount={validationCount}
|
||||
/>
|
||||
</DetailHero>
|
||||
</DetailPageShell>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { buildPluginDetailHref } from "../../../lib/pluginRoutes";
|
||||
|
||||
export const Route = createFileRoute("/plugins/$name/settings")({
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
href: buildPluginDetailHref(params.name),
|
||||
statusCode: 308,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
|
||||
import { packageNameFromScopedRoute, buildPluginDetailHref } from "../../../../lib/pluginRoutes";
|
||||
|
||||
function packageNameFromParams(params: { scope: string; name: string }) {
|
||||
const packageName = packageNameFromScopedRoute(params.scope, params.name);
|
||||
if (!packageName) throw notFound();
|
||||
return packageName;
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/plugins/$scope/$name/settings")({
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
href: buildPluginDetailHref(packageNameFromParams(params)),
|
||||
statusCode: 308,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -105,6 +105,80 @@ async function scanReadmeRelativeAssets(files: File[]): Promise<RelativeReadmeAs
|
||||
}
|
||||
}
|
||||
|
||||
type ParsedInspectorPublishError = {
|
||||
summary: string;
|
||||
findings: Array<{ code: string; message: string }>;
|
||||
};
|
||||
|
||||
const PLUGIN_INSPECTOR_BLOCKED_PREFIX = "Plugin Inspector blocked publish:";
|
||||
|
||||
function parsePluginInspectorPublishError(message: string): ParsedInspectorPublishError | null {
|
||||
if (!message.startsWith(PLUGIN_INSPECTOR_BLOCKED_PREFIX)) return null;
|
||||
const body = message.slice(PLUGIN_INSPECTOR_BLOCKED_PREFIX.length).trim();
|
||||
if (!body) return { summary: "Hard findings blocked this publish.", findings: [] };
|
||||
const [summaryPart, ...detailParts] = body.split(". ");
|
||||
const summary = summaryPart?.trim() || "Hard findings blocked this publish.";
|
||||
const details = detailParts.join(". ").trim();
|
||||
if (!details) return { summary, findings: [] };
|
||||
const findings = details
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const match = part.match(/^([a-z0-9._-]+):\s+(.+)$/i);
|
||||
return match
|
||||
? { code: match[1]!, message: match[2]! }
|
||||
: { code: "plugin-inspector", message: part };
|
||||
});
|
||||
return { summary, findings };
|
||||
}
|
||||
|
||||
function isPluginInspectorPublishError(message: string) {
|
||||
return Boolean(parsePluginInspectorPublishError(message));
|
||||
}
|
||||
|
||||
function PluginPublishError({ message }: { message: string }) {
|
||||
const inspectorError = parsePluginInspectorPublishError(message);
|
||||
if (!inspectorError) {
|
||||
return (
|
||||
<div className="plugin-publish-error-text" role="alert">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="plugin-publish-error-panel" role="alert">
|
||||
<div className="plugin-publish-error-heading">
|
||||
<strong>Plugin Inspector blocked publish</strong>
|
||||
<span>{inspectorError.summary}</span>
|
||||
</div>
|
||||
{inspectorError.findings.length > 0 ? (
|
||||
<div className="plugin-publish-error-table-wrap">
|
||||
<table className="plugin-publish-error-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Code</th>
|
||||
<th scope="col">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inspectorError.findings.map((finding) => (
|
||||
<tr key={`${finding.code}:${finding.message}`}>
|
||||
<td>
|
||||
<code>{finding.code}</code>
|
||||
</td>
|
||||
<td>{finding.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PublishPluginRoute() {
|
||||
const search = useSearch({ from: "/plugins/publish" });
|
||||
const { isAuthenticated, isLoading: isAuthLoading, me } = useAuthStatus();
|
||||
@@ -636,11 +710,7 @@ export function PublishPluginRoute() {
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{error ? (
|
||||
<div className="text-sm font-medium text-red-600 dark:text-red-400" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <PluginPublishError message={error} /> : null}
|
||||
{status ? <div className="text-sm text-[color:var(--ink-soft)]">{status}</div> : null}
|
||||
{!status ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)]">
|
||||
@@ -727,7 +797,11 @@ export function PublishPluginRoute() {
|
||||
"Published. Pending security checks and verification before public listing.",
|
||||
);
|
||||
} catch (publishError) {
|
||||
toast.error(formatPublishError(publishError));
|
||||
const message = formatPublishError(publishError);
|
||||
setError(message);
|
||||
if (!isPluginInspectorPublishError(message)) {
|
||||
toast.error(message);
|
||||
}
|
||||
setStatus(null);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
|
||||
+153
@@ -3807,6 +3807,12 @@ code {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.plugin-install-stack {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-install-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3867,6 +3873,153 @@ code {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-warnings-panel {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.plugin-warning-list {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.plugin-warning-item {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.plugin-warning-item.is-error {
|
||||
border-color: color-mix(in srgb, var(--status-error-fg) 32%, var(--line));
|
||||
}
|
||||
|
||||
.plugin-warning-item-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.plugin-warning-item-header code {
|
||||
border-radius: 4px;
|
||||
background: var(--surface-muted);
|
||||
padding: 0.125rem 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.plugin-warning-item p {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.plugin-warning-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plugin-warning-meta div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.plugin-warning-meta dt {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.plugin-warning-meta dd {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.plugin-warning-evidence {
|
||||
margin: 0;
|
||||
padding-left: var(--space-4);
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.plugin-publish-error-text {
|
||||
color: var(--status-error-fg);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.plugin-publish-error-panel {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
width: min(100%, 720px);
|
||||
border: 1px solid color-mix(in srgb, var(--status-error-fg) 30%, var(--line));
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--status-error-bg);
|
||||
padding: var(--space-4);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.plugin-publish-error-heading {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.plugin-publish-error-heading strong {
|
||||
color: var(--status-error-fg);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.plugin-publish-error-heading span {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.plugin-publish-error-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid color-mix(in srgb, var(--status-error-fg) 20%, var(--line));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.plugin-publish-error-table {
|
||||
width: 100%;
|
||||
min-width: 420px;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.plugin-publish-error-table th,
|
||||
.plugin-publish-error-table td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0.65rem 0.75rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.plugin-publish-error-table th {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.plugin-publish-error-table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.plugin-publish-error-table code {
|
||||
border-radius: 4px;
|
||||
background: var(--surface-muted);
|
||||
padding: 0.125rem 0.35rem;
|
||||
color: var(--ink);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.plugin-tab-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user