mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat(admin): export plugin validation reports (#3326)
* feat(admin): add plugin validation report command * test(admin): cover validation report edge cases * test(admin): satisfy validation report static gate * feat(admin): serve plugin validation reports
This commit is contained in:
@@ -8302,6 +8302,83 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(runMutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns a paginated plugin validation report to moderators", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const page = {
|
||||
items: [
|
||||
{
|
||||
package: { id: "packages:demo", name: "demo", displayName: "Demo" },
|
||||
release: { id: "packageReleases:demo", version: "1.0.0", createdAt: 100 },
|
||||
references: { packagePage: "/plugins/demo", release: "demo@1.0.0" },
|
||||
scan: {
|
||||
status: "clean",
|
||||
scannedAt: 200,
|
||||
target: { channel: "beta", version: "2026.7.30-beta.1" },
|
||||
inspectorVersion: "0.3.19",
|
||||
skipReason: null,
|
||||
},
|
||||
findings: [],
|
||||
},
|
||||
],
|
||||
nextCursor: "page-2",
|
||||
done: false,
|
||||
};
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return page;
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery }),
|
||||
new Request("https://example.com/api/v1/packages/validation-report?limit=40&cursor=page-1", {
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual(page);
|
||||
expect(runQuery).toHaveBeenCalledWith(
|
||||
(internal as unknown as { packages: Record<string, unknown> }).packages
|
||||
.listPluginValidationReportPageInternal,
|
||||
{ cursor: "page-1", numItems: 40 },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unauthenticated plugin validation report requests", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValue(new Error("Unauthorized"));
|
||||
const runQuery = vi.fn();
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery }),
|
||||
new Request("https://example.com/api/v1/packages/validation-report"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(runQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids ordinary users from reading the plugin validation report", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:viewer",
|
||||
user: { _id: "users:viewer", role: "user" },
|
||||
} as never);
|
||||
const runQuery = vi.fn();
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery }),
|
||||
new Request("https://example.com/api/v1/packages/validation-report", {
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
await expect(response.text()).resolves.toBe("Moderator role required.");
|
||||
expect(runQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("package hard-delete dry-runs through the admin-only API", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ApiRoutes,
|
||||
ApiV1PackageOfficialMigrationListResponseSchema,
|
||||
ApiV1PackageOfficialMigrationResponseSchema,
|
||||
ApiV1PackageValidationReportPageSchema,
|
||||
ApiV1PackageModerationStatusResponseSchema,
|
||||
ApiV1PackageSecurityResponseSchema,
|
||||
PackageHardDeleteRequestSchema,
|
||||
@@ -84,6 +85,7 @@ import {
|
||||
resolveTagsBatch,
|
||||
requireApiTokenUserOrResponse,
|
||||
requireAdminOrResponse,
|
||||
requireModeratorOrResponse,
|
||||
requirePackagePublishAuthOrResponse,
|
||||
safeStoredFilePreviewResponse,
|
||||
safeStoredFileResponse,
|
||||
@@ -113,6 +115,7 @@ const internalRefs = internal as unknown as {
|
||||
getByNameForViewerInternal: unknown;
|
||||
hasMissingRecommendationScoresInternal: unknown;
|
||||
listPluginExportPageInternal: unknown;
|
||||
listPluginValidationReportPageInternal: unknown;
|
||||
listPageForViewerInternal: unknown;
|
||||
searchForViewerInternal: unknown;
|
||||
listVersionsForViewerInternal: unknown;
|
||||
@@ -3633,6 +3636,36 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
return await searchPackages(ctx, request, { includeSkills: true });
|
||||
}
|
||||
|
||||
if (segments[0] === "validation-report" && segments.length === 1) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
const moderator = requireModeratorOrResponse(auth.user, rate.headers);
|
||||
if (!moderator.ok) return moderator.response;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.max(
|
||||
1,
|
||||
Math.min(toOptionalNumber(url.searchParams.get("limit")) ?? 100, 100),
|
||||
);
|
||||
const cursor = url.searchParams.get("cursor")?.trim() || undefined;
|
||||
const result = await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.packages.listPluginValidationReportPageInternal,
|
||||
{ cursor, numItems: limit },
|
||||
);
|
||||
return json(
|
||||
parseArk(
|
||||
ApiV1PackageValidationReportPageSchema,
|
||||
result,
|
||||
"Package validation report response",
|
||||
),
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
|
||||
if (segments[0] === "moderation" && segments[1] === "queue" && segments.length === 2) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { assertAdmin } from "../lib/access";
|
||||
import { assertAdmin, assertModerator } from "../lib/access";
|
||||
import { requireApiTokenUser, requirePackagePublishAuth } from "../lib/apiTokenAuth";
|
||||
import { corsHeaders, mergeHeaders } from "../lib/httpHeaders";
|
||||
import { getPublishFileSizeError, MAX_PUBLISH_FILE_BYTES } from "../lib/publishLimits";
|
||||
@@ -261,6 +261,15 @@ export function requireAdminOrResponse(user: Doc<"users">, headers: HeadersInit)
|
||||
}
|
||||
}
|
||||
|
||||
export function requireModeratorOrResponse(user: Doc<"users">, headers: HeadersInit) {
|
||||
try {
|
||||
assertModerator(user);
|
||||
return { ok: true as const };
|
||||
} catch {
|
||||
return { ok: false as const, response: text("Moderator role required.", 403, headers) };
|
||||
}
|
||||
}
|
||||
|
||||
export function toOptionalNumber(value: string | null) {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
findPackagePublishResultInternal,
|
||||
listPackageModerationQueueInternal,
|
||||
listPluginExportPageInternal,
|
||||
listPluginValidationReportPageInternal,
|
||||
reservePackageNameInternal,
|
||||
listPublicPage,
|
||||
listPublicNewPluginsPage,
|
||||
@@ -218,6 +219,20 @@ const listPluginExportPageInternalHandler = (
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
const listPluginValidationReportPageInternalHandler = (
|
||||
listPluginValidationReportPageInternal as unknown as WrappedHandler<
|
||||
{ cursor?: string; numItems?: number },
|
||||
{
|
||||
items: Array<{
|
||||
package: { id: string; name: string; displayName: string };
|
||||
scan: { status: string; scannedAt: number | null };
|
||||
findings: Array<{ severity: string; code: string; message: string }>;
|
||||
}>;
|
||||
nextCursor: string | null;
|
||||
done: boolean;
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
const listVersionsHandler = (
|
||||
listVersions as unknown as WrappedHandler<
|
||||
{
|
||||
@@ -2051,6 +2066,95 @@ function makePluginExportCtx(
|
||||
};
|
||||
}
|
||||
|
||||
function makePluginValidationReportCtx(
|
||||
digests: Array<Record<string, unknown>>,
|
||||
options: {
|
||||
statesByRelease?: Record<string, Array<Record<string, unknown>>>;
|
||||
findingsByRelease?: Record<string, Array<Record<string, unknown>>>;
|
||||
} = {},
|
||||
) {
|
||||
const base = makePluginExportCtx(digests);
|
||||
const releasesById = new Map(
|
||||
digests.map((digest) => {
|
||||
const name = String(digest.name);
|
||||
const packageId = String(digest.packageId);
|
||||
return [
|
||||
`packageReleases:${name}-1`,
|
||||
{
|
||||
_id: `packageReleases:${name}-1`,
|
||||
packageId,
|
||||
version: typeof digest.latestVersion === "string" ? digest.latestVersion : "1.0.0",
|
||||
createdAt: Number(digest.createdAt),
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
return {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => (await base.db.get(id)) ?? releasesById.get(id) ?? null),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packageSearchDigest" || table === "packages") {
|
||||
return base.db.query(table);
|
||||
}
|
||||
if (table === "packageInspectorScanStates") {
|
||||
let releaseId = "";
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
_indexName: string,
|
||||
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
const queryBuilder = {
|
||||
eq: (field: string, value: unknown) => {
|
||||
if (field === "releaseId") releaseId = String(value);
|
||||
return queryBuilder;
|
||||
},
|
||||
};
|
||||
builder(queryBuilder);
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
first: vi.fn(
|
||||
async () =>
|
||||
[...(options.statesByRelease?.[releaseId] ?? [])].sort(
|
||||
(a, b) => Number(b.completedAt) - Number(a.completedAt),
|
||||
)[0] ?? null,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table === "packageInspectorWarnings") {
|
||||
let releaseId = "";
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
_indexName: string,
|
||||
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
const queryBuilder = {
|
||||
eq: (field: string, value: unknown) => {
|
||||
if (field === "releaseId") releaseId = String(value);
|
||||
return queryBuilder;
|
||||
},
|
||||
};
|
||||
builder(queryBuilder);
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
collect: vi.fn(async () => options.findingsByRelease?.[releaseId] ?? []),
|
||||
})),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeInsertReleaseCtx(
|
||||
existing: Record<string, unknown> | null,
|
||||
priorReleases: Array<Record<string, unknown>> = [],
|
||||
@@ -3047,6 +3151,121 @@ describe("packages public queries", () => {
|
||||
expect(third.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("exports the latest scan state and normalized current findings", async () => {
|
||||
const digest = makePluginExportDigest("demo", "code-plugin", 300);
|
||||
const releaseId = "packageReleases:demo-1";
|
||||
const ctx = makePluginValidationReportCtx([digest], {
|
||||
statesByRelease: {
|
||||
[releaseId]: [
|
||||
{
|
||||
releaseId,
|
||||
inspectorVersion: "0.3.18",
|
||||
targetOpenClawVersion: "2026.7.29-beta.1",
|
||||
completedAt: 100,
|
||||
},
|
||||
{
|
||||
releaseId,
|
||||
inspectorVersion: "0.3.19",
|
||||
targetOpenClawVersion: "2026.7.30-beta.1",
|
||||
completedAt: 200,
|
||||
},
|
||||
],
|
||||
},
|
||||
findingsByRelease: {
|
||||
[releaseId]: [
|
||||
{
|
||||
scanSource: "nightly",
|
||||
inspectorVersion: "0.3.18",
|
||||
targetOpenClawVersion: "2026.7.29-beta.1",
|
||||
findingKind: "error",
|
||||
code: "stale-missing-api",
|
||||
message: "Old target API is unavailable",
|
||||
},
|
||||
{
|
||||
scanSource: "publish",
|
||||
findingKind: "warning",
|
||||
code: "deprecated-api",
|
||||
message: "API is deprecated",
|
||||
},
|
||||
{
|
||||
scanSource: "nightly",
|
||||
inspectorVersion: "0.3.19",
|
||||
targetOpenClawVersion: "2026.7.30-beta.1",
|
||||
findingKind: "error",
|
||||
code: "missing-api",
|
||||
message: "Required API is unavailable",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await listPluginValidationReportPageInternalHandler(ctx, { numItems: 20 });
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
package: { id: "packages:demo", name: "demo", displayName: "demo" },
|
||||
scan: expect.objectContaining({
|
||||
status: "error",
|
||||
scannedAt: 200,
|
||||
target: { channel: "beta", version: "2026.7.30-beta.1" },
|
||||
inspectorVersion: "0.3.19",
|
||||
}),
|
||||
findings: [
|
||||
{ severity: "warning", code: "deprecated-api", message: "API is deprecated" },
|
||||
{ severity: "error", code: "missing-api", message: "Required API is unavailable" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
nextCursor: null,
|
||||
done: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("represents missing scans and preserves plugin pagination continuity", async () => {
|
||||
const ctx = makePluginValidationReportCtx(
|
||||
[
|
||||
makePluginExportDigest("newer-code", "code-plugin", 300),
|
||||
makePluginExportDigest("older-bundle", "bundle-plugin", 200),
|
||||
],
|
||||
{
|
||||
findingsByRelease: {
|
||||
"packageReleases:newer-code-1": [
|
||||
{
|
||||
scanSource: "publish",
|
||||
findingKind: "error",
|
||||
code: "publish-error",
|
||||
message: "Publish-time validation failed",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const first = await listPluginValidationReportPageInternalHandler(ctx, { numItems: 1 });
|
||||
const second = await listPluginValidationReportPageInternalHandler(ctx, {
|
||||
cursor: first.nextCursor ?? undefined,
|
||||
numItems: 1,
|
||||
});
|
||||
|
||||
expect(first.items).toHaveLength(1);
|
||||
expect(first.items[0]).toMatchObject({
|
||||
package: { name: "newer-code" },
|
||||
scan: { status: "error", scannedAt: null },
|
||||
findings: [{ severity: "error", code: "publish-error" }],
|
||||
});
|
||||
expect(first.done).toBe(false);
|
||||
expect(first.nextCursor).toBeTruthy();
|
||||
expect(second.items).toHaveLength(1);
|
||||
expect(second.items[0]).toMatchObject({
|
||||
package: { name: "older-bundle" },
|
||||
scan: { status: "not-scanned", scannedAt: null },
|
||||
findings: [],
|
||||
});
|
||||
expect(second.done).toBe(true);
|
||||
expect(second.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps buffered cursor items aligned across paginated public pages", async () => {
|
||||
const { ctx, paginate } = makeDigestCtx({
|
||||
pages: [
|
||||
|
||||
@@ -135,6 +135,7 @@ const MAX_PUBLIC_LIST_PAGE_SIZE = 200;
|
||||
const MAX_PUBLIC_LIST_FILTER_SCAN_DOCUMENTS = 500;
|
||||
const MAX_PUBLIC_LIST_FILTER_SCAN_PAGES = 6;
|
||||
const MAX_PLUGIN_EXPORT_LIST_LIMIT = 250;
|
||||
const MAX_PLUGIN_VALIDATION_REPORT_PAGE_SIZE = 50;
|
||||
const MAX_SEARCH_PAGE_SIZE = 200;
|
||||
const MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES = 20;
|
||||
const MAX_DIRECT_PACKAGE_FULL_TEXT_CANDIDATES = 40;
|
||||
@@ -3968,6 +3969,105 @@ export const listPluginExportPageInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
function normalizePackageValidationFinding(warning: Doc<"packageInspectorWarnings">) {
|
||||
const severity =
|
||||
warning.findingKind === "error" ||
|
||||
warning.level === "breakage" ||
|
||||
warning.level === "error" ||
|
||||
warning.severity === "P0"
|
||||
? ("error" as const)
|
||||
: warning.severity?.toLowerCase() === "info"
|
||||
? ("info" as const)
|
||||
: ("warning" as const);
|
||||
return { severity, code: warning.code, message: warning.message };
|
||||
}
|
||||
|
||||
export const listPluginValidationReportPageInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
numItems: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const numItems = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
args.numItems ?? MAX_PLUGIN_VALIDATION_REPORT_PAGE_SIZE,
|
||||
MAX_PLUGIN_VALIDATION_REPORT_PAGE_SIZE,
|
||||
),
|
||||
);
|
||||
const result = await listMergedPluginExportPage(ctx, {
|
||||
startDate: 0,
|
||||
endDate: Number.MAX_SAFE_INTEGER,
|
||||
cursor: args.cursor,
|
||||
numItems,
|
||||
});
|
||||
const items = [];
|
||||
for (const digest of result.page) {
|
||||
if (!digest.latestReleaseId) continue;
|
||||
const release = await ctx.db.get(digest.latestReleaseId);
|
||||
if (!release || release.softDeletedAt || release.packageId !== digest.packageId) continue;
|
||||
const scanState = await ctx.db
|
||||
.query("packageInspectorScanStates")
|
||||
.withIndex("by_release_and_completed_at", (q) => q.eq("releaseId", release._id))
|
||||
.order("desc")
|
||||
.first();
|
||||
// CLAW-626 reconciles nightly findings per release and preserves publish/static findings.
|
||||
// Matching the selected tuple also fails closed if stale nightly rows survive a partial rollout.
|
||||
const storedWarnings = await ctx.db
|
||||
.query("packageInspectorWarnings")
|
||||
.withIndex("by_release_created", (q) => q.eq("releaseId", release._id))
|
||||
.order("asc")
|
||||
.collect();
|
||||
const warnings = storedWarnings.filter(
|
||||
(warning) =>
|
||||
warning.scanSource !== "nightly" ||
|
||||
(scanState !== null &&
|
||||
warning.inspectorVersion === scanState.inspectorVersion &&
|
||||
warning.targetOpenClawVersion === scanState.targetOpenClawVersion),
|
||||
);
|
||||
const findings = warnings.map(normalizePackageValidationFinding);
|
||||
const status = findings.some((finding) => finding.severity === "error")
|
||||
? ("error" as const)
|
||||
: findings.length > 0
|
||||
? ("warning" as const)
|
||||
: !scanState
|
||||
? ("not-scanned" as const)
|
||||
: ("clean" as const);
|
||||
items.push({
|
||||
package: {
|
||||
id: String(digest.packageId),
|
||||
name: digest.name,
|
||||
displayName: digest.displayName,
|
||||
},
|
||||
release: {
|
||||
id: String(release._id),
|
||||
version: release.version,
|
||||
createdAt: release.createdAt,
|
||||
},
|
||||
references: {
|
||||
packagePage: `/plugins/${encodeURIComponent(digest.name)}`,
|
||||
release: `${digest.name}@${release.version}`,
|
||||
},
|
||||
scan: {
|
||||
status,
|
||||
scannedAt: scanState?.completedAt ?? null,
|
||||
target: scanState
|
||||
? { channel: "beta" as const, version: scanState.targetOpenClawVersion }
|
||||
: null,
|
||||
inspectorVersion: scanState?.inspectorVersion ?? null,
|
||||
skipReason: null,
|
||||
},
|
||||
findings,
|
||||
});
|
||||
}
|
||||
return {
|
||||
items,
|
||||
nextCursor: result.nextCursor,
|
||||
done: !result.hasMore,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const listPageForViewerInternal = internalQuery({
|
||||
args: {
|
||||
family: v.optional(
|
||||
|
||||
+7
-5
@@ -1993,11 +1993,13 @@ const packageInspectorScanStates = defineTable({
|
||||
targetOpenClawVersion: v.string(),
|
||||
completedAt: v.number(),
|
||||
notificationCompletedAt: v.optional(v.number()),
|
||||
}).index("by_release_and_inspector_version_and_target_openclaw_version", [
|
||||
"releaseId",
|
||||
"inspectorVersion",
|
||||
"targetOpenClawVersion",
|
||||
]);
|
||||
})
|
||||
.index("by_release_and_inspector_version_and_target_openclaw_version", [
|
||||
"releaseId",
|
||||
"inspectorVersion",
|
||||
"targetOpenClawVersion",
|
||||
])
|
||||
.index("by_release_and_completed_at", ["releaseId", "completedAt"]);
|
||||
|
||||
const securityScanJobs = defineTable({
|
||||
targetKind: securityScanTargetKindValidator,
|
||||
|
||||
@@ -129,6 +129,7 @@ bun run admin -- plugins queue [--status open|blocked|manual|all]
|
||||
bun run admin -- plugins reports [--status open|confirmed|dismissed|all]
|
||||
bun run admin -- plugins triage-report <report-id> --status open|confirmed|dismissed [--note <text>] [--action none|quarantine|revoke] [--yes]
|
||||
|
||||
bun run admin -- packages validation-report --json > plugin-validation-report.json
|
||||
bun run admin -- plugins migrations [--phase <phase>]
|
||||
bun run admin -- plugins set-migration <bundled-plugin-id> --package <name>
|
||||
bun run admin -- plugins hard-delete <name> --owner <handle> --reason <text> [--apply --confirm <token> --yes] [--json]
|
||||
@@ -139,3 +140,7 @@ bun run admin -- plugins trusted-publisher delete <name>
|
||||
```
|
||||
|
||||
All skill and plugin commands accept `--json` where the underlying endpoint supports machine-readable output.
|
||||
|
||||
`packages validation-report --json` exhaustively fetches the current validation state for every
|
||||
plugin and writes exactly one JSON document to stdout. Redirect stdout to archive the report;
|
||||
authentication, registry, and request failures are written to stderr by the CLI error handler.
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "./commands/orgs.js";
|
||||
import {
|
||||
cmdDeletePackageTrustedPublisher,
|
||||
cmdExportPackageValidationReport,
|
||||
cmdHardDeletePackage,
|
||||
cmdListPackageMigrations,
|
||||
cmdListPackageReports,
|
||||
@@ -767,6 +768,15 @@ function registerPluginModerationCommands(command: Command) {
|
||||
}
|
||||
|
||||
function registerPluginOperations(command: Command) {
|
||||
command
|
||||
.command("validation-report")
|
||||
.description("Export current plugin validation results as JSON")
|
||||
.requiredOption("--json", "Output one JSON report document")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdExportPackageValidationReport(opts, options);
|
||||
});
|
||||
|
||||
command
|
||||
.command("moderate")
|
||||
.description("Set plugin release moderation state")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiV1PackageValidationReportPageSchema } from "../../../clawhub/src/schema/index.js";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
@@ -20,6 +21,7 @@ vi.mock("../../../clawhub/src/http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../../../clawhub/src/cli/ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const {
|
||||
cmdExportPackageValidationReport,
|
||||
cmdHardDeletePackage,
|
||||
cmdRepairPackageName,
|
||||
cmdRepairPackageRuntimeId,
|
||||
@@ -28,6 +30,205 @@ const {
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
httpMocks.apiRequest.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("cmdExportPackageValidationReport", () => {
|
||||
it("fetches every page and writes one catalog-wide JSON document", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-30T20:00:00.000Z"));
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
const clean = {
|
||||
package: { id: "packages:alpha", name: "alpha", displayName: "Alpha" },
|
||||
release: { id: "packageReleases:alpha", version: "1.0.0", createdAt: 100 },
|
||||
references: { packagePage: "/plugins/alpha", release: "alpha@1.0.0" },
|
||||
scan: {
|
||||
status: "clean",
|
||||
scannedAt: 200,
|
||||
target: { channel: "beta", version: "2026.7.30-beta.1" },
|
||||
inspectorVersion: "0.5.0",
|
||||
skipReason: null,
|
||||
},
|
||||
findings: [],
|
||||
};
|
||||
const failing = {
|
||||
package: { id: "packages:beta", name: "beta", displayName: "Beta" },
|
||||
release: { id: "packageReleases:beta", version: "2.0.0", createdAt: 300 },
|
||||
references: { packagePage: "/plugins/beta", release: "beta@2.0.0" },
|
||||
scan: {
|
||||
status: "error",
|
||||
scannedAt: 400,
|
||||
target: { channel: "beta", version: "2026.7.30-beta.1" },
|
||||
inspectorVersion: "0.5.0",
|
||||
skipReason: null,
|
||||
},
|
||||
findings: [
|
||||
{
|
||||
severity: "error",
|
||||
code: "missing-api",
|
||||
message: "Required API is unavailable",
|
||||
},
|
||||
],
|
||||
};
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({ items: [clean], nextCursor: "page-2", done: false })
|
||||
.mockResolvedValueOnce({ items: [failing], nextCursor: null, done: true });
|
||||
|
||||
const report = await cmdExportPackageValidationReport(makeGlobalOpts(), { json: true });
|
||||
|
||||
expect(authTokenMocks.requireAuthToken).toHaveBeenCalledOnce();
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(httpMocks.apiRequest.mock.calls[0]?.[1]).toMatchObject({ method: "GET", token: "tkn" });
|
||||
expect(httpMocks.apiRequest.mock.calls[0]?.[1]?.url).toBe(
|
||||
"https://clawhub.ai/api/v1/packages/validation-report?limit=100",
|
||||
);
|
||||
expect(httpMocks.apiRequest.mock.calls[0]?.[2]).toBe(ApiV1PackageValidationReportPageSchema);
|
||||
expect(httpMocks.apiRequest.mock.calls[1]?.[1]?.url).toBe(
|
||||
"https://clawhub.ai/api/v1/packages/validation-report?limit=100&cursor=page-2",
|
||||
);
|
||||
expect(report).toEqual({
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-07-30T20:00:00.000Z",
|
||||
source: { registry: "https://clawhub.ai", pages: 2 },
|
||||
totals: {
|
||||
plugins: 2,
|
||||
byScanStatus: { notScanned: 0, skipped: 0, clean: 1, warning: 0, error: 1 },
|
||||
findings: { total: 1, bySeverity: { info: 0, warning: 0, error: 1 } },
|
||||
},
|
||||
plugins: [clean, failing],
|
||||
});
|
||||
expect(stdout).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(String(stdout.mock.calls[0]?.[0]))).toEqual(report);
|
||||
expect(stderr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an explicit all-zero report for an empty catalog", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-30T20:00:00.000Z"));
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ items: [], nextCursor: null, done: true });
|
||||
|
||||
const report = await cmdExportPackageValidationReport(makeGlobalOpts(), { json: true });
|
||||
|
||||
expect(report).toEqual({
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-07-30T20:00:00.000Z",
|
||||
source: { registry: "https://clawhub.ai", pages: 1 },
|
||||
totals: {
|
||||
plugins: 0,
|
||||
byScanStatus: { notScanned: 0, skipped: 0, clean: 0, warning: 0, error: 0 },
|
||||
findings: { total: 0, bySeverity: { info: 0, warning: 0, error: 0 } },
|
||||
},
|
||||
plugins: [],
|
||||
});
|
||||
expect(stdout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("counts explicit missing, skipped, warning, and mixed-severity scan results", async () => {
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
const makePlugin = (
|
||||
id: string,
|
||||
status: "not-scanned" | "skipped" | "warning",
|
||||
findings: Array<{ severity: "info" | "warning" | "error"; code: string; message: string }>,
|
||||
) => ({
|
||||
package: { id: `packages:${id}`, name: id, displayName: id },
|
||||
release: { id: `packageReleases:${id}`, version: "1.0.0", createdAt: 100 },
|
||||
references: { packagePage: `/plugins/${id}`, release: `${id}@1.0.0` },
|
||||
scan: {
|
||||
status,
|
||||
scannedAt: status === "not-scanned" ? null : 200,
|
||||
target: status === "not-scanned" ? null : { channel: "beta", version: "beta.1" },
|
||||
inspectorVersion: status === "not-scanned" ? null : "0.5.0",
|
||||
skipReason: status === "skipped" ? "unchanged" : null,
|
||||
},
|
||||
findings,
|
||||
});
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
items: [
|
||||
makePlugin("missing", "not-scanned", []),
|
||||
makePlugin("skipped", "skipped", []),
|
||||
makePlugin("warning", "warning", [
|
||||
{ severity: "info", code: "info", message: "Informational" },
|
||||
{ severity: "warning", code: "warning", message: "Warning" },
|
||||
{ severity: "error", code: "error", message: "Error" },
|
||||
]),
|
||||
],
|
||||
nextCursor: null,
|
||||
done: true,
|
||||
});
|
||||
|
||||
const report = await cmdExportPackageValidationReport(makeGlobalOpts(), { json: true });
|
||||
|
||||
expect(report.totals).toEqual({
|
||||
plugins: 3,
|
||||
byScanStatus: { notScanned: 1, skipped: 1, clean: 0, warning: 1, error: 0 },
|
||||
findings: { total: 3, bySeverity: { info: 1, warning: 1, error: 1 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not write stdout when authentication fails", async () => {
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
authTokenMocks.requireAuthToken.mockRejectedValueOnce(new Error("Authentication required"));
|
||||
|
||||
await expect(
|
||||
cmdExportPackageValidationReport(makeGlobalOpts(), { json: true }),
|
||||
).rejects.toThrow("Authentication required");
|
||||
|
||||
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
|
||||
expect(stdout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when the API repeats a pagination cursor", async () => {
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: "same-page", done: false })
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: "same-page", done: false })
|
||||
.mockRejectedValueOnce(new Error("unexpected third request"));
|
||||
|
||||
await expect(
|
||||
cmdExportPackageValidationReport(makeGlobalOpts(), { json: true }),
|
||||
).rejects.toThrow("Validation report response repeated a pagination cursor");
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(stdout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when pages repeat a plugin", async () => {
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
const plugin = {
|
||||
package: { id: "packages:alpha", name: "alpha", displayName: "Alpha" },
|
||||
release: { id: "packageReleases:alpha", version: "1.0.0", createdAt: 100 },
|
||||
references: { packagePage: "/plugins/alpha", release: "alpha@1.0.0" },
|
||||
scan: {
|
||||
status: "not-scanned",
|
||||
scannedAt: null,
|
||||
target: null,
|
||||
inspectorVersion: null,
|
||||
skipReason: null,
|
||||
},
|
||||
findings: [],
|
||||
};
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({ items: [plugin], nextCursor: "page-2", done: false })
|
||||
.mockResolvedValueOnce({ items: [plugin], nextCursor: null, done: true });
|
||||
|
||||
await expect(
|
||||
cmdExportPackageValidationReport(makeGlobalOpts(), { json: true }),
|
||||
).rejects.toThrow("Validation report response repeated package packages:alpha");
|
||||
expect(stdout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when a truncated page omits its continuation cursor", async () => {
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ items: [], nextCursor: null, done: false });
|
||||
|
||||
await expect(
|
||||
cmdExportPackageValidationReport(makeGlobalOpts(), { json: true }),
|
||||
).rejects.toThrow("Validation report response omitted its pagination cursor");
|
||||
expect(stdout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdHardDeletePackage", () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { apiRequest, registryUrl } from "../../../clawhub/src/http.js";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1PackageValidationReportPageSchema,
|
||||
ApiV1PackageHardDeleteResponseSchema,
|
||||
ApiV1PackageModerationQueueResponseSchema,
|
||||
ApiV1PackageOfficialMigrationListResponseSchema,
|
||||
@@ -32,6 +33,8 @@ import {
|
||||
type PackageReportStatus,
|
||||
type PackageReleaseModerationState,
|
||||
type PackageTrustedPublisher,
|
||||
type ApiV1PackageValidationReportPage,
|
||||
type PackageValidationReportItem,
|
||||
} from "../../../clawhub/src/schema/index.js";
|
||||
|
||||
type PackageTrustedPublisherSetOptions = {
|
||||
@@ -130,6 +133,81 @@ type PackageHardDeleteOptions = {
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
type PackageValidationReportOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export async function cmdExportPackageValidationReport(
|
||||
opts: GlobalOpts,
|
||||
options: PackageValidationReportOptions = {},
|
||||
) {
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const plugins: PackageValidationReportItem[] = [];
|
||||
let cursor: string | null = null;
|
||||
let pages = 0;
|
||||
const seenCursors = new Set<string>();
|
||||
const seenPackageIds = new Set<string>();
|
||||
|
||||
do {
|
||||
const url = registryUrl(`${ApiRoutes.packages}/validation-report`, registry);
|
||||
url.searchParams.set("limit", "100");
|
||||
if (cursor) url.searchParams.set("cursor", cursor);
|
||||
const page = await apiRequest<ApiV1PackageValidationReportPage>(
|
||||
registry,
|
||||
{
|
||||
method: "GET",
|
||||
url: url.toString(),
|
||||
token,
|
||||
},
|
||||
ApiV1PackageValidationReportPageSchema,
|
||||
);
|
||||
pages += 1;
|
||||
for (const plugin of page.items) {
|
||||
if (seenPackageIds.has(plugin.package.id)) {
|
||||
fail(`Validation report response repeated package ${plugin.package.id}`);
|
||||
}
|
||||
seenPackageIds.add(plugin.package.id);
|
||||
plugins.push(plugin);
|
||||
}
|
||||
if (!page.done && !page.nextCursor) {
|
||||
fail("Validation report response omitted its pagination cursor");
|
||||
}
|
||||
const nextCursor = page.done ? null : page.nextCursor;
|
||||
if (nextCursor && seenCursors.has(nextCursor)) {
|
||||
fail("Validation report response repeated a pagination cursor");
|
||||
}
|
||||
if (nextCursor) seenCursors.add(nextCursor);
|
||||
cursor = nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
const byScanStatus = { notScanned: 0, skipped: 0, clean: 0, warning: 0, error: 0 };
|
||||
const bySeverity = { info: 0, warning: 0, error: 0 };
|
||||
let findingTotal = 0;
|
||||
for (const plugin of plugins) {
|
||||
const statusKey = plugin.scan.status === "not-scanned" ? "notScanned" : plugin.scan.status;
|
||||
byScanStatus[statusKey] += 1;
|
||||
for (const finding of plugin.findings) {
|
||||
bySeverity[finding.severity] += 1;
|
||||
findingTotal += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1 as const,
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: { registry, pages },
|
||||
totals: {
|
||||
plugins: plugins.length,
|
||||
byScanStatus,
|
||||
findings: { total: findingTotal, bySeverity },
|
||||
},
|
||||
plugins,
|
||||
};
|
||||
if (options.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
return report;
|
||||
}
|
||||
|
||||
export async function cmdHardDeletePackage(
|
||||
opts: GlobalOpts,
|
||||
packageName: string,
|
||||
|
||||
@@ -59,6 +59,41 @@ describe("packed admin CLI", () => {
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("Usage: clawhub-admin");
|
||||
const packagesHelp = spawnSync(
|
||||
process.execPath,
|
||||
[join(installDir, "package", "bin", "clawhub-admin.js"), "packages", "--help"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, FORCE_COLOR: "0" },
|
||||
},
|
||||
);
|
||||
expect(packagesHelp.status).toBe(0);
|
||||
expect(packagesHelp.stdout).toContain("validation-report");
|
||||
expect(packagesHelp.stdout).toContain("Export current plugin validation results as JSON");
|
||||
const authFailure = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
join(installDir, "package", "bin", "clawhub-admin.js"),
|
||||
"--registry",
|
||||
"https://example.invalid",
|
||||
"packages",
|
||||
"validation-report",
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWHUB_CONFIG_PATH: join(tempDir, "missing-auth.json"),
|
||||
FORCE_COLOR: "0",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(authFailure.status).not.toBe(0);
|
||||
expect(authFailure.stdout).toBe("");
|
||||
expect(authFailure.stderr).toMatch(/not logged in|login/i);
|
||||
await expect(
|
||||
readFile(join(installDir, "package", "dist", "clawhub-admin", "src", "cli.js")),
|
||||
).resolves.toBeTruthy();
|
||||
|
||||
@@ -373,6 +373,54 @@ export const ApiV1PackageListResponseSchema = type({
|
||||
nextCursor: "string|null",
|
||||
});
|
||||
|
||||
export const PackageValidationReportScanStatusSchema = type(
|
||||
'"not-scanned"|"skipped"|"clean"|"warning"|"error"',
|
||||
);
|
||||
export type PackageValidationReportScanStatus =
|
||||
(typeof PackageValidationReportScanStatusSchema)[inferred];
|
||||
|
||||
export const PackageValidationReportFindingSeveritySchema = type('"info"|"warning"|"error"');
|
||||
export type PackageValidationReportFindingSeverity =
|
||||
(typeof PackageValidationReportFindingSeveritySchema)[inferred];
|
||||
|
||||
export const PackageValidationReportItemSchema = type({
|
||||
package: {
|
||||
id: "string",
|
||||
name: "string",
|
||||
displayName: "string",
|
||||
},
|
||||
release: {
|
||||
id: "string",
|
||||
version: "string",
|
||||
createdAt: "number",
|
||||
},
|
||||
references: {
|
||||
packagePage: "string",
|
||||
release: "string",
|
||||
},
|
||||
scan: {
|
||||
status: PackageValidationReportScanStatusSchema,
|
||||
scannedAt: "number|null",
|
||||
target: type({ channel: "string", version: "string" }).or("null"),
|
||||
inspectorVersion: "string|null",
|
||||
skipReason: "string|null",
|
||||
},
|
||||
findings: type({
|
||||
severity: PackageValidationReportFindingSeveritySchema,
|
||||
code: "string",
|
||||
message: "string",
|
||||
}).array(),
|
||||
});
|
||||
export type PackageValidationReportItem = (typeof PackageValidationReportItemSchema)[inferred];
|
||||
|
||||
export const ApiV1PackageValidationReportPageSchema = type({
|
||||
items: PackageValidationReportItemSchema.array(),
|
||||
nextCursor: "string|null",
|
||||
done: "boolean",
|
||||
});
|
||||
export type ApiV1PackageValidationReportPage =
|
||||
(typeof ApiV1PackageValidationReportPageSchema)[inferred];
|
||||
|
||||
export const ApiV1PackageSearchResponseSchema = type({
|
||||
results: type({
|
||||
score: "number",
|
||||
|
||||
Vendored
+72
@@ -389,6 +389,78 @@ export declare const ApiV1PackageListResponseSchema: import("arktype/internal/va
|
||||
nextCursor: string | null;
|
||||
}, {}>;
|
||||
export type ApiV1PackageListResponse = (typeof ApiV1PackageListResponseSchema)[inferred];
|
||||
export declare const PackageValidationReportScanStatusSchema: import("arktype/internal/variants/string.ts").StringType<"clean" | "error" | "not-scanned" | "skipped" | "warning", {}>;
|
||||
export type PackageValidationReportScanStatus = (typeof PackageValidationReportScanStatusSchema)[inferred];
|
||||
export declare const PackageValidationReportFindingSeveritySchema: import("arktype/internal/variants/string.ts").StringType<"error" | "info" | "warning", {}>;
|
||||
export type PackageValidationReportFindingSeverity = (typeof PackageValidationReportFindingSeveritySchema)[inferred];
|
||||
export declare const PackageValidationReportItemSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
package: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
release: {
|
||||
id: string;
|
||||
version: string;
|
||||
createdAt: number;
|
||||
};
|
||||
references: {
|
||||
packagePage: string;
|
||||
release: string;
|
||||
};
|
||||
scan: {
|
||||
status: "clean" | "error" | "not-scanned" | "skipped" | "warning";
|
||||
scannedAt: number | null;
|
||||
target: {
|
||||
channel: string;
|
||||
version: string;
|
||||
} | null;
|
||||
inspectorVersion: string | null;
|
||||
skipReason: string | null;
|
||||
};
|
||||
findings: {
|
||||
severity: "error" | "info" | "warning";
|
||||
code: string;
|
||||
message: string;
|
||||
}[];
|
||||
}, {}>;
|
||||
export type PackageValidationReportItem = (typeof PackageValidationReportItemSchema)[inferred];
|
||||
export declare const ApiV1PackageValidationReportPageSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
items: {
|
||||
package: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
release: {
|
||||
id: string;
|
||||
version: string;
|
||||
createdAt: number;
|
||||
};
|
||||
references: {
|
||||
packagePage: string;
|
||||
release: string;
|
||||
};
|
||||
scan: {
|
||||
status: "clean" | "error" | "not-scanned" | "skipped" | "warning";
|
||||
scannedAt: number | null;
|
||||
target: {
|
||||
channel: string;
|
||||
version: string;
|
||||
} | null;
|
||||
inspectorVersion: string | null;
|
||||
skipReason: string | null;
|
||||
};
|
||||
findings: {
|
||||
severity: "error" | "info" | "warning";
|
||||
code: string;
|
||||
message: string;
|
||||
}[];
|
||||
}[];
|
||||
nextCursor: string | null;
|
||||
done: boolean;
|
||||
}, {}>;
|
||||
export type ApiV1PackageValidationReportPage = (typeof ApiV1PackageValidationReportPageSchema)[inferred];
|
||||
export declare const ApiV1PackageSearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
results: {
|
||||
score: number;
|
||||
|
||||
Vendored
+35
@@ -301,6 +301,41 @@ export const ApiV1PackageListResponseSchema = type({
|
||||
items: PackageListItemSchema.array(),
|
||||
nextCursor: "string|null",
|
||||
});
|
||||
export const PackageValidationReportScanStatusSchema = type('"not-scanned"|"skipped"|"clean"|"warning"|"error"');
|
||||
export const PackageValidationReportFindingSeveritySchema = type('"info"|"warning"|"error"');
|
||||
export const PackageValidationReportItemSchema = type({
|
||||
package: {
|
||||
id: "string",
|
||||
name: "string",
|
||||
displayName: "string",
|
||||
},
|
||||
release: {
|
||||
id: "string",
|
||||
version: "string",
|
||||
createdAt: "number",
|
||||
},
|
||||
references: {
|
||||
packagePage: "string",
|
||||
release: "string",
|
||||
},
|
||||
scan: {
|
||||
status: PackageValidationReportScanStatusSchema,
|
||||
scannedAt: "number|null",
|
||||
target: type({ channel: "string", version: "string" }).or("null"),
|
||||
inspectorVersion: "string|null",
|
||||
skipReason: "string|null",
|
||||
},
|
||||
findings: type({
|
||||
severity: PackageValidationReportFindingSeveritySchema,
|
||||
code: "string",
|
||||
message: "string",
|
||||
}).array(),
|
||||
});
|
||||
export const ApiV1PackageValidationReportPageSchema = type({
|
||||
items: PackageValidationReportItemSchema.array(),
|
||||
nextCursor: "string|null",
|
||||
done: "boolean",
|
||||
});
|
||||
export const ApiV1PackageSearchResponseSchema = type({
|
||||
results: type({
|
||||
score: "number",
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -404,6 +404,54 @@ export const ApiV1PackageListResponseSchema = type({
|
||||
});
|
||||
export type ApiV1PackageListResponse = (typeof ApiV1PackageListResponseSchema)[inferred];
|
||||
|
||||
export const PackageValidationReportScanStatusSchema = type(
|
||||
'"not-scanned"|"skipped"|"clean"|"warning"|"error"',
|
||||
);
|
||||
export type PackageValidationReportScanStatus =
|
||||
(typeof PackageValidationReportScanStatusSchema)[inferred];
|
||||
|
||||
export const PackageValidationReportFindingSeveritySchema = type('"info"|"warning"|"error"');
|
||||
export type PackageValidationReportFindingSeverity =
|
||||
(typeof PackageValidationReportFindingSeveritySchema)[inferred];
|
||||
|
||||
export const PackageValidationReportItemSchema = type({
|
||||
package: {
|
||||
id: "string",
|
||||
name: "string",
|
||||
displayName: "string",
|
||||
},
|
||||
release: {
|
||||
id: "string",
|
||||
version: "string",
|
||||
createdAt: "number",
|
||||
},
|
||||
references: {
|
||||
packagePage: "string",
|
||||
release: "string",
|
||||
},
|
||||
scan: {
|
||||
status: PackageValidationReportScanStatusSchema,
|
||||
scannedAt: "number|null",
|
||||
target: type({ channel: "string", version: "string" }).or("null"),
|
||||
inspectorVersion: "string|null",
|
||||
skipReason: "string|null",
|
||||
},
|
||||
findings: type({
|
||||
severity: PackageValidationReportFindingSeveritySchema,
|
||||
code: "string",
|
||||
message: "string",
|
||||
}).array(),
|
||||
});
|
||||
export type PackageValidationReportItem = (typeof PackageValidationReportItemSchema)[inferred];
|
||||
|
||||
export const ApiV1PackageValidationReportPageSchema = type({
|
||||
items: PackageValidationReportItemSchema.array(),
|
||||
nextCursor: "string|null",
|
||||
done: "boolean",
|
||||
});
|
||||
export type ApiV1PackageValidationReportPage =
|
||||
(typeof ApiV1PackageValidationReportPageSchema)[inferred];
|
||||
|
||||
export const ApiV1PackageSearchResponseSchema = type({
|
||||
results: type({
|
||||
score: "number",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseArk } from "./ark.js";
|
||||
import { ApiV1PackageValidationReportPageSchema } from "./packages.js";
|
||||
|
||||
describe("ApiV1PackageValidationReportPageSchema", () => {
|
||||
it("accepts a paginated plugin validation report page", () => {
|
||||
const page = {
|
||||
items: [
|
||||
{
|
||||
package: { id: "packages:demo", name: "@openclaw/demo", displayName: "Demo" },
|
||||
release: { id: "packageReleases:demo", version: "1.2.3", createdAt: 100 },
|
||||
references: {
|
||||
packagePage: "/plugins/%40openclaw%2Fdemo",
|
||||
release: "@openclaw/demo@1.2.3",
|
||||
},
|
||||
scan: {
|
||||
status: "error",
|
||||
scannedAt: 200,
|
||||
target: { channel: "beta", version: "2026.7.30-beta.1" },
|
||||
inspectorVersion: "0.3.19",
|
||||
skipReason: null,
|
||||
},
|
||||
findings: [
|
||||
{ severity: "error", code: "missing-api", message: "Required API is unavailable" },
|
||||
],
|
||||
},
|
||||
],
|
||||
nextCursor: "page-2",
|
||||
done: false,
|
||||
};
|
||||
|
||||
expect(parseArk(ApiV1PackageValidationReportPageSchema, page, "validation report")).toEqual(
|
||||
page,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown scan statuses", () => {
|
||||
expect(() =>
|
||||
parseArk(
|
||||
ApiV1PackageValidationReportPageSchema,
|
||||
{
|
||||
items: [
|
||||
{
|
||||
package: { id: "packages:demo", name: "demo", displayName: "Demo" },
|
||||
release: { id: "packageReleases:demo", version: "1.0.0", createdAt: 100 },
|
||||
references: { packagePage: "/plugins/demo", release: "demo@1.0.0" },
|
||||
scan: {
|
||||
status: "stale",
|
||||
scannedAt: null,
|
||||
target: null,
|
||||
inspectorVersion: null,
|
||||
skipReason: null,
|
||||
},
|
||||
findings: [],
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
done: true,
|
||||
},
|
||||
"validation report",
|
||||
),
|
||||
).toThrow(/status/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user