mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 01:12:11 +00:00
Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca33ea3839 | ||
|
|
4019c727b2 | ||
|
|
66d1814ee5 | ||
|
|
3f31e5bb03 | ||
|
|
879f4d95d4 | ||
|
|
b89f7d95e3 | ||
|
|
6ff0c9e560 | ||
|
|
aad6ff1912 | ||
|
|
b8b449f6f5 | ||
|
|
e538e7396c | ||
|
|
d13387d1bb | ||
|
|
d89b9d1075 | ||
|
|
3df5102259 | ||
|
|
9d397472fc | ||
|
|
3ed917f973 | ||
|
|
24a0fc10f6 | ||
|
|
8a19cd0a3e | ||
|
|
bb69b7c4df | ||
|
|
9b3904fa08 | ||
|
|
8506c3cbb3 | ||
|
|
6de67497e1 | ||
|
|
56953f5a2a | ||
|
|
b9d3620ca7 | ||
|
|
f71890dab3 | ||
|
|
ebaa5ed270 | ||
|
|
0d36f5e153 | ||
|
|
79f4dded4c | ||
|
|
14ce0288d7 | ||
|
|
ec6e960b32 | ||
|
|
98e3e663f3 | ||
|
|
d14eb821bc | ||
|
|
5e05b0e29a | ||
|
|
a11ee6245c | ||
|
|
9bbd9c2f53 | ||
|
|
343deb5611 | ||
|
|
a610f0d812 | ||
|
|
92c620b2e8 | ||
|
|
3b87bfcf0b | ||
|
|
c30b5f4ab0 | ||
|
|
b0d3ac36a2 | ||
|
|
3aa2d2da3a | ||
|
|
6516bce5ca | ||
|
|
9b1c727de6 | ||
|
|
2f3852f857 | ||
|
|
5bc359f3c7 | ||
|
|
d993daa2d6 | ||
|
|
a0fa36f57f | ||
|
|
6c4a8cb1c6 | ||
|
|
83f7b52805 | ||
|
|
336c4741cb | ||
|
|
3fe68a0c13 | ||
|
|
675b9324de | ||
|
|
5aee884618 | ||
|
|
8d44391c51 | ||
|
|
65dd5f2a52 | ||
|
|
f26abe7976 | ||
|
|
5d56bca4bb | ||
|
|
a43970b1ec | ||
|
|
afcb2b1150 | ||
|
|
f85914e8c2 | ||
|
|
f75061e492 | ||
|
|
af881532bb | ||
|
|
fc1aa31328 | ||
|
|
36ba9a679b | ||
|
|
92ca2c04cc | ||
|
|
ae659c042a | ||
|
|
10afffc712 | ||
|
|
fdecff9c9c | ||
|
|
48f94cdd57 | ||
|
|
df95443da5 | ||
|
|
76450a57ea | ||
|
|
de58458210 | ||
|
|
86af5c07df | ||
|
|
938caf8ed5 | ||
|
|
2262f00bf5 | ||
|
|
692db1da9a | ||
|
|
0e0510f7ea | ||
|
|
58b619708d | ||
|
|
1d38dc5592 | ||
|
|
b4c443f08f | ||
|
|
86d3c2a29a | ||
|
|
efdaf8381b | ||
|
|
41cebd32bb | ||
|
|
fae573f534 | ||
|
|
b468fd32f8 | ||
|
|
1f985013e1 | ||
|
|
97a8ad8f93 | ||
|
|
023d0cb863 |
+20
-1
@@ -17,6 +17,7 @@ import type { MutationCtx } from "./_generated/server";
|
||||
import {
|
||||
deletePackageSearchDigests,
|
||||
extractPackageDigestFields,
|
||||
extractPackageClawPackDigestFields,
|
||||
upsertPackageSearchDigest,
|
||||
} from "./lib/packageSearchDigest";
|
||||
import { getOwnerPublisher } from "./lib/publishers";
|
||||
@@ -130,6 +131,7 @@ async function syncPackageSearchDigest(
|
||||
});
|
||||
await upsertPackageSearchDigest(ctx, {
|
||||
...fields,
|
||||
...extractPackageClawPackDigestFields(latestRelease),
|
||||
latestVersion:
|
||||
latestRelease && !latestRelease.softDeletedAt ? latestRelease.version : undefined,
|
||||
ownerHandle: owner?.handle ?? "",
|
||||
@@ -147,6 +149,23 @@ export async function syncPackageSearchDigestForPackageId(
|
||||
await syncPackageSearchDigest(ctx, pkg);
|
||||
}
|
||||
|
||||
function packageReleaseDigestFieldsChanged(
|
||||
oldDoc: Doc<"packageReleases">,
|
||||
newDoc: Doc<"packageReleases">,
|
||||
) {
|
||||
return (
|
||||
oldDoc.softDeletedAt !== newDoc.softDeletedAt ||
|
||||
oldDoc.clawpackStorageId !== newDoc.clawpackStorageId ||
|
||||
oldDoc.clawpackSha256 !== newDoc.clawpackSha256 ||
|
||||
oldDoc.clawpackBuiltAt !== newDoc.clawpackBuiltAt ||
|
||||
oldDoc.clawpackRevokedAt !== newDoc.clawpackRevokedAt ||
|
||||
JSON.stringify(oldDoc.hostTargetsSummary ?? []) !==
|
||||
JSON.stringify(newDoc.hostTargetsSummary ?? []) ||
|
||||
JSON.stringify(oldDoc.environmentSummary ?? null) !==
|
||||
JSON.stringify(newDoc.environmentSummary ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncPackageSearchDigestsForOwnerUserId(
|
||||
ctx: PackageDigestSyncCtx,
|
||||
ownerUserId: Id<"users"> | null | undefined,
|
||||
@@ -402,7 +421,7 @@ triggers.register("packageReleases", async (ctx, change) => {
|
||||
if (change.operation === "insert") return;
|
||||
if (
|
||||
change.operation === "update" &&
|
||||
change.oldDoc.softDeletedAt === change.newDoc.softDeletedAt
|
||||
!packageReleaseDigestFieldsChanged(change.oldDoc, change.newDoc)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
soulsPostRouterV1Http,
|
||||
starsDeleteRouterV1Http,
|
||||
starsPostRouterV1Http,
|
||||
clawpacksGetRouterV1Http,
|
||||
transfersGetRouterV1Http,
|
||||
usersListV1Http,
|
||||
usersPostRouterV1Http,
|
||||
@@ -115,6 +116,12 @@ http.route({
|
||||
handler: pluginsGetRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: "/api/v1/clawpacks/",
|
||||
method: "GET",
|
||||
handler: clawpacksGetRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.skills,
|
||||
method: "POST",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* @vitest-environment node */
|
||||
import { unzipSync } from "fflate";
|
||||
import { unzipSync, zipSync } from "fflate";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { internal } from "./_generated/api";
|
||||
import { RATE_LIMITS } from "./lib/httpRateLimit";
|
||||
@@ -3795,6 +3795,741 @@ describe("httpApiV1 handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("package download serves the stored Claw Pack artifact when present", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "@openclaw/kitchen-sink",
|
||||
displayName: "Kitchen Sink",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "publishers:openclaw", handle: "openclaw" },
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
files: [
|
||||
{
|
||||
path: "package.json",
|
||||
size: 2,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:file",
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
clawpackStorageId: "storage:clawpack",
|
||||
clawpackSha256: "ab".repeat(32),
|
||||
clawpackSize: 13,
|
||||
clawpackSpecVersion: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const storageGet = vi.fn(async (storageId: string) => {
|
||||
if (storageId === "storage:clawpack") {
|
||||
return new Blob(["clawpack zip"], { type: "application/zip" });
|
||||
}
|
||||
throw new Error(`unexpected storage read: ${storageId}`);
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: {
|
||||
get: storageGet,
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/v1/packages/%40openclaw%2Fkitchen-sink/download"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("clawpack zip");
|
||||
expect(storageGet).toHaveBeenCalledTimes(1);
|
||||
expect(storageGet).toHaveBeenCalledWith("storage:clawpack");
|
||||
expect(response.headers.get("Content-Disposition")).toBe(
|
||||
'attachment; filename="@openclaw-kitchen-sink-1.0.0.clawpack.zip"',
|
||||
);
|
||||
expect(response.headers.get("ETag")).toBe(`"sha256:${"ab".repeat(32)}"`);
|
||||
expect(response.headers.get("Digest")).toBe(
|
||||
`sha-256=${Buffer.from("ab".repeat(32), "hex").toString("base64")}`,
|
||||
);
|
||||
expect(response.headers.get("X-ClawHub-ClawPack-Sha256")).toBe("ab".repeat(32));
|
||||
expect(response.headers.get("X-ClawHub-ClawPack-Spec-Version")).toBe("1");
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.packages.recordPackageDownloadInternal, {
|
||||
packageId: "packages:1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns release Claw Pack metadata without reading the artifact blob", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("version" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "@openclaw/kitchen-sink",
|
||||
displayName: "Kitchen Sink",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
files: [],
|
||||
clawpackStorageId: "storage:clawpack",
|
||||
clawpackSha256: "ab".repeat(32),
|
||||
clawpackSize: 13,
|
||||
clawpackSpecVersion: 1,
|
||||
clawpackFileCount: 3,
|
||||
clawpackManifestSha256: "cd".repeat(32),
|
||||
hostTargetsSummary: [{ os: "darwin", arch: "arm64", supportState: "supported" }],
|
||||
environmentSummary: { requiresLocalDesktop: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "@openclaw/kitchen-sink",
|
||||
displayName: "Kitchen Sink",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "publishers:openclaw", handle: "openclaw" },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const storageGet = vi.fn();
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: {
|
||||
get: storageGet,
|
||||
},
|
||||
}),
|
||||
new Request(
|
||||
"https://example.com/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack",
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.clawpack).toMatchObject({
|
||||
available: true,
|
||||
sha256: "ab".repeat(32),
|
||||
size: 13,
|
||||
fileCount: 3,
|
||||
manifestSha256: "cd".repeat(32),
|
||||
});
|
||||
expect(body.links).toEqual({
|
||||
download: "/api/v1/packages/%40openclaw%2Fkitchen-sink/download?version=1.0.0",
|
||||
immutable: `/api/v1/clawpacks/${"ab".repeat(32)}`,
|
||||
manifest: "/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/manifest",
|
||||
});
|
||||
expect(storageGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a release Claw Pack manifest from the stored artifact", async () => {
|
||||
const manifest = {
|
||||
kind: "openclaw.clawpack",
|
||||
specVersion: 1,
|
||||
package: { name: "@openclaw/kitchen-sink", version: "1.0.0" },
|
||||
files: [{ path: "package.json", sha256: "a".repeat(64), size: 2 }],
|
||||
};
|
||||
const zip = zipSync({
|
||||
"package/CLAWPACK.json": new TextEncoder().encode(JSON.stringify(manifest)),
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("version" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "@openclaw/kitchen-sink",
|
||||
displayName: "Kitchen Sink",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
files: [],
|
||||
clawpackStorageId: "storage:clawpack",
|
||||
clawpackSha256: "ab".repeat(32),
|
||||
clawpackSize: zip.byteLength,
|
||||
clawpackSpecVersion: 1,
|
||||
clawpackFileCount: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "@openclaw/kitchen-sink",
|
||||
displayName: "Kitchen Sink",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "publishers:openclaw", handle: "openclaw" },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const storageGet = vi.fn(async (storageId: string) => {
|
||||
if (storageId === "storage:clawpack") {
|
||||
const zipBlobPart = zip.buffer.slice(
|
||||
zip.byteOffset,
|
||||
zip.byteOffset + zip.byteLength,
|
||||
) as ArrayBuffer;
|
||||
return new Blob([zipBlobPart], {
|
||||
type: "application/zip",
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected storage read: ${storageId}`);
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: {
|
||||
get: storageGet,
|
||||
},
|
||||
}),
|
||||
new Request(
|
||||
"https://example.com/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/manifest",
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.package).toEqual({
|
||||
name: "@openclaw/kitchen-sink",
|
||||
displayName: "Kitchen Sink",
|
||||
family: "code-plugin",
|
||||
});
|
||||
expect(body.version).toBe("1.0.0");
|
||||
expect(body.clawpack.sha256).toBe("ab".repeat(32));
|
||||
expect(body.manifest).toEqual(manifest);
|
||||
expect(storageGet).toHaveBeenCalledWith("storage:clawpack");
|
||||
});
|
||||
|
||||
it("package download refuses revoked Claw Pack artifacts", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "@openclaw/kitchen-sink",
|
||||
displayName: "Kitchen Sink",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "publishers:openclaw", handle: "openclaw" },
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
files: [],
|
||||
clawpackStorageId: "storage:clawpack",
|
||||
clawpackSha256: "ab".repeat(32),
|
||||
clawpackSize: 13,
|
||||
clawpackSpecVersion: 1,
|
||||
clawpackRevokedAt: 1_763_000_000_000,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const storageGet = vi.fn();
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: {
|
||||
get: storageGet,
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/v1/packages/%40openclaw%2Fkitchen-sink/download"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(410);
|
||||
expect(await response.text()).toBe("Claw Pack revoked");
|
||||
expect(storageGet).not.toHaveBeenCalled();
|
||||
expect(
|
||||
runMutation.mock.calls.some(
|
||||
([ref]) => ref === internal.packages.recordPackageDownloadInternal,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("serves Claw Pack artifacts by digest with immutable cache headers", async () => {
|
||||
const sha256 = "ab".repeat(32);
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (args.sha256 === sha256) {
|
||||
return {
|
||||
status: "ok",
|
||||
artifact: {
|
||||
storageId: "storage:clawpack",
|
||||
sha256,
|
||||
size: 13,
|
||||
format: "zip",
|
||||
},
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "@openclaw/kitchen-sink",
|
||||
},
|
||||
release: {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
clawpackSpecVersion: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const storageGet = vi.fn(async () => new Blob(["clawpack zip"], { type: "application/zip" }));
|
||||
|
||||
const response = await __handlers.clawpacksGetRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: { get: storageGet },
|
||||
}),
|
||||
new Request(`https://example.com/api/v1/clawpacks/${sha256}`),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("clawpack zip");
|
||||
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
|
||||
expect(response.headers.get("ETag")).toBe(`"sha256:${sha256}"`);
|
||||
expect(response.headers.get("Digest")).toBe(
|
||||
`sha-256=${Buffer.from(sha256, "hex").toString("base64")}`,
|
||||
);
|
||||
expect(storageGet).toHaveBeenCalledWith("storage:clawpack");
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.packages.recordPackageDownloadInternal, {
|
||||
packageId: "packages:1",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports HEAD for digest-addressed Claw Pack artifacts without recording a download", async () => {
|
||||
const sha256 = "cd".repeat(32);
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (args.sha256 === sha256) {
|
||||
return {
|
||||
status: "ok",
|
||||
artifact: {
|
||||
storageId: "storage:clawpack",
|
||||
sha256,
|
||||
size: 13,
|
||||
format: "zip",
|
||||
},
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "demo",
|
||||
},
|
||||
release: {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
clawpackSpecVersion: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const storageGet = vi.fn(async () => new Blob(["clawpack zip"], { type: "application/zip" }));
|
||||
|
||||
const response = await __handlers.clawpacksGetRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: { get: storageGet },
|
||||
}),
|
||||
new Request(`https://example.com/api/v1/clawpacks/${sha256}`, { method: "HEAD" }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("");
|
||||
expect(response.headers.get("Content-Length")).toBe("13");
|
||||
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
|
||||
expect(storageGet).toHaveBeenCalledWith("storage:clawpack");
|
||||
expect(
|
||||
runMutation.mock.calls.some(
|
||||
([ref]) => ref === internal.packages.recordPackageDownloadInternal,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns gone for revoked digest-addressed Claw Pack artifacts", async () => {
|
||||
const sha256 = "ef".repeat(32);
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (args.sha256 === sha256) return { status: "revoked" };
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.clawpacksGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation, storage: { get: vi.fn() } }),
|
||||
new Request(`https://example.com/api/v1/clawpacks/${sha256}`),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(410);
|
||||
});
|
||||
|
||||
it("clawpack migration status requires an admin API token", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
missingSample: [],
|
||||
missingSampleSize: 0,
|
||||
generatedClawPackSampleSize: 1,
|
||||
generatedClawPackBytes: 1024,
|
||||
sampleLimit: args.limit,
|
||||
};
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/clawpack/migration-status?limit=7"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
generatedClawPackSampleSize: 1,
|
||||
sampleLimit: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("clawpack migration readiness requires an admin API token", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
bundledPluginId: "opik",
|
||||
readinessState: "clawpack-missing",
|
||||
blockers: ["clawpack-missing"],
|
||||
},
|
||||
],
|
||||
readyCount: 1,
|
||||
blockedCount: 0,
|
||||
generatedAt: 1,
|
||||
};
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/clawpack/migration-readiness"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
readyCount: 1,
|
||||
items: [{ bundledPluginId: "opik", readinessState: "clawpack-missing" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("clawpack migration run dry-run and list routes require an admin API token", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
if (args.operation) {
|
||||
return {
|
||||
operation: args.operation,
|
||||
limit: args.limit,
|
||||
candidates: [{ name: "demo-plugin", version: "1.0.0" }],
|
||||
candidateCount: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
items: [{ _id: "clawPackMigrationRuns:1", status: "pending" }],
|
||||
limit: args.limit,
|
||||
status: args.status ?? null,
|
||||
};
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const dryRunResponse = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request(
|
||||
"https://example.com/api/v1/packages/clawpack/migration-runs/dry-run?operation=artifact-backfill&limit=5",
|
||||
),
|
||||
);
|
||||
const listResponse = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request(
|
||||
"https://example.com/api/v1/packages/clawpack/migration-runs?status=pending&limit=10",
|
||||
),
|
||||
);
|
||||
|
||||
expect(dryRunResponse.status).toBe(200);
|
||||
await expect(dryRunResponse.json()).resolves.toMatchObject({
|
||||
operation: "artifact-backfill",
|
||||
candidateCount: 1,
|
||||
});
|
||||
expect(listResponse.status).toBe(200);
|
||||
await expect(listResponse.json()).resolves.toMatchObject({
|
||||
items: [{ _id: "clawPackMigrationRuns:1" }],
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("clawpack migration run create and continue routes dispatch admin operations", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
_id: "clawPackMigrationRuns:1",
|
||||
actorUserId: args.actorUserId,
|
||||
operation: args.operation,
|
||||
status: "pending",
|
||||
limit: args.limit,
|
||||
};
|
||||
});
|
||||
const runAction = vi.fn().mockResolvedValue({
|
||||
run: { _id: "clawPackMigrationRuns:1", status: "completed" },
|
||||
result: { processed: 1, succeeded: 1, failed: 0 },
|
||||
});
|
||||
|
||||
const createResponse = await __handlers.packagesPostRouterV1Handler(
|
||||
makeCtx({ runAction, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/clawpack/migration-runs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ operation: "failure-retry", limit: 3 }),
|
||||
}),
|
||||
);
|
||||
const continueResponse = await __handlers.packagesPostRouterV1Handler(
|
||||
makeCtx({ runAction, runMutation }),
|
||||
new Request(
|
||||
"https://example.com/api/v1/packages/clawpack/migration-runs/clawPackMigrationRuns:1/continue",
|
||||
{ method: "POST" },
|
||||
),
|
||||
);
|
||||
|
||||
expect(createResponse.status).toBe(200);
|
||||
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
actorUserId: "users:admin",
|
||||
operation: "failure-retry",
|
||||
limit: 3,
|
||||
});
|
||||
await expect(createResponse.json()).resolves.toMatchObject({
|
||||
_id: "clawPackMigrationRuns:1",
|
||||
operation: "failure-retry",
|
||||
});
|
||||
expect(continueResponse.status).toBe(200);
|
||||
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
|
||||
actorUserId: "users:admin",
|
||||
runId: "clawPackMigrationRuns:1",
|
||||
});
|
||||
await expect(continueResponse.json()).resolves.toMatchObject({
|
||||
result: { processed: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("clawpack backfill dispatches the admin action", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runAction = vi.fn().mockResolvedValue({
|
||||
processed: 2,
|
||||
succeeded: 2,
|
||||
failed: 0,
|
||||
results: [],
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesPostRouterV1Handler(
|
||||
makeCtx({ runAction, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/clawpack/backfill", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ limit: 2 }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
|
||||
actorUserId: "users:admin",
|
||||
limit: 2,
|
||||
});
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
processed: 2,
|
||||
succeeded: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("clawpack index backfill dispatches the admin action", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runAction = vi.fn().mockResolvedValue({
|
||||
processed: 2,
|
||||
succeeded: 2,
|
||||
failed: 0,
|
||||
results: [],
|
||||
continueCursor: "cursor:2",
|
||||
isDone: false,
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesPostRouterV1Handler(
|
||||
makeCtx({ runAction, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/clawpack/index-backfill", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ limit: 2, cursor: "cursor:1" }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
|
||||
actorUserId: "users:admin",
|
||||
limit: 2,
|
||||
cursor: "cursor:1",
|
||||
});
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
processed: 2,
|
||||
continueCursor: "cursor:2",
|
||||
});
|
||||
});
|
||||
|
||||
it("clawpack failure retry dispatches the admin action", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const runAction = vi.fn().mockResolvedValue({
|
||||
processed: 1,
|
||||
succeeded: 1,
|
||||
failed: 0,
|
||||
results: [],
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesPostRouterV1Handler(
|
||||
makeCtx({ runAction, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/clawpack/retry-failures", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ limit: 1 }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
|
||||
actorUserId: "users:admin",
|
||||
limit: 1,
|
||||
});
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
processed: 1,
|
||||
succeeded: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("clawpack revoke dispatches the moderator mutation", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
packageId: "packages:1",
|
||||
releaseId: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
sha256: "ab".repeat(32),
|
||||
revokedArtifactCount: 1,
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request(
|
||||
"https://example.com/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/revoke",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: "malware confirmed" }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: true,
|
||||
releaseId: "packageReleases:1",
|
||||
revokedArtifactCount: 1,
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
actorUserId: "users:moderator",
|
||||
name: "@openclaw/kitchen-sink",
|
||||
version: "1.0.0",
|
||||
reason: "malware confirmed",
|
||||
});
|
||||
});
|
||||
|
||||
it("package download fails when any stored file is missing", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
packagesPostRouterV1Handler,
|
||||
pluginsGetRouterV1Handler,
|
||||
publishPackageV1Handler,
|
||||
clawpacksGetRouterV1Handler,
|
||||
} from "./httpApiV1/packagesV1";
|
||||
import {
|
||||
listSkillsV1Handler,
|
||||
@@ -38,6 +39,7 @@ export const packagesGetRouterV1Http = httpAction(packagesGetRouterV1Handler);
|
||||
export const packagesPostRouterV1Http = httpAction(packagesPostRouterV1Handler);
|
||||
export const packagesDeleteRouterV1Http = httpAction(packagesDeleteRouterV1Handler);
|
||||
export const pluginsGetRouterV1Http = httpAction(pluginsGetRouterV1Handler);
|
||||
export const clawpacksGetRouterV1Http = httpAction(clawpacksGetRouterV1Handler);
|
||||
export const publishPackageV1Http = httpAction(publishPackageV1Handler);
|
||||
export const mintPublishTokenV1Http = httpAction(mintPublishTokenV1Handler);
|
||||
export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler);
|
||||
@@ -72,6 +74,7 @@ export const __handlers = {
|
||||
packagesPostRouterV1Handler,
|
||||
packagesDeleteRouterV1Handler,
|
||||
pluginsGetRouterV1Handler,
|
||||
clawpacksGetRouterV1Handler,
|
||||
publishPackageV1Handler,
|
||||
mintPublishTokenV1Handler,
|
||||
listCodePluginsV1Handler,
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
PublishTokenMintRequestSchema,
|
||||
parseArk,
|
||||
} from "clawhub-schema";
|
||||
import { ApiRoutes } from "clawhub-schema/routes";
|
||||
import { unzipSync } from "fflate";
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
json,
|
||||
resolveTagsBatch,
|
||||
requireApiTokenUserOrResponse,
|
||||
requireAdminOrResponse,
|
||||
requirePackagePublishAuthOrResponse,
|
||||
safeTextFileResponse,
|
||||
softDeleteErrorToResponse,
|
||||
@@ -53,11 +56,23 @@ const internalRefs = internal as unknown as {
|
||||
listVersionsForViewerInternal: unknown;
|
||||
getPackageByNameInternal: unknown;
|
||||
getTrustedPublisherByPackageIdInternal: unknown;
|
||||
getClawPackArtifactByShaForViewerInternal: unknown;
|
||||
getVersionByNameForViewerInternal: unknown;
|
||||
publishPackageForUserInternal: unknown;
|
||||
publishPackageForTrustedPublisherInternal: unknown;
|
||||
setTrustedPublisherForUserInternal: unknown;
|
||||
deleteTrustedPublisherForUserInternal: unknown;
|
||||
backfillClawPackArtifactsInternal: unknown;
|
||||
backfillClawPackSearchIndexInternal: unknown;
|
||||
retryClawPackBackfillFailuresInternal: unknown;
|
||||
getClawPackMigrationStatusInternal: unknown;
|
||||
dryRunClawPackMigrationRunForStaffInternal: unknown;
|
||||
listClawPackMigrationRunsForStaffInternal: unknown;
|
||||
getClawPackMigrationRunInternal: unknown;
|
||||
startClawPackMigrationRunInternal: unknown;
|
||||
continueClawPackMigrationRunInternal: unknown;
|
||||
listOfficialMigrationReadinessForStaffInternal: unknown;
|
||||
revokeClawPackArtifactForStaffInternal: unknown;
|
||||
getReleasesByIdsInternal: unknown;
|
||||
getReleaseByPackageAndVersionInternal: unknown;
|
||||
getReleaseByIdInternal: unknown;
|
||||
@@ -108,10 +123,29 @@ type PackageListQueryArgs = {
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
hostTarget?: string;
|
||||
environment?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
paginationOpts: { cursor: string | null; numItems: number };
|
||||
};
|
||||
|
||||
type ClawPackMigrationOperation = "artifact-backfill" | "failure-retry" | "search-index-backfill";
|
||||
type ClawPackMigrationStatus = "pending" | "running" | "completed" | "failed";
|
||||
|
||||
function parseClawPackMigrationOperation(raw: unknown): ClawPackMigrationOperation | null {
|
||||
if (raw === "artifact-backfill" || raw === "failure-retry" || raw === "search-index-backfill") {
|
||||
return raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseClawPackMigrationStatus(raw: unknown): ClawPackMigrationStatus | undefined {
|
||||
if (raw === "pending" || raw === "running" || raw === "completed" || raw === "failed") {
|
||||
return raw;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type SkillPackageDocLike = {
|
||||
_id: Id<"skills">;
|
||||
slug: string;
|
||||
@@ -157,6 +191,20 @@ type ReleaseLike = {
|
||||
compatibility?: Doc<"packageReleases">["compatibility"];
|
||||
capabilities?: Doc<"packageReleases">["capabilities"];
|
||||
verification?: Doc<"packageReleases">["verification"];
|
||||
clawpackStorageId?: Id<"_storage">;
|
||||
clawpackSha256?: string;
|
||||
clawpackSize?: number;
|
||||
clawpackSpecVersion?: number;
|
||||
clawpackFormat?: "zip";
|
||||
clawpackFileCount?: number;
|
||||
clawpackManifestSha256?: string;
|
||||
clawpackBuiltAt?: number;
|
||||
clawpackBuildVersion?: string;
|
||||
clawpackRevokedAt?: number;
|
||||
clawpackRevokedByUserId?: Id<"users">;
|
||||
clawpackRevocationReason?: string;
|
||||
hostTargetsSummary?: Doc<"packageReleases">["hostTargetsSummary"];
|
||||
environmentSummary?: Doc<"packageReleases">["environmentSummary"];
|
||||
sha256hash?: string;
|
||||
vtAnalysis?: Doc<"packageReleases">["vtAnalysis"];
|
||||
llmAnalysis?: Doc<"packageReleases">["llmAnalysis"];
|
||||
@@ -201,6 +249,89 @@ function getReleaseSecurityBlock(release: ReleaseLike) {
|
||||
return getPackageDownloadSecurityBlock(release);
|
||||
}
|
||||
|
||||
function toPublicClawPack(release: ReleaseLike | null | undefined) {
|
||||
if (
|
||||
!release?.clawpackStorageId ||
|
||||
!release.clawpackSha256 ||
|
||||
!release.clawpackSize ||
|
||||
release.clawpackRevokedAt
|
||||
) {
|
||||
return {
|
||||
available: false,
|
||||
specVersion: null,
|
||||
format: null,
|
||||
sha256: null,
|
||||
size: null,
|
||||
fileCount: null,
|
||||
manifestSha256: null,
|
||||
builtAt: null,
|
||||
buildVersion: null,
|
||||
hostTargets: release?.hostTargetsSummary ?? [],
|
||||
environment: release?.environmentSummary ?? null,
|
||||
runtimeBundles: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
specVersion: release.clawpackSpecVersion ?? 1,
|
||||
format: release.clawpackFormat ?? "zip",
|
||||
sha256: release.clawpackSha256,
|
||||
size: release.clawpackSize,
|
||||
fileCount: release.clawpackFileCount ?? null,
|
||||
manifestSha256: release.clawpackManifestSha256 ?? null,
|
||||
builtAt: release.clawpackBuiltAt ?? null,
|
||||
buildVersion: release.clawpackBuildVersion ?? null,
|
||||
hostTargets: release.hostTargetsSummary ?? [],
|
||||
environment: release.environmentSummary ?? null,
|
||||
runtimeBundles: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function readClawPackManifest(blob: Blob) {
|
||||
const entries = unzipSync(new Uint8Array(await blob.arrayBuffer()));
|
||||
const manifestBytes = entries["package/CLAWPACK.json"];
|
||||
if (!manifestBytes) throw new Error("Missing Claw Pack manifest");
|
||||
return JSON.parse(new TextDecoder().decode(manifestBytes)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireModeratorOrResponse(
|
||||
user: { role?: string | null | undefined },
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
if (user.role === "admin" || user.role === "moderator") return { ok: true as const };
|
||||
return { ok: false as const, response: text("Forbidden", 403, headers) };
|
||||
}
|
||||
|
||||
function sha256DigestHeader(hex: string) {
|
||||
const bytes = hex.match(/.{1,2}/g)?.map((part) => Number.parseInt(part, 16)) ?? [];
|
||||
return `sha-256=${btoa(String.fromCharCode(...bytes))}`;
|
||||
}
|
||||
|
||||
function normalizeClawPackSha256(raw: string | undefined) {
|
||||
const sha256 = raw?.trim().toLowerCase();
|
||||
return sha256 && /^[a-f0-9]{64}$/.test(sha256) ? sha256 : null;
|
||||
}
|
||||
|
||||
function clawPackArtifactHeaders(input: {
|
||||
packageName: string;
|
||||
version: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
specVersion?: number;
|
||||
immutable?: boolean;
|
||||
}) {
|
||||
return {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Length": String(input.size),
|
||||
"Content-Disposition": `attachment; filename="${input.packageName.replaceAll("/", "-")}-${input.version}.clawpack.zip"`,
|
||||
ETag: `"sha256:${input.sha256}"`,
|
||||
Digest: sha256DigestHeader(input.sha256),
|
||||
...(input.immutable ? { "Cache-Control": "public, max-age=31536000, immutable" } : {}),
|
||||
"X-ClawHub-ClawPack-Sha256": input.sha256,
|
||||
"X-ClawHub-ClawPack-Spec-Version": String(input.specVersion ?? 1),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolvePackageTags(
|
||||
ctx: ActionCtx,
|
||||
tags: Record<string, Id<"packageReleases">>,
|
||||
@@ -237,10 +368,82 @@ type CatalogListItem = {
|
||||
capabilityTags?: string[];
|
||||
executesCode?: boolean;
|
||||
verificationTier?: string | null;
|
||||
clawpackAvailable?: boolean;
|
||||
hostTargetKeys?: string[];
|
||||
environmentFlags?: string[];
|
||||
};
|
||||
|
||||
type CatalogSearchEntry = { score: number; package: CatalogListItem };
|
||||
|
||||
function toPublicCatalogItem(item: CatalogListItem & Record<string, unknown>): CatalogListItem {
|
||||
const { clawpackAvailable, clawpack, ...rest } = item;
|
||||
return {
|
||||
...rest,
|
||||
...(typeof clawpackAvailable === "boolean" ? { clawpackAvailable: clawpackAvailable } : {}),
|
||||
...(clawpack ? { clawpack: clawpack } : {}),
|
||||
} as CatalogListItem;
|
||||
}
|
||||
|
||||
function toPublicCatalogSearchEntry(entry: CatalogSearchEntry): CatalogSearchEntry {
|
||||
return {
|
||||
...entry,
|
||||
package: toPublicCatalogItem(entry.package as CatalogListItem & Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
|
||||
function toPublicClawPackMigrationStatus(result: Record<string, unknown>) {
|
||||
const { generatedClawPackSampleSize, generatedClawPackBytes, ...rest } = result;
|
||||
return {
|
||||
...rest,
|
||||
generatedClawPackSampleSize: generatedClawPackSampleSize,
|
||||
generatedClawPackBytes: generatedClawPackBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function toPublicClawPackReadinessLabel(value: unknown) {
|
||||
return value === "clawpack-missing" ? "clawpack-missing" : value;
|
||||
}
|
||||
|
||||
function toPublicClawPackReadinessResult(result: Record<string, unknown>) {
|
||||
const items = Array.isArray(result.items)
|
||||
? result.items.map((item) => {
|
||||
if (!item || typeof item !== "object") return item;
|
||||
const record = item as Record<string, unknown>;
|
||||
return {
|
||||
...record,
|
||||
readinessState: toPublicClawPackReadinessLabel(record.readinessState),
|
||||
blockers: Array.isArray(record.blockers)
|
||||
? record.blockers.map(toPublicClawPackReadinessLabel)
|
||||
: record.blockers,
|
||||
};
|
||||
})
|
||||
: result.items;
|
||||
return { ...result, items };
|
||||
}
|
||||
|
||||
type ClawPackArtifactLookup =
|
||||
| {
|
||||
status: "ok";
|
||||
artifact: {
|
||||
storageId: Id<"_storage">;
|
||||
sha256: string;
|
||||
size: number;
|
||||
format: string;
|
||||
};
|
||||
package: {
|
||||
_id: Id<"packages">;
|
||||
name: string;
|
||||
};
|
||||
release: {
|
||||
_id: Id<"packageReleases">;
|
||||
version: string;
|
||||
clawpackSpecVersion?: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
status: "revoked";
|
||||
};
|
||||
|
||||
type CatalogSourceCursorState = {
|
||||
cursor: string | null;
|
||||
offset: number;
|
||||
@@ -463,6 +666,8 @@ async function searchPackageCatalogByListing(
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
hostTarget?: string;
|
||||
environment?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
},
|
||||
): Promise<CatalogSearchEntry[]> {
|
||||
@@ -488,6 +693,8 @@ async function searchPackageCatalogByListing(
|
||||
highlightedOnly: args.highlightedOnly,
|
||||
executesCode: args.executesCode,
|
||||
capabilityTag: args.capabilityTag,
|
||||
hostTarget: args.hostTarget,
|
||||
environment: args.environment,
|
||||
viewerUserId: args.viewerUserId,
|
||||
paginationOpts: { cursor, numItems: HTTP_PACKAGE_SEARCH_PAGE_SIZE },
|
||||
});
|
||||
@@ -653,6 +860,8 @@ async function listPackages(
|
||||
const familyRaw = url.searchParams.get("family");
|
||||
const channelRaw = url.searchParams.get("channel")?.trim();
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const hostTarget = url.searchParams.get("hostTarget")?.trim() || undefined;
|
||||
const environment = url.searchParams.get("environment")?.trim() || undefined;
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const highlightedOnly =
|
||||
url.searchParams.get("featured") === "true" ||
|
||||
@@ -665,7 +874,10 @@ async function listPackages(
|
||||
(familyRaw === "skill" || familyRaw === "code-plugin" || familyRaw === "bundle-plugin"
|
||||
? familyRaw
|
||||
: undefined);
|
||||
const includeSkills = options?.includeSkills ?? effectiveFamily === undefined;
|
||||
const packageOnlyFilters = Boolean(hostTarget || environment);
|
||||
const includeSkills = packageOnlyFilters
|
||||
? false
|
||||
: (options?.includeSkills ?? effectiveFamily === undefined);
|
||||
const channel =
|
||||
channelRaw === "official" || channelRaw === "community" || channelRaw === "private"
|
||||
? channelRaw
|
||||
@@ -676,6 +888,9 @@ async function listPackages(
|
||||
executesCodeRaw === "true" ? true : executesCodeRaw === "false" ? false : undefined;
|
||||
|
||||
if (effectiveFamily === "skill") {
|
||||
if (packageOnlyFilters) {
|
||||
return json({ items: [], nextCursor: null }, 200, rate.headers);
|
||||
}
|
||||
const result = await runQueryRef<{
|
||||
page: CatalogListItem[];
|
||||
isDone: boolean;
|
||||
@@ -689,7 +904,12 @@ async function listPackages(
|
||||
paginationOpts: { cursor, numItems: limit },
|
||||
});
|
||||
return json(
|
||||
{ items: result.page, nextCursor: result.isDone ? null : result.continueCursor },
|
||||
{
|
||||
items: result.page.map((item) =>
|
||||
toPublicCatalogItem(item as CatalogListItem & Record<string, unknown>),
|
||||
),
|
||||
nextCursor: result.isDone ? null : result.continueCursor,
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
@@ -714,6 +934,8 @@ async function listPackages(
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
hostTarget,
|
||||
environment,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
paginationOpts: { cursor: pageCursor, numItems },
|
||||
});
|
||||
@@ -768,7 +990,9 @@ async function listPackages(
|
||||
nextState.skills.offset === 0;
|
||||
return json(
|
||||
{
|
||||
items,
|
||||
items: items.map((item) =>
|
||||
toPublicCatalogItem(item as CatalogListItem & Record<string, unknown>),
|
||||
),
|
||||
nextCursor: isDoneAll ? null : encodeUnifiedCatalogCursor(nextState),
|
||||
},
|
||||
200,
|
||||
@@ -798,6 +1022,8 @@ async function listPackages(
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
hostTarget,
|
||||
environment,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
paginationOpts: { cursor: pageCursor, numItems },
|
||||
});
|
||||
@@ -847,7 +1073,9 @@ async function listPackages(
|
||||
nextState.bundlePlugins.offset === 0;
|
||||
return json(
|
||||
{
|
||||
items,
|
||||
items: items.map((item) =>
|
||||
toPublicCatalogItem(item as CatalogListItem & Record<string, unknown>),
|
||||
),
|
||||
nextCursor: isDoneAll ? null : encodePluginCatalogCursor(nextState),
|
||||
},
|
||||
200,
|
||||
@@ -866,11 +1094,18 @@ async function listPackages(
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
hostTarget,
|
||||
environment,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
paginationOpts: { cursor, numItems: limit },
|
||||
} satisfies PackageListQueryArgs);
|
||||
return json(
|
||||
{ items: result.page, nextCursor: result.isDone ? null : result.continueCursor },
|
||||
{
|
||||
items: result.page.map((item) =>
|
||||
toPublicCatalogItem(item as CatalogListItem & Record<string, unknown>),
|
||||
),
|
||||
nextCursor: result.isDone ? null : result.continueCursor,
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
@@ -1051,6 +1286,209 @@ export async function mintPublishTokenV1Handler(ctx: ActionCtx, request: Request
|
||||
|
||||
export async function packagesPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/packages/");
|
||||
if (segments[0] === "clawpack" && segments[1] === "migration-runs" && segments.length === 2) {
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
if (!rate.ok) return rate.response;
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
const admin = requireAdminOrResponse(auth.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const operation = parseClawPackMigrationOperation(
|
||||
body && typeof body === "object" ? (body as { operation?: unknown }).operation : undefined,
|
||||
);
|
||||
if (!operation) return text("Invalid Claw Pack migration operation", 400, rate.headers);
|
||||
const rawLimit =
|
||||
body && typeof body === "object" && "limit" in body
|
||||
? Number((body as { limit?: unknown }).limit)
|
||||
: undefined;
|
||||
const cursor =
|
||||
body && typeof body === "object" && typeof (body as { cursor?: unknown }).cursor === "string"
|
||||
? (body as { cursor: string }).cursor
|
||||
: undefined;
|
||||
const result = await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.packages.startClawPackMigrationRunInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
operation,
|
||||
...(Number.isFinite(rawLimit) ? { limit: rawLimit } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
}
|
||||
|
||||
if (
|
||||
segments[0] === "clawpack" &&
|
||||
segments[1] === "migration-runs" &&
|
||||
segments[3] === "continue" &&
|
||||
segments.length === 4
|
||||
) {
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
if (!rate.ok) return rate.response;
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
const admin = requireAdminOrResponse(auth.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
try {
|
||||
const result = await runActionRef(
|
||||
ctx,
|
||||
internalRefs.packages.continueClawPackMigrationRunInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
runId: segments[2] as Id<"clawPackMigrationRuns">,
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return text(
|
||||
error instanceof Error ? error.message : "Claw Pack migration run failed",
|
||||
400,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (segments[0] === "clawpack" && segments[1] === "backfill" && segments.length === 2) {
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
if (!rate.ok) return rate.response;
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
const admin = requireAdminOrResponse(auth.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const rawLimit =
|
||||
body && typeof body === "object" && "limit" in body
|
||||
? Number((body as { limit?: unknown }).limit)
|
||||
: undefined;
|
||||
const limit = Number.isFinite(rawLimit) ? rawLimit : undefined;
|
||||
try {
|
||||
const result = await runActionRef(
|
||||
ctx,
|
||||
internalRefs.packages.backfillClawPackArtifactsInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
...(limit ? { limit } : {}),
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return text(
|
||||
error instanceof Error ? error.message : "Claw Pack backfill failed",
|
||||
400,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (segments[0] === "clawpack" && segments[1] === "index-backfill" && segments.length === 2) {
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
if (!rate.ok) return rate.response;
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
const admin = requireAdminOrResponse(auth.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const rawLimit =
|
||||
body && typeof body === "object" && "limit" in body
|
||||
? Number((body as { limit?: unknown }).limit)
|
||||
: undefined;
|
||||
const cursor =
|
||||
body && typeof body === "object" && typeof (body as { cursor?: unknown }).cursor === "string"
|
||||
? (body as { cursor: string }).cursor
|
||||
: undefined;
|
||||
const limit = Number.isFinite(rawLimit) ? rawLimit : undefined;
|
||||
try {
|
||||
const result = await runActionRef(
|
||||
ctx,
|
||||
internalRefs.packages.backfillClawPackSearchIndexInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
...(limit ? { limit } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return text(
|
||||
error instanceof Error ? error.message : "Claw Pack index backfill failed",
|
||||
400,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (segments[0] === "clawpack" && segments[1] === "retry-failures" && segments.length === 2) {
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
if (!rate.ok) return rate.response;
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
const admin = requireAdminOrResponse(auth.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const rawLimit =
|
||||
body && typeof body === "object" && "limit" in body
|
||||
? Number((body as { limit?: unknown }).limit)
|
||||
: undefined;
|
||||
const limit = Number.isFinite(rawLimit) ? rawLimit : undefined;
|
||||
try {
|
||||
const result = await runActionRef(
|
||||
ctx,
|
||||
internalRefs.packages.retryClawPackBackfillFailuresInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
...(limit ? { limit } : {}),
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return text(
|
||||
error instanceof Error ? error.message : "Claw Pack failure retry failed",
|
||||
400,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
segments[1] === "versions" &&
|
||||
segments[3] === "clawpack" &&
|
||||
segments[4] === "revoke" &&
|
||||
segments.length === 5
|
||||
) {
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
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 body = await request.json().catch(() => ({}));
|
||||
const reason =
|
||||
body && typeof body === "object" && typeof (body as { reason?: unknown }).reason === "string"
|
||||
? (body as { reason: string }).reason.trim()
|
||||
: undefined;
|
||||
try {
|
||||
const result = await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.packages.revokeClawPackArtifactForStaffInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
name: segments[0]!,
|
||||
version: segments[2]!,
|
||||
...(reason ? { reason } : {}),
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return text(
|
||||
error instanceof Error ? error.message : "Claw Pack revoke failed",
|
||||
400,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (segments[1] === "rescan" && segments.length === 2) {
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
if (!rate.ok) return rate.response;
|
||||
@@ -1300,11 +1738,16 @@ async function searchPackages(
|
||||
url.searchParams.get("highlightedOnly") === "1";
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const hostTarget = url.searchParams.get("hostTarget")?.trim() || undefined;
|
||||
const environment = url.searchParams.get("environment")?.trim() || undefined;
|
||||
const family =
|
||||
familyRaw === "skill" || familyRaw === "code-plugin" || familyRaw === "bundle-plugin"
|
||||
? familyRaw
|
||||
: undefined;
|
||||
const includeSkills = options?.includeSkills ?? family === undefined;
|
||||
const packageOnlyFilters = Boolean(hostTarget || environment);
|
||||
const includeSkills = packageOnlyFilters
|
||||
? false
|
||||
: (options?.includeSkills ?? family === undefined);
|
||||
const channel =
|
||||
channelRaw === "official" || channelRaw === "community" || channelRaw === "private"
|
||||
? channelRaw
|
||||
@@ -1316,6 +1759,9 @@ async function searchPackages(
|
||||
|
||||
let results: CatalogSearchEntry[];
|
||||
if (family === "skill") {
|
||||
if (packageOnlyFilters) {
|
||||
return json({ results: [] }, 200, rate.headers);
|
||||
}
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(
|
||||
ctx,
|
||||
apiRefs.skills.searchPackageCatalogPublic,
|
||||
@@ -1342,6 +1788,8 @@ async function searchPackages(
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
hostTarget,
|
||||
environment,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
}),
|
||||
),
|
||||
@@ -1367,6 +1815,8 @@ async function searchPackages(
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
hostTarget,
|
||||
environment,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
});
|
||||
}
|
||||
@@ -1380,6 +1830,8 @@ async function searchPackages(
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
hostTarget,
|
||||
environment,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
}),
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
@@ -1403,7 +1855,7 @@ async function searchPackages(
|
||||
.sort(compareCatalogSearchEntries)
|
||||
.slice(0, limit);
|
||||
}
|
||||
return json({ results }, 200, rate.headers);
|
||||
return json({ results: results.map(toPublicCatalogSearchEntry) }, 200, rate.headers);
|
||||
}
|
||||
|
||||
export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
@@ -1412,6 +1864,96 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
|
||||
return await searchPackages(ctx, request, { includeSkills: true });
|
||||
}
|
||||
if (segments[0] === "clawpack" && segments[1] === "migration-status" && segments.length === 2) {
|
||||
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 admin = requireAdminOrResponse(auth.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
const limit = toOptionalNumber(new URL(request.url).searchParams.get("limit")) ?? undefined;
|
||||
const result = (await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.packages.getClawPackMigrationStatusInternal,
|
||||
{ limit },
|
||||
)) as Record<string, unknown>;
|
||||
return json(toPublicClawPackMigrationStatus(result), 200, rate.headers);
|
||||
}
|
||||
if (
|
||||
segments[0] === "clawpack" &&
|
||||
segments[1] === "migration-runs" &&
|
||||
segments[2] === "dry-run" &&
|
||||
segments.length === 3
|
||||
) {
|
||||
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 admin = requireAdminOrResponse(auth.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
const search = new URL(request.url).searchParams;
|
||||
const operation = parseClawPackMigrationOperation(search.get("operation"));
|
||||
if (!operation) return text("Invalid Claw Pack migration operation", 400, rate.headers);
|
||||
const result = await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.packages.dryRunClawPackMigrationRunForStaffInternal,
|
||||
{
|
||||
operation,
|
||||
limit: toOptionalNumber(search.get("limit")) ?? undefined,
|
||||
cursor: search.get("cursor") || undefined,
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
}
|
||||
if (segments[0] === "clawpack" && segments[1] === "migration-runs" && segments.length === 2) {
|
||||
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 admin = requireAdminOrResponse(auth.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
const search = new URL(request.url).searchParams;
|
||||
const result = await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.packages.listClawPackMigrationRunsForStaffInternal,
|
||||
{
|
||||
status: parseClawPackMigrationStatus(search.get("status")),
|
||||
limit: toOptionalNumber(search.get("limit")) ?? undefined,
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
}
|
||||
if (segments[0] === "clawpack" && segments[1] === "migration-runs" && segments.length === 3) {
|
||||
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 admin = requireAdminOrResponse(auth.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
const result = await runQueryRef(ctx, internalRefs.packages.getClawPackMigrationRunInternal, {
|
||||
runId: segments[2] as Id<"clawPackMigrationRuns">,
|
||||
});
|
||||
if (!result) return text("Claw Pack migration run not found", 404, rate.headers);
|
||||
return json(result, 200, rate.headers);
|
||||
}
|
||||
if (
|
||||
segments[0] === "clawpack" &&
|
||||
segments[1] === "migration-readiness" &&
|
||||
segments.length === 2
|
||||
) {
|
||||
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 admin = requireAdminOrResponse(auth.user, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
const result = (await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.packages.listOfficialMigrationReadinessForStaffInternal,
|
||||
{},
|
||||
)) as Record<string, unknown>;
|
||||
return json(toPublicClawPackReadinessResult(result), 200, rate.headers);
|
||||
}
|
||||
|
||||
const rateKind = segments[1] === "download" ? "download" : "read";
|
||||
const rate = await applyRateLimit(ctx, request, rateKind);
|
||||
@@ -1451,6 +1993,7 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
package: {
|
||||
...publicPackage!,
|
||||
tags: await resolvePackageTags(ctx, publicPackage!.tags),
|
||||
clawpack: toPublicClawPack(packageDetail?.latestRelease),
|
||||
},
|
||||
owner: packageOwner
|
||||
? {
|
||||
@@ -1533,6 +2076,92 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
);
|
||||
}
|
||||
|
||||
const artifactRoute = segments[3];
|
||||
if (
|
||||
segments[1] === "versions" &&
|
||||
segments[2] &&
|
||||
artifactRoute === "clawpack" &&
|
||||
(segments.length === 4 || (segments[4] === "manifest" && segments.length === 5))
|
||||
) {
|
||||
if (!publicPackage) return text("Claw Pack not available", 404, rate.headers);
|
||||
const result = (await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.packages.getVersionByNameForViewerInternal,
|
||||
{
|
||||
name: packageName,
|
||||
version: segments[2],
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
},
|
||||
)) as { package: PublicPackageDocLike; version: ReleaseLike } | null;
|
||||
if (!result) return text("Version not found", 404, rate.headers);
|
||||
|
||||
const clawpack = toPublicClawPack(result.version);
|
||||
if (result.version.clawpackRevokedAt) return text("Claw Pack revoked", 410, rate.headers);
|
||||
if (!clawpack.available) return text("Claw Pack not available", 404, rate.headers);
|
||||
|
||||
const securityBlock = getReleaseSecurityBlock(result.version);
|
||||
if (securityBlock) return text(securityBlock.message, securityBlock.status, rate.headers);
|
||||
|
||||
if (segments[4] === "manifest") {
|
||||
if (!result.version.clawpackStorageId) {
|
||||
return text("Claw Pack not available", 404, rate.headers);
|
||||
}
|
||||
const blob = await ctx.storage.get(result.version.clawpackStorageId);
|
||||
if (!blob) return text("Missing stored Claw Pack artifact", 500, rate.headers);
|
||||
try {
|
||||
const manifest = await readClawPackManifest(blob);
|
||||
return json(
|
||||
{
|
||||
package: {
|
||||
name: result.package.name,
|
||||
displayName: result.package.displayName,
|
||||
family: result.package.family,
|
||||
},
|
||||
version: result.version.version,
|
||||
clawpack,
|
||||
manifest,
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
} catch (error) {
|
||||
return text(
|
||||
error instanceof Error ? error.message : "Invalid Claw Pack manifest",
|
||||
500,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return json(
|
||||
{
|
||||
package: {
|
||||
name: result.package.name,
|
||||
displayName: result.package.displayName,
|
||||
family: result.package.family,
|
||||
},
|
||||
version: {
|
||||
version: result.version.version,
|
||||
createdAt: result.version.createdAt,
|
||||
distTags: result.version.distTags ?? [],
|
||||
verification: result.version.verification ?? null,
|
||||
sha256hash: result.version.sha256hash ?? null,
|
||||
vtAnalysis: result.version.vtAnalysis ?? null,
|
||||
llmAnalysis: result.version.llmAnalysis ?? null,
|
||||
staticScan: result.version.staticScan ?? null,
|
||||
},
|
||||
clawpack,
|
||||
links: {
|
||||
download: `${ApiRoutes.packages}/${encodeURIComponent(result.package.name)}/download?version=${encodeURIComponent(result.version.version)}`,
|
||||
immutable: clawpack.sha256 ? `/api/v1/clawpacks/${clawpack.sha256}` : null,
|
||||
manifest: `${ApiRoutes.packages}/${encodeURIComponent(result.package.name)}/versions/${encodeURIComponent(result.version.version)}/clawpack/manifest`,
|
||||
},
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
|
||||
if (segments[1] === "versions" && segments[2]) {
|
||||
if (skillDetail?.skill) {
|
||||
const version = (await runQueryRef(
|
||||
@@ -1607,6 +2236,7 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
vtAnalysis: result.version.vtAnalysis ?? null,
|
||||
llmAnalysis: result.version.llmAnalysis ?? null,
|
||||
staticScan: result.version.staticScan ?? null,
|
||||
clawpack: toPublicClawPack(result.version),
|
||||
},
|
||||
},
|
||||
200,
|
||||
@@ -1680,6 +2310,32 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
if (!release) return text("Version not found", 404, rate.headers);
|
||||
const securityBlock = getReleaseSecurityBlock(release);
|
||||
if (securityBlock) return text(securityBlock.message, securityBlock.status, rate.headers);
|
||||
if (release.clawpackRevokedAt) return text("Claw Pack revoked", 410, rate.headers);
|
||||
if (release.clawpackStorageId && release.clawpackSha256 && release.clawpackSize) {
|
||||
const blob = await ctx.storage.get(release.clawpackStorageId);
|
||||
if (!blob) return text("Missing stored Claw Pack artifact", 500, rate.headers);
|
||||
try {
|
||||
await runMutationRef(ctx, internalRefs.packages.recordPackageDownloadInternal, {
|
||||
packageId: publicPackage!._id,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort metric path; never fail package downloads.
|
||||
}
|
||||
return new Response(blob, {
|
||||
status: 200,
|
||||
headers: mergeHeaders(
|
||||
rate.headers,
|
||||
clawPackArtifactHeaders({
|
||||
packageName: publicPackage!.name,
|
||||
version: release.version,
|
||||
sha256: release.clawpackSha256,
|
||||
size: release.clawpackSize,
|
||||
specVersion: release.clawpackSpecVersion,
|
||||
}),
|
||||
corsHeaders(),
|
||||
),
|
||||
});
|
||||
}
|
||||
const entries: Array<{ path: string; bytes: Uint8Array }> = [];
|
||||
for (const file of release.files) {
|
||||
const blob = await ctx.storage.get(file.storageId);
|
||||
@@ -1713,6 +2369,53 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
export async function clawpacksGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const prefix = "/api/v1/clawpacks/";
|
||||
const segments = getPathSegments(request, prefix);
|
||||
if (segments.length !== 1) return text("Not found", 404);
|
||||
const sha256 = normalizeClawPackSha256(segments[0]);
|
||||
if (!sha256) return text("Invalid Claw Pack digest", 400);
|
||||
const rate = await applyRateLimit(ctx, request, "download");
|
||||
if (!rate.ok) return rate.response;
|
||||
const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
|
||||
const lookup = await runQueryRef<ClawPackArtifactLookup | null>(
|
||||
ctx,
|
||||
internalRefs.packages.getClawPackArtifactByShaForViewerInternal,
|
||||
{
|
||||
sha256,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
},
|
||||
);
|
||||
if (!lookup) return text("Claw Pack not found", 404, rate.headers);
|
||||
if (lookup.status === "revoked") return text("Claw Pack revoked", 410, rate.headers);
|
||||
const blob = await ctx.storage.get(lookup.artifact.storageId);
|
||||
if (!blob) return text("Missing stored Claw Pack artifact", 500, rate.headers);
|
||||
if (request.method !== "HEAD") {
|
||||
try {
|
||||
await runMutationRef(ctx, internalRefs.packages.recordPackageDownloadInternal, {
|
||||
packageId: lookup.package._id,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort metric path; never fail Claw Pack downloads.
|
||||
}
|
||||
}
|
||||
return new Response(request.method === "HEAD" ? null : blob, {
|
||||
status: 200,
|
||||
headers: mergeHeaders(
|
||||
rate.headers,
|
||||
clawPackArtifactHeaders({
|
||||
packageName: lookup.package.name,
|
||||
version: lookup.release.version,
|
||||
sha256: lookup.artifact.sha256,
|
||||
size: lookup.artifact.size,
|
||||
specVersion: lookup.release.clawpackSpecVersion,
|
||||
immutable: true,
|
||||
}),
|
||||
corsHeaders(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function pluginsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/plugins/");
|
||||
if (segments.length === 0) return text("Not found", 404);
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { unzipSync } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildClawPack,
|
||||
CLAWPACK_MANIFEST_PATH,
|
||||
deriveClawPackEnvironment,
|
||||
deriveClawPackHostTargets,
|
||||
type ClawPackFile,
|
||||
type ClawPackInput,
|
||||
sha256Hex,
|
||||
} from "./clawpack";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
async function makeClawPack(overrides: Partial<Parameters<typeof buildClawPack>[0]> = {}) {
|
||||
return await buildClawPack({
|
||||
packageId: "pkg_123",
|
||||
releaseId: "rel_123",
|
||||
name: "@openclaw/kitchen-sink",
|
||||
owner: "openclaw",
|
||||
slug: "openclaw-kitchen-sink",
|
||||
version: "1.0.0",
|
||||
family: "code-plugin",
|
||||
channel: "official",
|
||||
publishedAt: 1_763_000_000_000,
|
||||
compatibility: {
|
||||
minGatewayVersion: ">=2026.5.0",
|
||||
pluginApiRange: "^1.0.0",
|
||||
},
|
||||
capabilities: {
|
||||
executesCode: true,
|
||||
hostTargets: ["darwin-arm64", "linux-x64-glibc", "win32-x64"],
|
||||
capabilityTags: ["browser", "desktop", "service:github"],
|
||||
},
|
||||
verification: {
|
||||
tier: "source-linked",
|
||||
scope: "artifact-only",
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: "package.json",
|
||||
size: 2,
|
||||
sha256: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||
bytes: encoder.encode("{}"),
|
||||
contentType: "application/json",
|
||||
},
|
||||
{
|
||||
path: "dist/index.js",
|
||||
size: 17,
|
||||
sha256: "index-sha",
|
||||
bytes: encoder.encode("export default {};"),
|
||||
contentType: "text/javascript",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
async function fixtureFile(
|
||||
path: string,
|
||||
source: string,
|
||||
contentType?: string,
|
||||
): Promise<ClawPackFile> {
|
||||
const bytes = encoder.encode(source);
|
||||
return {
|
||||
path,
|
||||
size: bytes.byteLength,
|
||||
sha256: await sha256Hex(bytes),
|
||||
bytes,
|
||||
...(contentType ? { contentType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function makeKitchenSinkClawPackInput(): Promise<ClawPackInput> {
|
||||
return {
|
||||
packageId: "pkg_kitchen_sink",
|
||||
releaseId: "rel_kitchen_sink",
|
||||
name: "@openclaw/kitchen-sink-plugin",
|
||||
owner: "openclaw",
|
||||
slug: "openclaw-kitchen-sink-plugin",
|
||||
version: "9.9.9",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
publishedAt: 1_767_225_600_000,
|
||||
source: {
|
||||
kind: "github",
|
||||
repository: "openclaw/kitchen-sink-plugin",
|
||||
commit: "abc123fixture",
|
||||
},
|
||||
compatibility: {
|
||||
builtWithOpenClawVersion: "2026.5.0",
|
||||
pluginApiRange: "^1.0.0",
|
||||
minGatewayVersion: ">=2026.5.0",
|
||||
},
|
||||
capabilities: {
|
||||
executesCode: true,
|
||||
runtimeId: "openclaw.kitchen-sink",
|
||||
pluginKind: "runtime",
|
||||
hooks: ["chat:before", "chat:after", "app:startup"],
|
||||
providers: ["openai", "openrouter"],
|
||||
toolNames: ["browser.open", "desktop.capture", "github.search"],
|
||||
serviceNames: ["playwright", "github"],
|
||||
bundledSkills: ["prompt-reviewer", "workflow-runner"],
|
||||
setupEntry: true,
|
||||
configSchema: true,
|
||||
configUiHints: true,
|
||||
materializesDependencies: true,
|
||||
hostTargets: ["darwin-arm64", "darwin-x64", "linux-x64-glibc", "win32-x64"],
|
||||
capabilityTags: [
|
||||
"browser",
|
||||
"desktop",
|
||||
"audio",
|
||||
"service:github",
|
||||
"service:openai",
|
||||
"permission:screen-recording",
|
||||
],
|
||||
},
|
||||
verification: {
|
||||
tier: "source-linked",
|
||||
scope: "dependency-graph-aware",
|
||||
sourceRepo: "openclaw/kitchen-sink-plugin",
|
||||
sourceCommit: "abc123fixture",
|
||||
scanStatus: "clean",
|
||||
},
|
||||
files: [
|
||||
await fixtureFile(
|
||||
"package.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "@openclaw/kitchen-sink-plugin",
|
||||
version: "9.9.9",
|
||||
type: "module",
|
||||
openclaw: {
|
||||
plugin: "./openclaw.plugin.json",
|
||||
extensions: ["./dist/index.js"],
|
||||
},
|
||||
dependencies: {
|
||||
"@playwright/test": "^1.52.0",
|
||||
ws: "^8.18.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"application/json",
|
||||
),
|
||||
await fixtureFile(
|
||||
"openclaw.plugin.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
id: "openclaw.kitchen-sink",
|
||||
entry: "./dist/index.js",
|
||||
setup: "./dist/setup.js",
|
||||
hostTargets: ["darwin-arm64", "darwin-x64", "linux-x64-glibc", "win32-x64"],
|
||||
permissions: ["network", "screen-recording", "audio-input"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"application/json",
|
||||
),
|
||||
await fixtureFile(
|
||||
"dist/index.js",
|
||||
"export const plugin = { activate() { return 'kitchen-sink'; } };\n",
|
||||
"text/javascript",
|
||||
),
|
||||
await fixtureFile(
|
||||
"dist/setup.js",
|
||||
"export function setup() { return { schema: true, uiHints: true }; }\n",
|
||||
"text/javascript",
|
||||
),
|
||||
await fixtureFile(
|
||||
"browser/playwright-smoke.ts",
|
||||
"export async function smoke(page) { await page.goto('https://example.com'); }\n",
|
||||
"text/typescript",
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("clawpack", () => {
|
||||
it("builds a deterministic archive with a generated CLAWPACK manifest", async () => {
|
||||
const first = await makeClawPack();
|
||||
const second = await makeClawPack();
|
||||
const unzipped = unzipSync(first.bytes);
|
||||
const manifest = JSON.parse(decoder.decode(unzipped[`package/${CLAWPACK_MANIFEST_PATH}`]));
|
||||
|
||||
expect(Array.from(first.bytes)).toEqual(Array.from(second.bytes));
|
||||
expect(first.sha256).toBe(second.sha256);
|
||||
expect(Object.keys(unzipped).sort()).toEqual([
|
||||
"package/CLAWPACK.json",
|
||||
"package/dist/index.js",
|
||||
"package/package.json",
|
||||
]);
|
||||
expect(manifest).toMatchObject({
|
||||
specVersion: 1,
|
||||
kind: "openclaw.clawpack",
|
||||
package: {
|
||||
name: "@openclaw/kitchen-sink",
|
||||
owner: "openclaw",
|
||||
slug: "openclaw-kitchen-sink",
|
||||
version: "1.0.0",
|
||||
family: "code-plugin",
|
||||
channel: "official",
|
||||
},
|
||||
artifact: {
|
||||
format: "zip",
|
||||
root: "package/",
|
||||
fileCount: 2,
|
||||
},
|
||||
});
|
||||
expect(manifest.files.map((file: { path: string }) => file.path)).toEqual([
|
||||
"dist/index.js",
|
||||
"package.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores publisher supplied CLAWPACK.json files", async () => {
|
||||
const built = await makeClawPack({
|
||||
files: [
|
||||
{
|
||||
path: "CLAWPACK.json",
|
||||
size: 22,
|
||||
sha256: "attacker-sha",
|
||||
bytes: encoder.encode('{"forged": true}\n'),
|
||||
},
|
||||
{
|
||||
path: "package.json",
|
||||
size: 2,
|
||||
sha256: "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||
bytes: encoder.encode("{}"),
|
||||
},
|
||||
],
|
||||
});
|
||||
const unzipped = unzipSync(built.bytes);
|
||||
const manifest = JSON.parse(decoder.decode(unzipped["package/CLAWPACK.json"]));
|
||||
|
||||
expect(Object.keys(unzipped).sort()).toEqual(["package/CLAWPACK.json", "package/package.json"]);
|
||||
expect(manifest.forged).toBeUndefined();
|
||||
expect(manifest.files).toHaveLength(1);
|
||||
expect(built.fileCount).toBe(2);
|
||||
});
|
||||
|
||||
it("normalizes archive separators before packing files", async () => {
|
||||
const built = await makeClawPack({
|
||||
files: [
|
||||
{
|
||||
path: "dist\\index.js",
|
||||
size: 17,
|
||||
sha256: "index-sha",
|
||||
bytes: encoder.encode("export default {};"),
|
||||
},
|
||||
],
|
||||
});
|
||||
const unzipped = unzipSync(built.bytes);
|
||||
const manifest = JSON.parse(decoder.decode(unzipped[`package/${CLAWPACK_MANIFEST_PATH}`]));
|
||||
|
||||
expect(Object.keys(unzipped).sort()).toEqual([
|
||||
"package/CLAWPACK.json",
|
||||
"package/dist/index.js",
|
||||
]);
|
||||
expect(manifest.files.map((file: { path: string }) => file.path)).toEqual(["dist/index.js"]);
|
||||
});
|
||||
|
||||
it("rejects archive paths that can escape the package root", async () => {
|
||||
for (const path of ["../evil.js", "dist/../../evil.js", "/tmp/evil.js", "C:\\tmp\\evil.js"]) {
|
||||
await expect(
|
||||
makeClawPack({
|
||||
files: [
|
||||
{
|
||||
path,
|
||||
size: 4,
|
||||
sha256: "evil-sha",
|
||||
bytes: encoder.encode("evil"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow("Invalid Claw Pack file path");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects case-insensitive duplicate archive paths", async () => {
|
||||
await expect(
|
||||
makeClawPack({
|
||||
files: [
|
||||
{
|
||||
path: "dist/index.js",
|
||||
size: 17,
|
||||
sha256: "index-sha",
|
||||
bytes: encoder.encode("export default {};"),
|
||||
},
|
||||
{
|
||||
path: "dist/INDEX.js",
|
||||
size: 17,
|
||||
sha256: "index-upper-sha",
|
||||
bytes: encoder.encode("export default {};"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow("Duplicate Claw Pack file path");
|
||||
});
|
||||
|
||||
it("packs a kitchen-sink OpenClaw plugin with cross-platform signals", async () => {
|
||||
const input = await makeKitchenSinkClawPackInput();
|
||||
const built = await buildClawPack(input);
|
||||
const unzipped = unzipSync(built.bytes);
|
||||
const manifest = JSON.parse(decoder.decode(unzipped[`package/${CLAWPACK_MANIFEST_PATH}`]));
|
||||
const packageJson = JSON.parse(decoder.decode(unzipped["package/package.json"]));
|
||||
|
||||
expect(Object.keys(unzipped).sort()).toEqual([
|
||||
"package/CLAWPACK.json",
|
||||
"package/browser/playwright-smoke.ts",
|
||||
"package/dist/index.js",
|
||||
"package/dist/setup.js",
|
||||
"package/openclaw.plugin.json",
|
||||
"package/package.json",
|
||||
]);
|
||||
expect(packageJson.openclaw.extensions).toEqual(["./dist/index.js"]);
|
||||
expect(manifest.hostTargets).toEqual([
|
||||
{
|
||||
os: "darwin",
|
||||
arch: "arm64",
|
||||
supportState: "supported",
|
||||
openclawRange: ">=2026.5.0",
|
||||
pluginApiRange: "^1.0.0",
|
||||
},
|
||||
{
|
||||
os: "darwin",
|
||||
arch: "x64",
|
||||
supportState: "supported",
|
||||
openclawRange: ">=2026.5.0",
|
||||
pluginApiRange: "^1.0.0",
|
||||
},
|
||||
{
|
||||
os: "linux",
|
||||
arch: "x64",
|
||||
libc: "glibc",
|
||||
supportState: "supported",
|
||||
openclawRange: ">=2026.5.0",
|
||||
pluginApiRange: "^1.0.0",
|
||||
},
|
||||
{
|
||||
os: "win32",
|
||||
arch: "x64",
|
||||
supportState: "supported",
|
||||
openclawRange: ">=2026.5.0",
|
||||
pluginApiRange: "^1.0.0",
|
||||
},
|
||||
]);
|
||||
expect(manifest.environment).toEqual({
|
||||
requiresNetwork: true,
|
||||
requiresBrowser: true,
|
||||
requiresLocalDesktop: true,
|
||||
requiresAudioDevice: true,
|
||||
requiresExternalServices: ["github", "openai"],
|
||||
});
|
||||
expect(built.hostTargets.map((target) => [target.os, target.arch, target.libc])).toEqual([
|
||||
["darwin", "arm64", undefined],
|
||||
["darwin", "x64", undefined],
|
||||
["linux", "x64", "glibc"],
|
||||
["win32", "x64", undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives host targets and environment cues from package capabilities", () => {
|
||||
expect(
|
||||
deriveClawPackHostTargets({
|
||||
capabilities: {
|
||||
hostTargets: ["Darwin/ARM64", "linux-x64-musl", "bad-target", "linux-x64-musl"],
|
||||
},
|
||||
compatibility: {
|
||||
minGatewayVersion: ">=2026.5.0",
|
||||
pluginApiRange: "^1.0.0",
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
os: "darwin",
|
||||
arch: "arm64",
|
||||
supportState: "supported",
|
||||
openclawRange: ">=2026.5.0",
|
||||
pluginApiRange: "^1.0.0",
|
||||
},
|
||||
{
|
||||
os: "linux",
|
||||
arch: "x64",
|
||||
libc: "musl",
|
||||
supportState: "supported",
|
||||
openclawRange: ">=2026.5.0",
|
||||
pluginApiRange: "^1.0.0",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
deriveClawPackEnvironment({
|
||||
capabilities: {
|
||||
capabilityTags: ["browser", "desktop", "audio", "service:slack"],
|
||||
},
|
||||
files: [{ path: "dist/index.js" }],
|
||||
}),
|
||||
).toEqual({
|
||||
requiresNetwork: true,
|
||||
requiresBrowser: true,
|
||||
requiresLocalDesktop: true,
|
||||
requiresAudioDevice: true,
|
||||
requiresExternalServices: ["slack"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { buildDeterministicPackageZip } from "./skillZip";
|
||||
|
||||
const CLAWPACK_SPEC_VERSION = 1;
|
||||
export const CLAWPACK_MANIFEST_PATH = "CLAWPACK.json";
|
||||
|
||||
type ClawPackHostTarget = {
|
||||
os: "darwin" | "linux" | "win32";
|
||||
arch: "arm64" | "x64";
|
||||
libc?: "glibc" | "musl";
|
||||
nodeRange?: string;
|
||||
openclawRange?: string;
|
||||
pluginApiRange?: string;
|
||||
supportState?: "supported" | "setup-required" | "unsupported";
|
||||
unsupportedReason?: string;
|
||||
};
|
||||
|
||||
type ClawPackEnvironmentSummary = {
|
||||
requiresLocalDesktop?: boolean;
|
||||
requiresBrowser?: boolean;
|
||||
requiresAudioDevice?: boolean;
|
||||
requiresNetwork?: boolean;
|
||||
requiresExternalServices?: string[];
|
||||
requiresOsPermissions?: string[];
|
||||
supportsRemoteHost?: boolean;
|
||||
knownUnsupported?: string[];
|
||||
};
|
||||
|
||||
export type ClawPackFile = {
|
||||
path: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
bytes: Uint8Array;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export type ClawPackInput = {
|
||||
packageId: string;
|
||||
releaseId: string;
|
||||
name: string;
|
||||
owner?: string | null;
|
||||
slug: string;
|
||||
version: string;
|
||||
family: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel: "official" | "community" | "private";
|
||||
publishedAt: number;
|
||||
source?: unknown;
|
||||
compatibility?: unknown;
|
||||
capabilities?: unknown;
|
||||
verification?: unknown;
|
||||
files: ClawPackFile[];
|
||||
};
|
||||
|
||||
type BuiltClawPack = {
|
||||
bytes: Uint8Array;
|
||||
sha256: string;
|
||||
size: number;
|
||||
fileCount: number;
|
||||
manifestSha256: string;
|
||||
manifest: Record<string, unknown>;
|
||||
hostTargets: ClawPackHostTarget[];
|
||||
environment: ClawPackEnvironmentSummary;
|
||||
};
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export async function sha256Hex(bytes: Uint8Array) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes));
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function stableJson(value: unknown) {
|
||||
return `${JSON.stringify(sortJson(value), null, 2)}\n`;
|
||||
}
|
||||
|
||||
function sortJson(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sortJson);
|
||||
if (!value || typeof value !== "object") return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, entry]) => [key, sortJson(entry)]),
|
||||
);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string" && Boolean(entry.trim()))
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeHostTarget(raw: string): ClawPackHostTarget | null {
|
||||
const parts = raw.trim().toLowerCase().split(/[-_/]/).filter(Boolean);
|
||||
const os = parts.find((part) => part === "darwin" || part === "linux" || part === "win32");
|
||||
const arch = parts.find((part) => part === "arm64" || part === "x64");
|
||||
const libc = parts.find((part) => part === "glibc" || part === "musl");
|
||||
if (!os || !arch) return null;
|
||||
return {
|
||||
os,
|
||||
arch,
|
||||
...(libc ? { libc } : {}),
|
||||
supportState: "supported",
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueTargets(targets: ClawPackHostTarget[]) {
|
||||
const seen = new Set<string>();
|
||||
const result: ClawPackHostTarget[] = [];
|
||||
for (const target of targets) {
|
||||
const key = [target.os, target.arch, target.libc ?? ""].join("-");
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(target);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeClawPackFilePath(path: string) {
|
||||
const normalizedSeparators = path.trim().replaceAll("\\", "/");
|
||||
if (!normalizedSeparators) return null;
|
||||
if (
|
||||
Array.from(normalizedSeparators).some((character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
return codePoint <= 31 || codePoint === 127;
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (normalizedSeparators.startsWith("/") || normalizedSeparators.startsWith("//")) return null;
|
||||
if (/^[a-zA-Z]:($|\/)/.test(normalizedSeparators)) return null;
|
||||
if (normalizedSeparators.endsWith("/")) return null;
|
||||
|
||||
const segments = normalizedSeparators.split("/").filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
if (segments.some((segment) => segment === "." || segment === "..")) return null;
|
||||
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function normalizeClawPackFiles(files: ClawPackFile[]) {
|
||||
const seen = new Map<string, string>();
|
||||
const publishFiles: ClawPackFile[] = [];
|
||||
for (const file of files) {
|
||||
const path = normalizeClawPackFilePath(file.path);
|
||||
if (!path) {
|
||||
throw new Error(`Invalid Claw Pack file path: ${file.path}`);
|
||||
}
|
||||
const lowerPath = path.toLowerCase();
|
||||
if (lowerPath === CLAWPACK_MANIFEST_PATH.toLowerCase()) {
|
||||
continue;
|
||||
}
|
||||
const collisionKey = path.toLowerCase();
|
||||
const existingPath = seen.get(collisionKey);
|
||||
if (existingPath) {
|
||||
throw new Error(`Duplicate Claw Pack file path: ${existingPath} and ${path}`);
|
||||
}
|
||||
seen.set(collisionKey, path);
|
||||
publishFiles.push({ ...file, path });
|
||||
}
|
||||
return publishFiles;
|
||||
}
|
||||
|
||||
export function deriveClawPackHostTargets(input: {
|
||||
capabilities?: unknown;
|
||||
compatibility?: unknown;
|
||||
}): ClawPackHostTarget[] {
|
||||
const capabilities = asRecord(input.capabilities);
|
||||
const compatibility = asRecord(input.compatibility);
|
||||
const targetStrings = stringArray(capabilities.hostTargets);
|
||||
const fromCapabilities = targetStrings
|
||||
.map(normalizeHostTarget)
|
||||
.filter((target): target is ClawPackHostTarget => Boolean(target));
|
||||
if (fromCapabilities.length > 0) {
|
||||
return uniqueTargets(
|
||||
fromCapabilities.map((target) => ({
|
||||
...target,
|
||||
openclawRange: stringValue(compatibility.minGatewayVersion),
|
||||
pluginApiRange: stringValue(compatibility.pluginApiRange),
|
||||
})),
|
||||
);
|
||||
}
|
||||
return [
|
||||
{
|
||||
os: "darwin",
|
||||
arch: "arm64",
|
||||
supportState: "supported",
|
||||
openclawRange: stringValue(compatibility.minGatewayVersion),
|
||||
pluginApiRange: stringValue(compatibility.pluginApiRange),
|
||||
},
|
||||
{
|
||||
os: "linux",
|
||||
arch: "x64",
|
||||
libc: "glibc",
|
||||
supportState: "supported",
|
||||
openclawRange: stringValue(compatibility.minGatewayVersion),
|
||||
pluginApiRange: stringValue(compatibility.pluginApiRange),
|
||||
},
|
||||
{
|
||||
os: "win32",
|
||||
arch: "x64",
|
||||
supportState: "supported",
|
||||
openclawRange: stringValue(compatibility.minGatewayVersion),
|
||||
pluginApiRange: stringValue(compatibility.pluginApiRange),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function deriveClawPackEnvironment(input: {
|
||||
capabilities?: unknown;
|
||||
files: Array<{ path: string }>;
|
||||
}): ClawPackEnvironmentSummary {
|
||||
const capabilities = asRecord(input.capabilities);
|
||||
const capabilityTags = stringArray(capabilities.capabilityTags).map((tag) => tag.toLowerCase());
|
||||
const fileNames = input.files.map((file) => file.path.toLowerCase());
|
||||
const requiresBrowser =
|
||||
capabilityTags.some((tag) => tag.includes("browser") || tag.includes("playwright")) ||
|
||||
fileNames.some((path) => path.includes("playwright") || path.includes("browser"));
|
||||
const requiresLocalDesktop = capabilityTags.some(
|
||||
(tag) => tag.includes("desktop") || tag.includes("imessage") || tag.includes("bluebubbles"),
|
||||
);
|
||||
const requiresAudioDevice = capabilityTags.some(
|
||||
(tag) => tag.includes("audio") || tag.includes("meet"),
|
||||
);
|
||||
const externalServices = capabilityTags
|
||||
.filter((tag) => tag.startsWith("service:"))
|
||||
.map((tag) => tag.slice("service:".length))
|
||||
.filter(Boolean);
|
||||
return {
|
||||
requiresNetwork: true,
|
||||
...(requiresBrowser ? { requiresBrowser } : {}),
|
||||
...(requiresLocalDesktop ? { requiresLocalDesktop } : {}),
|
||||
...(requiresAudioDevice ? { requiresAudioDevice } : {}),
|
||||
...(externalServices.length > 0 ? { requiresExternalServices: externalServices } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildClawPack(input: ClawPackInput): Promise<BuiltClawPack> {
|
||||
const publishFiles = normalizeClawPackFiles(input.files);
|
||||
const hostTargets = deriveClawPackHostTargets({
|
||||
capabilities: input.capabilities,
|
||||
compatibility: input.compatibility,
|
||||
});
|
||||
const environment = deriveClawPackEnvironment({
|
||||
capabilities: input.capabilities,
|
||||
files: publishFiles,
|
||||
});
|
||||
const fileManifest = publishFiles
|
||||
.map((file) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
...(file.contentType ? { contentType: file.contentType } : {}),
|
||||
}))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
const manifest: Record<string, unknown> = {
|
||||
specVersion: CLAWPACK_SPEC_VERSION,
|
||||
kind: "openclaw.clawpack",
|
||||
package: {
|
||||
name: input.name,
|
||||
owner: input.owner ?? null,
|
||||
slug: input.slug,
|
||||
version: input.version,
|
||||
family: input.family,
|
||||
channel: input.channel,
|
||||
},
|
||||
release: {
|
||||
packageId: input.packageId,
|
||||
releaseId: input.releaseId,
|
||||
publishedAt: input.publishedAt,
|
||||
...(input.source !== undefined ? { source: input.source } : {}),
|
||||
},
|
||||
artifact: {
|
||||
format: "zip",
|
||||
root: "package/",
|
||||
specVersion: CLAWPACK_SPEC_VERSION,
|
||||
contentSha256: await sha256Hex(
|
||||
textEncoder.encode(
|
||||
stableJson(fileManifest.map((file) => ({ path: file.path, sha256: file.sha256 }))),
|
||||
),
|
||||
),
|
||||
fileCount: publishFiles.length,
|
||||
},
|
||||
files: fileManifest,
|
||||
compatibility: input.compatibility ?? null,
|
||||
capabilities: input.capabilities ?? null,
|
||||
verification: input.verification ?? null,
|
||||
hostTargets,
|
||||
environment,
|
||||
runtimeBundles: [],
|
||||
};
|
||||
const manifestBytes = textEncoder.encode(stableJson(manifest));
|
||||
const manifestSha256 = await sha256Hex(manifestBytes);
|
||||
const bytes = buildDeterministicPackageZip([
|
||||
{ path: CLAWPACK_MANIFEST_PATH, bytes: manifestBytes },
|
||||
...publishFiles.map((file) => ({ path: file.path, bytes: file.bytes })),
|
||||
]);
|
||||
const sha256 = await sha256Hex(bytes);
|
||||
return {
|
||||
bytes,
|
||||
sha256,
|
||||
size: bytes.byteLength,
|
||||
fileCount: publishFiles.length + 1,
|
||||
manifestSha256,
|
||||
manifest,
|
||||
hostTargets,
|
||||
environment,
|
||||
};
|
||||
}
|
||||
@@ -56,6 +56,9 @@ export type PackageSearchDigestFields = Pick<Doc<"packages">, (typeof SHARED_KEY
|
||||
ownerHandle?: string;
|
||||
ownerKind?: "user" | "org";
|
||||
verificationTier?: Doc<"packageSearchDigest">["verificationTier"];
|
||||
clawpackAvailable?: boolean;
|
||||
hostTargetKeys?: string[];
|
||||
environmentFlags?: string[];
|
||||
};
|
||||
|
||||
type PackageCapabilitySearchDigestFields = Pick<
|
||||
@@ -74,6 +77,48 @@ export function extractPackageDigestFields(pkg: Doc<"packages">): PackageSearchD
|
||||
};
|
||||
}
|
||||
|
||||
export function extractPackageClawPackDigestFields(
|
||||
release: Doc<"packageReleases"> | null | undefined,
|
||||
): Pick<PackageSearchDigestFields, "clawpackAvailable" | "hostTargetKeys" | "environmentFlags"> {
|
||||
if (!release || release.softDeletedAt || release.clawpackRevokedAt) {
|
||||
return {
|
||||
clawpackAvailable: false,
|
||||
hostTargetKeys: [],
|
||||
environmentFlags: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
clawpackAvailable: Boolean(release.clawpackStorageId),
|
||||
hostTargetKeys: getPackageClawPackHostTargetKeys(release),
|
||||
environmentFlags: getPackageClawPackEnvironmentFlags(release),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPackageClawPackHostTargetKeys(release: Doc<"packageReleases">) {
|
||||
return [
|
||||
...new Set(
|
||||
(release.hostTargetsSummary ?? []).map((target) =>
|
||||
[target.os, target.arch, target.libc].filter(Boolean).join("-"),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function getPackageClawPackEnvironmentFlags(release: Doc<"packageReleases">) {
|
||||
const environment = release.environmentSummary;
|
||||
const flags = [
|
||||
environment?.requiresLocalDesktop ? "desktop" : null,
|
||||
environment?.requiresBrowser ? "browser" : null,
|
||||
environment?.requiresAudioDevice ? "audio" : null,
|
||||
environment?.requiresNetwork ? "network" : null,
|
||||
environment?.supportsRemoteHost ? "remote-host" : null,
|
||||
...(environment?.requiresExternalServices ?? []).map((service) => `service:${service}`),
|
||||
...(environment?.requiresOsPermissions ?? []).map((permission) => `permission:${permission}`),
|
||||
...(environment?.knownUnsupported ?? []).map((target) => `unsupported:${target}`),
|
||||
].filter((flag): flag is string => Boolean(flag));
|
||||
return [...new Set(flags)];
|
||||
}
|
||||
|
||||
export async function upsertPackageSearchDigest(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
fields: PackageSearchDigestFields,
|
||||
|
||||
@@ -73,6 +73,8 @@ const listPublicPageHandler = (
|
||||
isOfficial?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
hostTarget?: string;
|
||||
environment?: string;
|
||||
paginationOpts: { cursor: string | null; numItems: number };
|
||||
},
|
||||
{ page: Array<{ name: string }>; isDone: boolean; continueCursor: string }
|
||||
@@ -384,6 +386,11 @@ function makeDigestCtx(options: {
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
}>;
|
||||
clawPackPages?: Array<{
|
||||
page: Array<Record<string, unknown>>;
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
}>;
|
||||
exactPackages?: Array<Record<string, unknown>>;
|
||||
exactDigests?: Array<Record<string, unknown>>;
|
||||
publisherMemberships?: Record<string, "owner" | "admin" | "publisher">;
|
||||
@@ -428,6 +435,7 @@ function makeDigestCtx(options: {
|
||||
|
||||
setPages("packageSearchDigest", options.pages ?? []);
|
||||
setPages("packageCapabilitySearchDigest", options.capabilityPages ?? []);
|
||||
setPages("packageClawPackSearchIndex", options.clawPackPages ?? []);
|
||||
|
||||
const paginate = vi.fn();
|
||||
const paginateForTable = (table: string) =>
|
||||
@@ -610,6 +618,12 @@ function makeDigestCtx(options: {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "packageClawPackSearchIndex") {
|
||||
tableNames.push(table);
|
||||
return {
|
||||
withIndex: (indexName: string) => withIndex(table, indexName),
|
||||
};
|
||||
}
|
||||
if (table !== "packageCapabilitySearchDigest") {
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}
|
||||
@@ -1318,6 +1332,43 @@ describe("packages public queries", () => {
|
||||
expect(indexNames).toEqual(["by_active_executes_updated"]);
|
||||
});
|
||||
|
||||
it("uses the ClawPack index for host-target public listings", async () => {
|
||||
const digest = makeDigest("darwin-demo", {
|
||||
packageId: "packages:darwin-demo",
|
||||
clawpackAvailable: true,
|
||||
hostTargetKeys: ["darwin-arm64"],
|
||||
});
|
||||
const { ctx, indexNames, tableNames } = makeDigestCtx({
|
||||
clawPackPages: [
|
||||
{
|
||||
page: [
|
||||
{
|
||||
_id: "packageClawPackSearchIndex:1",
|
||||
packageId: "packages:darwin-demo",
|
||||
releaseId: "packageReleases:darwin-demo",
|
||||
kind: "host-target",
|
||||
key: "darwin-arm64",
|
||||
updatedAt: 10,
|
||||
createdAt: 10,
|
||||
},
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
exactDigests: [digest],
|
||||
});
|
||||
|
||||
const result = await listPublicPageHandler(ctx, {
|
||||
hostTarget: "darwin-arm64",
|
||||
paginationOpts: { cursor: null, numItems: 10 },
|
||||
});
|
||||
|
||||
expect(result.page.map((entry) => entry.name)).toEqual(["darwin-demo"]);
|
||||
expect(tableNames).toEqual(["packageClawPackSearchIndex", "packageSearchDigest"]);
|
||||
expect(indexNames).toEqual(["by_kind_key_updated"]);
|
||||
});
|
||||
|
||||
it("uses capability digests for capability-tagged package search", async () => {
|
||||
const { ctx, indexNames, tableNames } = makeDigestCtx({
|
||||
capabilityPages: [
|
||||
@@ -2728,6 +2779,7 @@ describe("packages public queries", () => {
|
||||
},
|
||||
storage: {
|
||||
get: vi.fn(),
|
||||
store: vi.fn().mockResolvedValue("storage:clawpack"),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2810,6 +2862,7 @@ describe("packages public queries", () => {
|
||||
},
|
||||
storage: {
|
||||
get: vi.fn(),
|
||||
store: vi.fn().mockResolvedValue("storage:clawpack"),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2935,6 +2988,7 @@ describe("packages public queries", () => {
|
||||
"storage:package",
|
||||
JSON.stringify({
|
||||
name: "demo-plugin",
|
||||
$schema: "https://json.schemastore.org/package",
|
||||
openclaw: {
|
||||
extensions: ["./dist/index.js"],
|
||||
compat: { pluginApi: "^1.0.0" },
|
||||
@@ -2947,6 +3001,11 @@ describe("packages public queries", () => {
|
||||
"storage:manifest",
|
||||
JSON.stringify({
|
||||
id: "demo.plugin",
|
||||
configSchema: {
|
||||
$defs: {
|
||||
secret: { $ref: "#/$defs/secret" },
|
||||
},
|
||||
},
|
||||
tools: [{ name: "demoTool" }],
|
||||
}),
|
||||
],
|
||||
@@ -2958,6 +3017,7 @@ describe("packages public queries", () => {
|
||||
const content = files.get(storageId);
|
||||
return content ? new Blob([content]) : null;
|
||||
}),
|
||||
store: vi.fn().mockResolvedValue("storage:clawpack"),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2983,21 +3043,21 @@ describe("packages public queries", () => {
|
||||
path: "package.json",
|
||||
size: 1,
|
||||
storageId: "storage:package",
|
||||
sha256: "package",
|
||||
sha256: "6eb6f88411091ea48eb66a990a5d83c45edb34b5a7d4db7b64ff618a82c951ef",
|
||||
contentType: "application/json",
|
||||
},
|
||||
{
|
||||
path: "openclaw.plugin.json",
|
||||
size: 1,
|
||||
storageId: "storage:manifest",
|
||||
sha256: "manifest",
|
||||
sha256: "765ff752ed0b735b69860133a82c088479f2ecd84abb7db0ee3edf412239ec9e",
|
||||
contentType: "application/json",
|
||||
},
|
||||
{
|
||||
path: "dist/index.js",
|
||||
size: 1,
|
||||
storageId: "storage:code",
|
||||
sha256: "code",
|
||||
sha256: "42d6cead6d2a563483e07881281dabe3a21c964e5522eb690ab0406528943ab3",
|
||||
contentType: "application/javascript",
|
||||
},
|
||||
],
|
||||
@@ -3005,6 +3065,23 @@ describe("packages public queries", () => {
|
||||
})) as Record<string, unknown>;
|
||||
|
||||
expect(runMutation).toHaveBeenCalled();
|
||||
const insertReleaseArgs = runMutation.mock.calls.find(
|
||||
([, args]) => typeof args === "object" && args !== null && "extractedPackageJson" in args,
|
||||
)?.[1];
|
||||
expect(insertReleaseArgs).toEqual(
|
||||
expect.objectContaining({
|
||||
extractedPackageJson: expect.objectContaining({
|
||||
dollar_schema: "https://json.schemastore.org/package",
|
||||
}),
|
||||
extractedPluginManifest: expect.objectContaining({
|
||||
configSchema: {
|
||||
dollar_defs: {
|
||||
secret: { dollar_ref: "#/$defs/secret" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.verification).toEqual(expect.objectContaining({ scanStatus: "pending" }));
|
||||
expect(result.staticScan).toEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
+2082
-3
File diff suppressed because it is too large
Load Diff
+144
-1
@@ -29,6 +29,30 @@ const vtAnalysisValidator = v.object({
|
||||
checkedAt: v.number(),
|
||||
});
|
||||
|
||||
const packageHostTargetValidator = v.object({
|
||||
os: v.union(v.literal("darwin"), v.literal("linux"), v.literal("win32")),
|
||||
arch: v.union(v.literal("arm64"), v.literal("x64")),
|
||||
libc: v.optional(v.union(v.literal("glibc"), v.literal("musl"))),
|
||||
nodeRange: v.optional(v.string()),
|
||||
openclawRange: v.optional(v.string()),
|
||||
pluginApiRange: v.optional(v.string()),
|
||||
supportState: v.optional(
|
||||
v.union(v.literal("supported"), v.literal("setup-required"), v.literal("unsupported")),
|
||||
),
|
||||
unsupportedReason: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const packageEnvironmentSummaryValidator = v.object({
|
||||
requiresLocalDesktop: v.optional(v.boolean()),
|
||||
requiresBrowser: v.optional(v.boolean()),
|
||||
requiresAudioDevice: v.optional(v.boolean()),
|
||||
requiresNetwork: v.optional(v.boolean()),
|
||||
requiresExternalServices: v.optional(v.array(v.string())),
|
||||
requiresOsPermissions: v.optional(v.array(v.string())),
|
||||
supportsRemoteHost: v.optional(v.boolean()),
|
||||
knownUnsupported: v.optional(v.array(v.string())),
|
||||
});
|
||||
|
||||
const depRegistryStatusValidator = v.union(
|
||||
v.literal("clean"),
|
||||
v.literal("suspicious"),
|
||||
@@ -822,6 +846,20 @@ const packageReleases = defineTable({
|
||||
compatibility: packageCompatibilityValidator,
|
||||
capabilities: packageCapabilitiesValidator,
|
||||
verification: packageVerificationValidator,
|
||||
clawpackStorageId: v.optional(v.id("_storage")),
|
||||
clawpackSha256: v.optional(v.string()),
|
||||
clawpackSize: v.optional(v.number()),
|
||||
clawpackSpecVersion: v.optional(v.number()),
|
||||
clawpackFormat: v.optional(v.literal("zip")),
|
||||
clawpackFileCount: v.optional(v.number()),
|
||||
clawpackManifestSha256: v.optional(v.string()),
|
||||
clawpackBuiltAt: v.optional(v.number()),
|
||||
clawpackBuildVersion: v.optional(v.string()),
|
||||
clawpackRevokedAt: v.optional(v.number()),
|
||||
clawpackRevokedByUserId: v.optional(v.id("users")),
|
||||
clawpackRevocationReason: v.optional(v.string()),
|
||||
hostTargetsSummary: v.optional(v.array(packageHostTargetValidator)),
|
||||
environmentSummary: v.optional(packageEnvironmentSummaryValidator),
|
||||
sha256hash: v.optional(v.string()),
|
||||
vtAnalysis: v.optional(vtAnalysisValidator),
|
||||
llmAnalysis: v.optional(
|
||||
@@ -875,7 +913,96 @@ const packageReleases = defineTable({
|
||||
.index("by_package_active_created", ["packageId", "softDeletedAt", "createdAt"])
|
||||
.index("by_active_created", ["softDeletedAt", "createdAt"])
|
||||
.index("by_package_version", ["packageId", "version"])
|
||||
.index("by_sha256hash", ["sha256hash"]);
|
||||
.index("by_sha256hash", ["sha256hash"])
|
||||
.index("by_clawpack_built_at", ["clawpackBuiltAt"]);
|
||||
|
||||
const packageReleaseArtifacts = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
releaseId: v.id("packageReleases"),
|
||||
kind: v.union(
|
||||
v.literal("clawpack"),
|
||||
v.literal("runtime-bundle"),
|
||||
v.literal("scan-report"),
|
||||
v.literal("sbom"),
|
||||
),
|
||||
targetKey: v.optional(v.string()),
|
||||
storageId: v.id("_storage"),
|
||||
sha256: v.string(),
|
||||
size: v.number(),
|
||||
format: v.string(),
|
||||
createdAt: v.number(),
|
||||
status: v.union(v.literal("active"), v.literal("superseded"), v.literal("revoked")),
|
||||
revokedAt: v.optional(v.number()),
|
||||
revokedByUserId: v.optional(v.id("users")),
|
||||
revocationReason: v.optional(v.string()),
|
||||
})
|
||||
.index("by_release", ["releaseId"])
|
||||
.index("by_package_kind", ["packageId", "kind"])
|
||||
.index("by_sha256", ["sha256"])
|
||||
.index("by_target_key", ["targetKey"])
|
||||
.index("by_status", ["status"]);
|
||||
|
||||
const packageClawPackBackfillFailures = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
releaseId: v.id("packageReleases"),
|
||||
name: v.string(),
|
||||
version: v.string(),
|
||||
error: v.string(),
|
||||
attemptCount: v.number(),
|
||||
firstFailedAt: v.number(),
|
||||
lastAttemptAt: v.number(),
|
||||
lastFailedAt: v.number(),
|
||||
resolvedAt: v.optional(v.number()),
|
||||
})
|
||||
.index("by_release", ["releaseId"])
|
||||
.index("by_package_failed_at", ["packageId", "lastFailedAt"])
|
||||
.index("by_open_failed_at", ["resolvedAt", "lastFailedAt"]);
|
||||
|
||||
const packageClawPackSearchIndex = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
releaseId: v.id("packageReleases"),
|
||||
kind: v.union(v.literal("host-target"), v.literal("environment")),
|
||||
key: v.string(),
|
||||
updatedAt: v.number(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_release", ["releaseId"])
|
||||
.index("by_package", ["packageId"])
|
||||
.index("by_package_kind_key", ["packageId", "kind", "key"])
|
||||
.index("by_kind_key_updated", ["kind", "key", "updatedAt"]);
|
||||
|
||||
const clawPackMigrationRuns = defineTable({
|
||||
actorUserId: v.id("users"),
|
||||
operation: v.union(
|
||||
v.literal("artifact-backfill"),
|
||||
v.literal("failure-retry"),
|
||||
v.literal("search-index-backfill"),
|
||||
),
|
||||
status: v.union(
|
||||
v.literal("pending"),
|
||||
v.literal("running"),
|
||||
v.literal("completed"),
|
||||
v.literal("failed"),
|
||||
),
|
||||
limit: v.number(),
|
||||
cursor: v.optional(v.string()),
|
||||
continueCursor: v.optional(v.string()),
|
||||
isDone: v.optional(v.boolean()),
|
||||
processed: v.number(),
|
||||
generated: v.number(),
|
||||
skipped: v.number(),
|
||||
failed: v.number(),
|
||||
bytesGenerated: v.number(),
|
||||
failureCounts: v.record(v.string(), v.number()),
|
||||
lastError: v.optional(v.string()),
|
||||
startedAt: v.optional(v.number()),
|
||||
completedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_created_at", ["createdAt"])
|
||||
.index("by_status_created_at", ["status", "createdAt"])
|
||||
.index("by_actor_created_at", ["actorUserId", "createdAt"]);
|
||||
|
||||
const packageTrustedPublishers = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
@@ -939,6 +1066,9 @@ const packageSearchDigest = defineTable({
|
||||
capabilityTags: v.optional(v.array(v.string())),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
verificationTier: v.optional(packageVerificationTierValidator),
|
||||
clawpackAvailable: v.optional(v.boolean()),
|
||||
hostTargetKeys: v.optional(v.array(v.string())),
|
||||
environmentFlags: v.optional(v.array(v.string())),
|
||||
scanStatus: packageScanStatusValidator,
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
@@ -983,6 +1113,12 @@ const packageSearchDigest = defineTable({
|
||||
"executesCode",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_active_family_scan_status_updated", [
|
||||
"softDeletedAt",
|
||||
"family",
|
||||
"scanStatus",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_active_channel_executes_updated", [
|
||||
"softDeletedAt",
|
||||
"channel",
|
||||
@@ -1025,6 +1161,9 @@ const packageCapabilitySearchDigest = defineTable({
|
||||
capabilityTag: v.string(),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
verificationTier: v.optional(packageVerificationTierValidator),
|
||||
clawpackAvailable: v.optional(v.boolean()),
|
||||
hostTargetKeys: v.optional(v.array(v.string())),
|
||||
environmentFlags: v.optional(v.array(v.string())),
|
||||
scanStatus: packageScanStatusValidator,
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
@@ -1466,6 +1605,10 @@ export default defineSchema({
|
||||
skillSlugAliases,
|
||||
packages,
|
||||
packageReleases,
|
||||
packageReleaseArtifacts,
|
||||
packageClawPackBackfillFailures,
|
||||
packageClawPackSearchIndex,
|
||||
clawPackMigrationRuns,
|
||||
packageTrustedPublishers,
|
||||
packagePublishTokens,
|
||||
packageBadges,
|
||||
|
||||
+13
-5
@@ -13,11 +13,15 @@ Reading order (new contributor):
|
||||
2. `docs/quickstart.md`: end-to-end: search → install → publish → sync.
|
||||
3. `docs/architecture.md`: how the pieces fit (TanStack Start + Convex + CLI).
|
||||
4. `docs/skill-format.md`: what a “skill” is on disk + on the registry.
|
||||
5. `docs/cli.md`: CLI reference (flags, config, lockfiles, sync rules).
|
||||
6. `docs/http-api.md`: HTTP endpoints used by the CLI + public API.
|
||||
7. `docs/auth.md`: GitHub OAuth + API tokens + CLI loopback login.
|
||||
8. `docs/deploy.md`: Convex + Vercel deployment + rewrites.
|
||||
9. `docs/troubleshooting.md`: common failure modes.
|
||||
5. `docs/plugin-publishing.md`: publish plugin packages and preview ClawPack output.
|
||||
6. `docs/clawpack.md`: ClawPack artifact contract, download, and verification.
|
||||
7. `docs/clawpack-operations.md`: ClawPack moderation, backfill, retry, and revocation.
|
||||
8. `docs/official-plugin-migration-readiness.md`: readiness tracking for future OpenClaw externalization.
|
||||
9. `docs/cli.md`: CLI reference (flags, config, lockfiles, sync rules).
|
||||
10. `docs/http-api.md`: HTTP endpoints used by the CLI + public API.
|
||||
11. `docs/auth.md`: GitHub OAuth + API tokens + CLI loopback login.
|
||||
12. `docs/deploy.md`: Convex + Vercel deployment + rewrites.
|
||||
13. `docs/troubleshooting.md`: common failure modes.
|
||||
|
||||
Feature/ops docs (already present):
|
||||
|
||||
@@ -27,6 +31,10 @@ Feature/ops docs (already present):
|
||||
- `docs/webhook.md`: Discord webhook events/payload.
|
||||
- `docs/diffing.md`: version-to-version diff UI spec.
|
||||
- `docs/manual-testing.md`: CLI smoke scripts.
|
||||
- `docs/clawpack.md`: ClawPack artifact model and integrity checks.
|
||||
- `docs/clawpack-operations.md`: staff operation runbook for ClawPack artifacts.
|
||||
- `docs/plugin-publishing.md`: publisher workflow for code and bundle plugins.
|
||||
- `docs/official-plugin-migration-readiness.md`: ClawHub-only readiness tracker for bundled OpenClaw plugin migration planning.
|
||||
|
||||
Docs tooling:
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
summary: "Staff runbook for ClawPack migration, moderation, retry, and revocation."
|
||||
read_when:
|
||||
- Operating ClawPack backfills
|
||||
- Moderating plugin artifacts
|
||||
- Debugging failed package artifact builds
|
||||
---
|
||||
|
||||
# ClawPack Operations
|
||||
|
||||
ClawPack operations are staff-only surfaces for migration, moderation,
|
||||
artifact recovery, and revocation. They exist so operators do not have to edit
|
||||
Convex documents manually.
|
||||
|
||||
Current management entry points:
|
||||
|
||||
```text
|
||||
/management
|
||||
/management/clawpacks
|
||||
/management/moderation
|
||||
/management/migrations
|
||||
```
|
||||
|
||||
Unauthorized users should see the required role and their current auth state,
|
||||
not a generic broken page.
|
||||
|
||||
## Roles
|
||||
|
||||
- moderators can review plugin risk and revoke ClawPack artifacts
|
||||
- admins can run migration and backfill operations
|
||||
- normal publishers can publish their own plugins but cannot mutate staff state
|
||||
|
||||
Live Convex mutations and deploys should be confirmed before running in a
|
||||
shared or production deployment.
|
||||
|
||||
## ClawPack Ops Dashboard
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
/management/clawpacks
|
||||
```
|
||||
|
||||
The dashboard should answer:
|
||||
|
||||
- how many plugin releases exist
|
||||
- how many have ClawPack artifacts
|
||||
- how many are missing artifacts
|
||||
- how many artifacts are revoked
|
||||
- how many builds failed
|
||||
- how many search index rows exist
|
||||
- which sample rows need attention
|
||||
|
||||
Admin actions:
|
||||
|
||||
- preview migration candidates without writing
|
||||
- create persistent migration runs
|
||||
- execute one bounded batch at a time
|
||||
- build missing ClawPack artifacts in bounded repair batches
|
||||
- rebuild ClawPack host/environment index rows
|
||||
- retry failed builds
|
||||
- inspect failed release ids and reason codes
|
||||
|
||||
Every batch must be bounded and tied to a visible run record when the operation
|
||||
is part of a coordinated migration. Avoid unbounded table scans and avoid any
|
||||
operation that makes a partial migration silently look complete.
|
||||
|
||||
## CLI Admin Commands
|
||||
|
||||
Status:
|
||||
|
||||
```bash
|
||||
clawhub package clawpack-admin status --json
|
||||
```
|
||||
|
||||
Preview a coordinated migration:
|
||||
|
||||
```bash
|
||||
clawhub package clawpack-admin dry-run --operation artifact-backfill --limit 25
|
||||
```
|
||||
|
||||
Create and continue a durable run:
|
||||
|
||||
```bash
|
||||
clawhub package clawpack-admin create-run --operation artifact-backfill --limit 25
|
||||
clawhub package clawpack-admin continue-run <run-id>
|
||||
```
|
||||
|
||||
List run history:
|
||||
|
||||
```bash
|
||||
clawhub package clawpack-admin runs --status failed --json
|
||||
```
|
||||
|
||||
Direct repair for missing artifacts:
|
||||
|
||||
```bash
|
||||
clawhub package clawpack-admin backfill --limit 25
|
||||
```
|
||||
|
||||
Direct search-index repair:
|
||||
|
||||
```bash
|
||||
clawhub package clawpack-admin index-backfill --limit 100
|
||||
```
|
||||
|
||||
Direct failure retry:
|
||||
|
||||
```bash
|
||||
clawhub package clawpack-admin retry-failures --limit 25
|
||||
```
|
||||
|
||||
Revoke an artifact:
|
||||
|
||||
```bash
|
||||
clawhub package clawpack-admin revoke <name> <version> --reason "reason code or note"
|
||||
```
|
||||
|
||||
Use `--json` for automation and audit capture. For production-sized work, prefer
|
||||
`dry-run` -> `create-run` -> repeated `continue-run` over the direct repair
|
||||
commands.
|
||||
|
||||
## Moderation Console
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
/management/moderation
|
||||
```
|
||||
|
||||
Moderators should see plugin releases by risk and operational state:
|
||||
|
||||
- pending review
|
||||
- suspicious scan
|
||||
- malicious scan
|
||||
- missing ClawPack
|
||||
- failed ClawPack build
|
||||
- revoked
|
||||
- official review
|
||||
- metadata incomplete
|
||||
|
||||
The queue should show source facts, ClawPack digest, scan summaries, LLM/static
|
||||
verdicts, VirusTotal status where present, and latest release state.
|
||||
|
||||
Destructive actions require a reason. Revocation reason should be visible to
|
||||
staff and exposed safely through API responses where useful.
|
||||
|
||||
## Revocation
|
||||
|
||||
Revocation makes the stored artifact non-downloadable. It is separate from
|
||||
package deletion and separate from hiding a package.
|
||||
|
||||
Revocation must update:
|
||||
|
||||
- artifact status
|
||||
- release summary fields
|
||||
- revocation timestamp
|
||||
- revoking user id
|
||||
- reason text
|
||||
|
||||
All ClawPack download paths must block revoked artifacts.
|
||||
|
||||
## Retry and Recovery
|
||||
|
||||
Retry is safe for transient storage/build failures and search index failures.
|
||||
Retry is not a substitute for fixing publisher metadata. If validation failed
|
||||
because metadata is incomplete or unsafe, ask the publisher for a corrected
|
||||
release.
|
||||
|
||||
Operators should record:
|
||||
|
||||
- failed release id
|
||||
- package name
|
||||
- version
|
||||
- failure code
|
||||
- failure message
|
||||
- retry count
|
||||
- last attempted time
|
||||
|
||||
## Integrity Sampling
|
||||
|
||||
Integrity checks should compare:
|
||||
|
||||
- stored archive digest
|
||||
- release summary digest
|
||||
- artifact row digest
|
||||
- generated manifest digest
|
||||
- archive availability in Convex storage
|
||||
|
||||
Digest mismatch is a serious incident. Revoke first if public downloads could
|
||||
serve corrupted or substituted artifacts, then rebuild from trusted source if
|
||||
available.
|
||||
|
||||
## Production Safety
|
||||
|
||||
Before production operations:
|
||||
|
||||
1. Check current deployment health.
|
||||
2. Dry-run or status-read first.
|
||||
3. Use small bounded limits.
|
||||
4. Capture command output.
|
||||
5. Confirm before any write action.
|
||||
6. Recheck status after the batch.
|
||||
|
||||
Do not run ClawPack migrations as a single unbounded backfill.
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
summary: "ClawPack artifact contract, integrity model, and download behavior."
|
||||
read_when:
|
||||
- Working on plugin artifact storage
|
||||
- Changing package download APIs
|
||||
- Debugging ClawPack verification
|
||||
---
|
||||
|
||||
# ClawPack
|
||||
|
||||
ClawPack is ClawHub's stored artifact format for plugin releases. A ClawPack
|
||||
is a deterministic ZIP archive built by ClawHub from publisher-provided package
|
||||
source. Publishers may upload `CLAWPACK.json`, but ClawHub ignores it and
|
||||
generates the canonical manifest itself.
|
||||
|
||||
ClawPack is not OpenClaw install support by itself. OpenClaw consumption is a
|
||||
future downstream step. This repository owns artifact creation, storage,
|
||||
moderation, API, CLI, and operator readiness surfaces.
|
||||
|
||||
## Contract
|
||||
|
||||
Every active ClawPack has:
|
||||
|
||||
- a canonical package name
|
||||
- a release version
|
||||
- `package/CLAWPACK.json`
|
||||
- normalized package files under `package/`
|
||||
- a SHA-256 digest of the final ZIP bytes
|
||||
- a manifest SHA-256 digest
|
||||
- a file count and byte size
|
||||
- a spec version
|
||||
- a build timestamp
|
||||
- a storage id in Convex file storage
|
||||
- artifact status: `active`, `superseded`, or `revoked`
|
||||
|
||||
The ZIP digest is the immutable artifact identity. The release row stores a hot
|
||||
summary for UI/API reads, while the artifact row owns detailed storage identity
|
||||
and status.
|
||||
|
||||
## Manifest
|
||||
|
||||
`package/CLAWPACK.json` describes the archive ClawHub actually produced. It
|
||||
includes package identity, source attribution, compatibility, host targets,
|
||||
environment requirements, and file summaries.
|
||||
|
||||
Required properties for plugin confidence:
|
||||
|
||||
- package family: `code-plugin` or `bundle-plugin`
|
||||
- package name and version
|
||||
- source repository, path, ref, or commit where known
|
||||
- OpenClaw compatibility range for code plugins
|
||||
- plugin API compatibility range for code plugins
|
||||
- host target matrix where declared
|
||||
- environment flags such as browser, desktop, network, native dependencies, or external services
|
||||
|
||||
Missing host or environment facts do not always block publish, but they lower
|
||||
readiness and should be visible in UI, API, and moderation tools.
|
||||
|
||||
## Build Rules
|
||||
|
||||
The artifact builder must:
|
||||
|
||||
- reject unsafe archive paths, absolute paths, and traversal paths
|
||||
- normalize path separators
|
||||
- ignore local junk such as dependency folders and build cache files
|
||||
- ignore publisher-provided `CLAWPACK.json`
|
||||
- sort manifest entries deterministically
|
||||
- build deterministic ZIP bytes
|
||||
- hash the final archive bytes
|
||||
- store the artifact in Convex storage
|
||||
- write release summary fields and artifact records together
|
||||
- avoid making a failed artifact publicly installable
|
||||
|
||||
## Download Paths
|
||||
|
||||
Public download routes return stored artifacts, not regenerated archives.
|
||||
|
||||
- `GET /api/v1/packages/{name}/download`
|
||||
- `GET /api/v1/packages/{name}/versions/{version}/clawpack`
|
||||
- `GET /api/v1/clawpacks/{sha256}`
|
||||
|
||||
Expected headers:
|
||||
|
||||
```http
|
||||
ETag: "sha256:<hex>"
|
||||
Digest: sha-256=<base64>
|
||||
X-ClawHub-ClawPack-Sha256: <hex>
|
||||
X-ClawHub-ClawPack-Spec-Version: 1
|
||||
X-ClawHub-Artifact-Status: active
|
||||
```
|
||||
|
||||
Revoked artifacts must not be served from any path.
|
||||
|
||||
## CLI Verification
|
||||
|
||||
Download:
|
||||
|
||||
```bash
|
||||
clawhub package download <name> --version <version>
|
||||
```
|
||||
|
||||
Inspect:
|
||||
|
||||
```bash
|
||||
clawhub package inspect <name> --version <version>
|
||||
clawhub package clawpack <name> --version <version> --json
|
||||
```
|
||||
|
||||
Verify a downloaded artifact:
|
||||
|
||||
```bash
|
||||
clawhub package verify <file>.clawpack.zip --sha256 <digest>
|
||||
```
|
||||
|
||||
The verifier checks the archive digest when `--sha256` is provided and confirms
|
||||
that `package/CLAWPACK.json` exists.
|
||||
|
||||
## Storage
|
||||
|
||||
V1 source of truth is Convex file storage. The database stores the Convex
|
||||
storage id, artifact digest, status, and release summary.
|
||||
|
||||
S3 is intentionally not required for the first platform release. A later mirror
|
||||
can add provider, bucket/key, mirror digest, status, and repair metadata, but
|
||||
the mirror must never be trusted until digest verification passes against the
|
||||
Convex source artifact.
|
||||
|
||||
## Failure Model
|
||||
|
||||
Common failure states:
|
||||
|
||||
- metadata validation blocked publish
|
||||
- archive expansion failed
|
||||
- unsafe path rejected
|
||||
- ClawPack build failed
|
||||
- Convex storage write failed
|
||||
- artifact row write failed
|
||||
- search index backfill failed
|
||||
- artifact revoked after publish
|
||||
|
||||
Admin and moderator tooling should show the failed step, reason code, release
|
||||
identity, and retry path where retry is safe.
|
||||
+116
-1
@@ -237,6 +237,8 @@ Stores your API token + cached registry URL.
|
||||
- `--family skill|code-plugin|bundle-plugin`
|
||||
- `--official`
|
||||
- `--executes-code`
|
||||
- `--host-target <target>` (for example `darwin-arm64`, `linux-x64-glibc`, `win32-x64`)
|
||||
- `--environment <flag>` (for example `browser`, `desktop`, `network`)
|
||||
- `--limit <n>` (1-100, default: 25)
|
||||
- `--json`
|
||||
|
||||
@@ -244,13 +246,15 @@ Examples:
|
||||
|
||||
```bash
|
||||
clawhub package explore --family code-plugin
|
||||
clawhub package explore --family code-plugin --host-target darwin-arm64
|
||||
clawhub package explore browser --family code-plugin --environment browser
|
||||
clawhub package explore episodic-claw --family code-plugin
|
||||
```
|
||||
|
||||
### `package inspect <name>`
|
||||
|
||||
- Fetches package metadata without installing.
|
||||
- Use this for plugin metadata, compatibility, verification, source, and version/file inspection.
|
||||
- Use this for plugin metadata, ClawPack availability, compatibility, verification, source, and version/file inspection.
|
||||
- `--version <version>`: inspect a specific version (default: latest).
|
||||
- `--tag <tag>`: inspect a tagged version (e.g. `latest`).
|
||||
- `--versions`: list version history (first page).
|
||||
@@ -259,11 +263,122 @@ clawhub package explore episodic-claw --family code-plugin
|
||||
- `--file <path>`: fetch raw file content (text files only; 200KB limit).
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
### `package download <name>`
|
||||
|
||||
- Downloads the selected package release as a ClawPack archive.
|
||||
- Calls `GET /api/v1/packages/{name}/download`.
|
||||
- Defaults to the latest release.
|
||||
- `--version <version>`: download a specific version.
|
||||
- `--tag <tag>`: download a tagged version.
|
||||
- `-o, --output <path>`: output path. Defaults to `<name>.clawpack.zip`.
|
||||
- `--json`: print the output path, ClawPack SHA-256 header, and spec version.
|
||||
|
||||
### `package verify <file>`
|
||||
|
||||
- Verifies a downloaded ClawPack ZIP.
|
||||
- Requires `package/CLAWPACK.json` inside the archive.
|
||||
- `--sha256 <digest>`: also compare the full archive SHA-256.
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
### `package clawpack <name>`
|
||||
|
||||
- Alias-style ClawPack download command for operators who want the artifact noun first.
|
||||
- Accepts the same `--version`, `--tag`, `--output`, and `--json` flags as `package download`.
|
||||
|
||||
### `package clawpack-admin status`
|
||||
|
||||
- Admin-only migration status check.
|
||||
- Calls `GET /api/v1/packages/clawpack/migration-status`.
|
||||
- Requires an API token for an admin user.
|
||||
- `--limit <n>` controls sample size.
|
||||
- `--json` emits the raw response.
|
||||
|
||||
### `package clawpack-admin readiness`
|
||||
|
||||
- Admin-only readiness check for the official OpenClaw bundled plugin migration targets.
|
||||
- Calls `GET /api/v1/packages/clawpack/migration-readiness`.
|
||||
- Requires an API token for an admin user.
|
||||
- Prints each target package, readiness state, and blocker list.
|
||||
- `--json` emits the raw response.
|
||||
|
||||
### `package clawpack-admin dry-run`
|
||||
|
||||
- Admin-only preview for a persistent ClawPack migration run.
|
||||
- Calls `GET /api/v1/packages/clawpack/migration-runs/dry-run`.
|
||||
- Requires an API token for an admin user.
|
||||
- `--operation <operation>` accepts `artifact-backfill`, `failure-retry`, or `search-index-backfill`.
|
||||
- `--limit <n>` controls candidate sample or batch size.
|
||||
- `--cursor <cursor>` previews a later search-index batch.
|
||||
- `--json` emits the raw response.
|
||||
|
||||
### `package clawpack-admin runs`
|
||||
|
||||
- Admin-only run ledger for ClawPack migration operations.
|
||||
- Calls `GET /api/v1/packages/clawpack/migration-runs`.
|
||||
- Requires an API token for an admin user.
|
||||
- `--status <status>` filters by `pending`, `running`, `completed`, or `failed`.
|
||||
- `--limit <n>` controls run count.
|
||||
- `--json` emits the raw response.
|
||||
|
||||
### `package clawpack-admin create-run`
|
||||
|
||||
- Admin-only creation path for a durable ClawPack migration run.
|
||||
- Calls `POST /api/v1/packages/clawpack/migration-runs`.
|
||||
- Requires an API token for an admin user.
|
||||
- `--operation <operation>` accepts `artifact-backfill`, `failure-retry`, or `search-index-backfill`.
|
||||
- `--limit <n>` controls batch size for each continuation.
|
||||
- `--cursor <cursor>` sets the initial search-index cursor.
|
||||
- `--json` emits the raw response.
|
||||
|
||||
### `package clawpack-admin continue-run <run-id>`
|
||||
|
||||
- Admin-only execution path for the next bounded batch of a migration run.
|
||||
- Calls `POST /api/v1/packages/clawpack/migration-runs/{runId}/continue`.
|
||||
- Requires an API token for an admin user.
|
||||
- `--json` emits the raw response.
|
||||
|
||||
### `package clawpack-admin backfill`
|
||||
|
||||
- Admin-only direct batch builder for legacy plugin releases missing stored ClawPack artifacts.
|
||||
- Calls `POST /api/v1/packages/clawpack/backfill`.
|
||||
- Requires an API token for an admin user.
|
||||
- `--limit <n>` controls batch size.
|
||||
- `--json` emits the raw response.
|
||||
- Prefer `dry-run`, `create-run`, and `continue-run` for coordinated migrations; use direct backfill for focused repair.
|
||||
|
||||
### `package clawpack-admin index-backfill`
|
||||
|
||||
- Admin-only direct batch builder for ClawPack host-target and environment lookup indexes.
|
||||
- Calls `POST /api/v1/packages/clawpack/index-backfill`.
|
||||
- Requires an API token for an admin user.
|
||||
- `--limit <n>` controls batch size.
|
||||
- `--cursor <cursor>` continues from the previous batch response.
|
||||
- `--json` emits the raw response.
|
||||
- Prefer the `search-index-backfill` migration-run operation for coordinated index migrations.
|
||||
|
||||
### `package clawpack-admin retry-failures`
|
||||
|
||||
- Admin-only direct batch retry for failed ClawPack artifact builds.
|
||||
- Calls `POST /api/v1/packages/clawpack/retry-failures`.
|
||||
- Requires an API token for an admin user.
|
||||
- `--limit <n>` controls batch size.
|
||||
- `--json` emits the raw response.
|
||||
- Prefer the `failure-retry` migration-run operation for coordinated retries.
|
||||
|
||||
### `package clawpack-admin revoke <name> <version>`
|
||||
|
||||
- Moderator/admin revocation path for a published ClawPack artifact.
|
||||
- Calls `POST /api/v1/packages/{name}/versions/{version}/clawpack/revoke`.
|
||||
- Requires an API token for an admin or moderator user.
|
||||
- `--reason <text>` records the moderation reason.
|
||||
- `--json` emits the raw response.
|
||||
|
||||
### `package publish <source>`
|
||||
|
||||
- Publishes a code plugin or bundle plugin via `POST /api/v1/packages`.
|
||||
- `<source>` accepts:
|
||||
- Local folder path: `./my-plugin`
|
||||
- Local package archive: `./my-plugin.zip`, `./my-plugin.tgz`, or `./my-plugin.tar.gz`
|
||||
- GitHub repo: `owner/repo` or `owner/repo@ref`
|
||||
- GitHub URL: `https://github.com/owner/repo`
|
||||
- Metadata is auto-detected from `package.json`, `openclaw.plugin.json`, and `openclaw.bundle.json`.
|
||||
|
||||
+110
-2
@@ -293,15 +293,22 @@ Query params:
|
||||
- `isOfficial` (optional): `true` or `false`
|
||||
- `executesCode` (optional): `true` or `false`
|
||||
- `capabilityTag` (optional): capability filter for plugin packages
|
||||
- `hostTarget` (optional): ClawPack host target key, e.g. `darwin-arm64`, `linux-x64-glibc`, `win32-x64`
|
||||
- `environment` (optional): ClawPack environment flag, e.g. `browser`, `desktop`, `network`
|
||||
|
||||
Notes:
|
||||
|
||||
- `GET /api/v1/code-plugins` and `GET /api/v1/bundle-plugins` remain fixed-family aliases.
|
||||
- Skill entries stay backed by the skill registry and can still be published only through `POST /api/v1/skills`.
|
||||
- `POST /api/v1/packages` is still only for code-plugin and bundle-plugin releases.
|
||||
- ClawPack-only filters return plugin package entries and exclude skill-backed catalog entries.
|
||||
- Anonymous callers only see public package channels.
|
||||
- Authenticated callers can see private packages for publishers they belong to in list/search results.
|
||||
- `channel=private` only returns packages the authenticated caller can read.
|
||||
- Package list items include ClawPack summary signals when available:
|
||||
- `clawpackAvailable`
|
||||
- `hostTargetKeys`
|
||||
- `environmentFlags`
|
||||
|
||||
### `GET /api/v1/packages/search`
|
||||
|
||||
@@ -316,9 +323,12 @@ Query params:
|
||||
- `isOfficial` (optional): `true` or `false`
|
||||
- `executesCode` (optional): `true` or `false`
|
||||
- `capabilityTag` (optional): capability filter for plugin packages
|
||||
- `hostTarget` (optional): ClawPack host target key
|
||||
- `environment` (optional): ClawPack environment flag
|
||||
|
||||
Notes:
|
||||
|
||||
- ClawPack-only filters return plugin package entries and exclude skill-backed catalog entries.
|
||||
- Anonymous callers only see public package channels.
|
||||
- Authenticated callers can search private packages for publishers they belong to.
|
||||
- `channel=private` only returns packages the authenticated caller can read.
|
||||
@@ -386,8 +396,10 @@ Notes:
|
||||
|
||||
- Defaults to the latest release.
|
||||
- Skills redirect to `GET /api/v1/download`.
|
||||
- Plugin/package archives are zip files with a `package/` root so they install directly in OpenClaw without repacking.
|
||||
- Registry-only metadata is not injected into the downloaded archive.
|
||||
- Plugin/package archives are ClawPack zip files with a `package/` root and a generated `package/CLAWPACK.json` manifest.
|
||||
- Stored ClawPack artifacts are served when available; legacy releases fall back to deterministic package ZIP assembly.
|
||||
- Response headers include `X-ClawHub-ClawPack-Sha256` and `X-ClawHub-ClawPack-Spec-Version` when a stored ClawPack is served.
|
||||
- Publisher-supplied `CLAWPACK.json` files are ignored during ClawPack generation.
|
||||
- Pending VirusTotal scans do not block downloads; malicious releases return `403`.
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
|
||||
@@ -455,9 +467,105 @@ Validation highlights:
|
||||
- `family` must be `code-plugin` or `bundle-plugin`.
|
||||
- Code plugins require `package.json`, `openclaw.plugin.json`, source repo metadata, source commit metadata, and config schema metadata.
|
||||
- Bundle plugins require at least one host target.
|
||||
- Successful publishes generate and store a ClawPack artifact for the release.
|
||||
- Release detail responses include `version.clawpack` with digest, size, file count, host targets, environment summary, and runtime bundle placeholders.
|
||||
- Only trusted publishers may publish to the `official` channel.
|
||||
- On-behalf publishes still validate official-channel eligibility against the target owner account.
|
||||
|
||||
### `GET /api/v1/packages/clawpack/migration-status`
|
||||
|
||||
Admin-only ClawPack migration status.
|
||||
|
||||
- Requires Bearer token auth.
|
||||
- Caller must be an admin.
|
||||
- `limit` (optional): sample size for generated ClawPack artifact statistics.
|
||||
|
||||
### `GET /api/v1/packages/clawpack/migration-runs/dry-run`
|
||||
|
||||
Admin-only preview for a persistent ClawPack migration run.
|
||||
|
||||
- Requires Bearer token auth.
|
||||
- Caller must be an admin.
|
||||
- Query params:
|
||||
- `operation`: `artifact-backfill`, `failure-retry`, or `search-index-backfill`.
|
||||
- `limit` (optional): sample size.
|
||||
- `cursor` (optional): search-index continuation cursor.
|
||||
- Returns candidate rows and the cursor state without mutating data.
|
||||
|
||||
### `GET /api/v1/packages/clawpack/migration-runs`
|
||||
|
||||
Admin-only ClawPack migration run ledger.
|
||||
|
||||
- Requires Bearer token auth.
|
||||
- Caller must be an admin.
|
||||
- Query params:
|
||||
- `status` (optional): `pending`, `running`, `completed`, or `failed`.
|
||||
- `limit` (optional): max run records.
|
||||
|
||||
### `GET /api/v1/packages/clawpack/migration-runs/{runId}`
|
||||
|
||||
Admin-only ClawPack migration run detail.
|
||||
|
||||
- Requires Bearer token auth.
|
||||
- Caller must be an admin.
|
||||
- Returns `404` when the run id does not exist.
|
||||
|
||||
### `POST /api/v1/packages/clawpack/migration-runs`
|
||||
|
||||
Admin-only ClawPack migration run creation.
|
||||
|
||||
- Requires Bearer token auth.
|
||||
- Caller must be an admin.
|
||||
- JSON body: `{ "operation": "artifact-backfill", "limit": 10, "cursor": "optional" }`.
|
||||
- Creates a durable `pending` run. It does not execute the batch until `continue` is called.
|
||||
|
||||
### `POST /api/v1/packages/clawpack/migration-runs/{runId}/continue`
|
||||
|
||||
Admin-only execution path for one bounded ClawPack migration batch.
|
||||
|
||||
- Requires Bearer token auth.
|
||||
- Caller must be an admin.
|
||||
- Runs one batch for the selected migration run and updates processed/generated/skipped/failed counters.
|
||||
- Returns the updated run and the batch result. Failed runs store `lastError`.
|
||||
|
||||
### `POST /api/v1/packages/clawpack/backfill`
|
||||
|
||||
Admin-only ClawPack backfill batch for legacy plugin releases.
|
||||
|
||||
- Requires Bearer token auth.
|
||||
- Caller must be an admin.
|
||||
- JSON body: `{ "limit": 10 }`.
|
||||
- Builds missing ClawPack artifacts for eligible code-plugin and bundle-plugin releases.
|
||||
- Prefer migration runs for coordinated production work; use this direct endpoint for focused repair.
|
||||
|
||||
### `POST /api/v1/packages/clawpack/index-backfill`
|
||||
|
||||
Admin-only ClawPack lookup-index backfill batch.
|
||||
|
||||
- Requires Bearer token auth.
|
||||
- Caller must be an admin.
|
||||
- JSON body: `{ "limit": 25, "cursor": "<previous continueCursor>" }`.
|
||||
- Rebuilds host-target and environment lookup rows for releases with stored, non-revoked ClawPack artifacts.
|
||||
- Prefer the `search-index-backfill` migration-run operation for coordinated production work.
|
||||
|
||||
### `POST /api/v1/packages/clawpack/retry-failures`
|
||||
|
||||
Admin-only retry path for failed ClawPack artifact builds.
|
||||
|
||||
- Requires Bearer token auth.
|
||||
- Caller must be an admin.
|
||||
- JSON body: `{ "limit": 10 }`.
|
||||
- Prefer the `failure-retry` migration-run operation for coordinated production work.
|
||||
|
||||
### `POST /api/v1/packages/{name}/versions/{version}/clawpack/revoke`
|
||||
|
||||
Moderator/admin ClawPack revocation for a specific package release.
|
||||
|
||||
- Requires Bearer token auth.
|
||||
- Caller must be an admin or moderator.
|
||||
- JSON body: `{ "reason": "Malware confirmed" }`.
|
||||
- Marks the active ClawPack artifact revoked and blocks package download and digest-addressed ClawPack download paths.
|
||||
|
||||
### `DELETE /api/v1/skills/{slug}` / `POST /api/v1/skills/{slug}/undelete`
|
||||
|
||||
Soft-delete / restore a skill (owner, moderator, or admin).
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
summary: "ClawHub-only readiness tracking for future OpenClaw bundled plugin externalization."
|
||||
read_when:
|
||||
- Planning OpenClaw plugin externalization
|
||||
- Reviewing ClawPack migration readiness
|
||||
- Exporting operator status reports
|
||||
---
|
||||
|
||||
# Official Plugin Migration Readiness
|
||||
|
||||
ClawHub can track whether bundled OpenClaw plugins are ready to become external
|
||||
ClawHub-hosted packages. This tracker is informational and operational. It does
|
||||
not mutate `openclaw/openclaw`, remove bundled plugins, or claim install support
|
||||
before the downstream OpenClaw work exists.
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
/management/migrations
|
||||
```
|
||||
|
||||
CLI:
|
||||
|
||||
```bash
|
||||
clawhub package clawpack-admin readiness --json
|
||||
```
|
||||
|
||||
## Readiness Object
|
||||
|
||||
Each candidate should track:
|
||||
|
||||
- bundled plugin id
|
||||
- desired ClawHub package name
|
||||
- publisher or owner
|
||||
- source repository
|
||||
- source path
|
||||
- source commit or ref
|
||||
- current ClawHub package id
|
||||
- latest release id and version
|
||||
- ClawPack digest
|
||||
- host matrix completeness
|
||||
- environment metadata completeness
|
||||
- scan state
|
||||
- moderation state
|
||||
- docs status
|
||||
- runtime bundle decision
|
||||
- API visibility
|
||||
- blockers
|
||||
- readiness decision
|
||||
|
||||
## Gates
|
||||
|
||||
A candidate is not ready until all gates are green:
|
||||
|
||||
- package exists
|
||||
- latest release exists
|
||||
- active ClawPack exists
|
||||
- digest-addressed download works
|
||||
- source repo, path, and commit are recorded
|
||||
- host targets are complete
|
||||
- environment metadata is complete
|
||||
- scan is clean or manually approved
|
||||
- moderation is approved
|
||||
- docs link exists where required
|
||||
- runtime bundle decision is recorded
|
||||
|
||||
Readiness should be conservative. Unknown is blocked.
|
||||
|
||||
## States
|
||||
|
||||
Use explicit states:
|
||||
|
||||
```text
|
||||
planned
|
||||
package-missing
|
||||
release-missing
|
||||
clawpack-missing
|
||||
metadata-incomplete
|
||||
scan-blocked
|
||||
moderation-blocked
|
||||
runtime-bundle-blocked
|
||||
docs-blocked
|
||||
ready-for-openclaw
|
||||
```
|
||||
|
||||
Do not show `ready-for-openclaw` unless every required gate is satisfied.
|
||||
|
||||
## Operator Workflow
|
||||
|
||||
1. Open `/management/migrations`.
|
||||
2. Review each candidate state.
|
||||
3. Open the package or release links where available.
|
||||
4. Fix ClawHub-side metadata, publishing, ClawPack, moderation, or docs gaps.
|
||||
5. Export readiness for planning.
|
||||
6. Use the export as input to future OpenClaw work.
|
||||
|
||||
The export is a planning artifact, not an OpenClaw change request by itself.
|
||||
|
||||
## What This Tracker Must Not Do
|
||||
|
||||
- edit `openclaw/openclaw`
|
||||
- open OpenClaw pull requests
|
||||
- remove bundled plugin code
|
||||
- auto-publish packages without human-owned source attribution
|
||||
- mark a candidate ready while ClawPack or moderation is missing
|
||||
- hide blockers behind a single percentage score
|
||||
|
||||
## Suggested Blocker Codes
|
||||
|
||||
- `package-missing`
|
||||
- `release-missing`
|
||||
- `clawpack-missing`
|
||||
- `digest-download-failed`
|
||||
- `source-metadata-missing`
|
||||
- `host-matrix-incomplete`
|
||||
- `environment-metadata-incomplete`
|
||||
- `scan-blocked`
|
||||
- `moderation-blocked`
|
||||
- `docs-missing`
|
||||
- `runtime-decision-missing`
|
||||
|
||||
Blockers should include an owner or next action when known.
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
summary: "Publisher workflow for code-plugin and bundle-plugin ClawPack releases."
|
||||
read_when:
|
||||
- Publishing plugin packages
|
||||
- Updating the publish UI
|
||||
- Debugging package publish validation
|
||||
---
|
||||
|
||||
# Plugin Publishing
|
||||
|
||||
ClawHub supports plugin package publishing for `code-plugin` and
|
||||
`bundle-plugin` families. Publishing creates a package release and, when source
|
||||
validation passes, a stored ClawPack artifact.
|
||||
|
||||
This is ClawHub-only. It does not remove bundled plugins from OpenClaw and does
|
||||
not mean OpenClaw can install the artifact yet.
|
||||
|
||||
## Web Flow
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
/publish-plugin
|
||||
```
|
||||
|
||||
The publish page accepts:
|
||||
|
||||
- `.zip`
|
||||
- `.tgz`
|
||||
- `.tar.gz`
|
||||
- folder upload
|
||||
|
||||
The page expands package source in the browser, normalizes paths, ignores local
|
||||
junk, extracts package metadata, and previews the ClawPack manifest ClawHub
|
||||
will generate.
|
||||
|
||||
Publisher checks should make these facts obvious before publish:
|
||||
|
||||
- package name
|
||||
- display name
|
||||
- version
|
||||
- package family
|
||||
- source repository and path where known
|
||||
- source ref or commit where known
|
||||
- OpenClaw compatibility range
|
||||
- plugin API compatibility range
|
||||
- host target matrix
|
||||
- environment requirements
|
||||
- files that will be included
|
||||
- ignored files
|
||||
- blocking errors
|
||||
- non-blocking warnings
|
||||
|
||||
The metadata form stays locked until package source is selected because source
|
||||
inspection is the trust boundary. The upload panel is the primary next action.
|
||||
|
||||
## CLI Flow
|
||||
|
||||
Preview first:
|
||||
|
||||
```bash
|
||||
clawhub package publish ./my-plugin --family code-plugin --dry-run
|
||||
```
|
||||
|
||||
Publish:
|
||||
|
||||
```bash
|
||||
clawhub package publish ./my-plugin --family code-plugin
|
||||
```
|
||||
|
||||
Supported source locators:
|
||||
|
||||
- local folder
|
||||
- local archive
|
||||
- `owner/repo`
|
||||
- `owner/repo@ref`
|
||||
- GitHub URL
|
||||
|
||||
Private GitHub imports require `GITHUB_TOKEN` in the publisher environment.
|
||||
|
||||
## Code Plugin Minimum Metadata
|
||||
|
||||
Code plugins must declare OpenClaw compatibility explicitly. Do not rely on the
|
||||
package version as a fallback for runtime compatibility.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@example/openclaw-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": ["./dist/index.js"],
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.3.24-beta.2"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.3.24-beta.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Required:
|
||||
|
||||
- `openclaw.extensions`
|
||||
- `openclaw.compat.pluginApi`
|
||||
- `openclaw.build.openclawVersion`
|
||||
|
||||
Optional but useful:
|
||||
|
||||
- `openclaw.compat.minGatewayVersion`
|
||||
- `openclaw.build.pluginSdkVersion`
|
||||
- host target declarations
|
||||
- environment requirement declarations
|
||||
|
||||
## Bundle Plugin Metadata
|
||||
|
||||
Bundle plugins should ship a bundle manifest such as `openclaw.bundle.json`.
|
||||
They do not execute native code, but they still need source attribution,
|
||||
versioning, family labels, and moderation.
|
||||
|
||||
The UI and API must never blur bundle plugins with code plugins. Cards, detail
|
||||
pages, and CLI output should explicitly label the family.
|
||||
|
||||
## Publish Result
|
||||
|
||||
After publish, ClawHub should expose:
|
||||
|
||||
- package URL
|
||||
- release URL
|
||||
- ClawPack digest when available
|
||||
- moderation state
|
||||
- scan state
|
||||
- next action for the publisher
|
||||
|
||||
New releases may remain pending or limited until scans and moderation complete.
|
||||
Published is not the same thing as publicly installable.
|
||||
|
||||
## Common Blockers
|
||||
|
||||
- missing `package.json`
|
||||
- missing plugin or bundle manifest
|
||||
- unsafe archive path
|
||||
- missing code-plugin compatibility fields
|
||||
- invalid version
|
||||
- unsupported package family
|
||||
- unknown source attribution
|
||||
- empty ClawPack file list
|
||||
- storage failure after validation
|
||||
|
||||
Errors should include file or field context. Vague "invalid package" messages
|
||||
are not acceptable for plugin publishing.
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { zipSync } from "fflate";
|
||||
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function makePluginZip() {
|
||||
return Buffer.from(
|
||||
zipSync({
|
||||
"demo-plugin/package.json": encoder.encode(
|
||||
JSON.stringify({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.2.3",
|
||||
repository: "https://github.com/openclaw/demo-plugin.git",
|
||||
openclaw: {
|
||||
extensions: ["./dist/index.js"],
|
||||
compat: {
|
||||
pluginApi: ">=2026.3.24-beta.2",
|
||||
},
|
||||
build: {
|
||||
openclawVersion: "2026.3.24-beta.2",
|
||||
pluginSdkVersion: "2026.3.24-beta.2",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
"demo-plugin/openclaw.plugin.json": encoder.encode(
|
||||
JSON.stringify({
|
||||
id: "demo.plugin",
|
||||
name: "Demo Plugin",
|
||||
setupEntry: "./dist/setup.js",
|
||||
}),
|
||||
),
|
||||
"demo-plugin/dist/index.js": encoder.encode("export const demo = true;\n"),
|
||||
"demo-plugin/CLAWPACK.json": encoder.encode('{"forged": true}\n'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test("publisher can upload an archive and inspect the ClawPack preview", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/publish-plugin", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: "Publish Plugin" })).toBeVisible();
|
||||
await expect(page.locator('[data-upload-ready="true"]')).toBeVisible();
|
||||
|
||||
await page.locator('input[aria-label="Package archive input"]').setInputFiles({
|
||||
name: "demo-plugin.zip",
|
||||
mimeType: "application/zip",
|
||||
buffer: makePluginZip(),
|
||||
});
|
||||
|
||||
await expect(page.getByText("Package detected")).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Plugin name")).toHaveValue("demo-plugin");
|
||||
await expect(page.getByPlaceholder("Display name")).toHaveValue("Demo Plugin");
|
||||
await expect(page.getByPlaceholder("Version")).toHaveValue("1.2.3");
|
||||
await expect(page.getByPlaceholder("Source repo (owner/repo)")).toHaveValue(
|
||||
"openclaw/demo-plugin",
|
||||
);
|
||||
await expect(page.getByRole("heading", { name: "ClawPack preview" })).toBeVisible();
|
||||
await expect(page.getByText('"kind": "openclaw.clawpack"')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("CLAWPACK.json supplied by package will be replaced by ClawHub."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("main").getByRole("button", { name: "Publish" })).toBeDisabled();
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
test("management child routes stay on the management URL and show access diagnostics", async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/management/clawpacks", { waitUntil: "domcontentloaded" });
|
||||
await expect(page).toHaveURL(/\/management\/clawpacks$/);
|
||||
await expect(page.getByText("Management access required")).toBeVisible();
|
||||
|
||||
await page.goto("/management/moderation", { waitUntil: "domcontentloaded" });
|
||||
await expect(page).toHaveURL(/\/management\/moderation$/);
|
||||
await expect(page.getByText("Management access required")).toBeVisible();
|
||||
|
||||
await page.goto("/management/migrations", { waitUntil: "domcontentloaded" });
|
||||
await expect(page).toHaveURL(/\/management\/migrations$/);
|
||||
await expect(page.getByText("Management access required")).toBeVisible();
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
@@ -18,10 +18,23 @@ import { cmdMergeSkill, cmdRenameSkill } from "./cli/commands/ownership.js";
|
||||
import {
|
||||
cmdExplorePackages,
|
||||
cmdGetPackageTrustedPublisher,
|
||||
cmdDownloadPackage,
|
||||
cmdInspectPackage,
|
||||
cmdInspectPackageClawPack,
|
||||
cmdDeletePackageTrustedPublisher,
|
||||
cmdPackageClawPackBackfill,
|
||||
cmdPackageClawPackIndexBackfill,
|
||||
cmdPackageClawPackMigrationDryRun,
|
||||
cmdPackageClawPackMigrationRunContinue,
|
||||
cmdPackageClawPackMigrationRunCreate,
|
||||
cmdPackageClawPackMigrationRuns,
|
||||
cmdPackageClawPackMigrationReadiness,
|
||||
cmdPackageClawPackMigrationStatus,
|
||||
cmdPackageClawPackRetryFailures,
|
||||
cmdPackageClawPackRevoke,
|
||||
cmdPublishPackage,
|
||||
cmdSetPackageTrustedPublisher,
|
||||
cmdVerifyPackageClawPack,
|
||||
} from "./cli/commands/packages.js";
|
||||
import { cmdPublish } from "./cli/commands/publish.js";
|
||||
import { cmdRescanPackage, cmdRescanSkill } from "./cli/commands/rescan.js";
|
||||
@@ -370,6 +383,8 @@ packageCmd
|
||||
.option("--family <family>", "skill|code-plugin|bundle-plugin")
|
||||
.option("--official", "Only official packages")
|
||||
.option("--executes-code", "Only packages that execute code")
|
||||
.option("--host-target <target>", "Filter by Claw Pack host target, e.g. darwin-arm64")
|
||||
.option("--environment <flag>", "Filter by Claw Pack environment flag, e.g. browser")
|
||||
.option(
|
||||
"--limit <n>",
|
||||
"Number of packages to show (max 100)",
|
||||
@@ -399,6 +414,177 @@ packageCmd
|
||||
await cmdInspectPackage(opts, name, options);
|
||||
});
|
||||
|
||||
packageCmd
|
||||
.command("download")
|
||||
.description("Download a package Claw Pack artifact")
|
||||
.argument("<name>", "Package name")
|
||||
.option("--version <version>", "Version to download")
|
||||
.option("--tag <tag>", "Tag to download")
|
||||
.option("-o, --output <path>", "Output path")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (name, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdDownloadPackage(opts, name, options);
|
||||
});
|
||||
|
||||
packageCmd
|
||||
.command("verify")
|
||||
.description("Verify a downloaded Claw Pack artifact")
|
||||
.argument("<file>", "Claw Pack ZIP path")
|
||||
.option("--sha256 <digest>", "Expected Claw Pack SHA-256")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (file, options) => {
|
||||
await cmdVerifyPackageClawPack(file, options);
|
||||
});
|
||||
|
||||
packageCmd
|
||||
.command("clawpack")
|
||||
.description("Download Claw Pack artifacts")
|
||||
.argument("[name]", "Package name")
|
||||
.option("--version <version>", "Version to download")
|
||||
.option("--tag <tag>", "Tag to download")
|
||||
.option("-o, --output <path>", "Output path")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (name, options) => {
|
||||
if (!name) {
|
||||
packageCmd.commands.find((command) => command.name() === "clawpack")?.help();
|
||||
return;
|
||||
}
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdDownloadPackage(opts, name, options);
|
||||
});
|
||||
|
||||
packageCmd
|
||||
.command("clawpack-inspect")
|
||||
.description("Inspect a remote Claw Pack artifact")
|
||||
.argument("<name>", "Package name")
|
||||
.option("--version <version>", "Version to inspect (default: latest)")
|
||||
.option("--manifest", "Print the generated CLAWPACK.json")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (name, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdInspectPackageClawPack(opts, name, options);
|
||||
});
|
||||
|
||||
const packageClawPackCmd = packageCmd
|
||||
.command("clawpack-admin")
|
||||
.description("Admin Claw Pack migration controls");
|
||||
|
||||
packageClawPackCmd
|
||||
.command("status")
|
||||
.description("Show Claw Pack migration status")
|
||||
.option("--limit <n>", "Sample limit", (value) => Number.parseInt(value, 10), 25)
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPackageClawPackMigrationStatus(opts, options);
|
||||
});
|
||||
|
||||
packageClawPackCmd
|
||||
.command("readiness")
|
||||
.description("Show official OpenClaw plugin migration readiness")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPackageClawPackMigrationReadiness(opts, options);
|
||||
});
|
||||
|
||||
packageClawPackCmd
|
||||
.command("dry-run")
|
||||
.description("Preview Claw Pack migration run candidates")
|
||||
.option(
|
||||
"--operation <operation>",
|
||||
"artifact-backfill, failure-retry, or search-index-backfill",
|
||||
"artifact-backfill",
|
||||
)
|
||||
.option("--limit <n>", "Sample size", (value) => Number.parseInt(value, 10), 10)
|
||||
.option("--cursor <cursor>", "Continue cursor for search-index-backfill")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPackageClawPackMigrationDryRun(opts, options);
|
||||
});
|
||||
|
||||
packageClawPackCmd
|
||||
.command("runs")
|
||||
.description("List Claw Pack migration run records")
|
||||
.option("--status <status>", "pending, running, completed, or failed")
|
||||
.option("--limit <n>", "Run limit", (value) => Number.parseInt(value, 10), 20)
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPackageClawPackMigrationRuns(opts, options);
|
||||
});
|
||||
|
||||
packageClawPackCmd
|
||||
.command("create-run")
|
||||
.description("Create a persistent Claw Pack migration run")
|
||||
.option(
|
||||
"--operation <operation>",
|
||||
"artifact-backfill, failure-retry, or search-index-backfill",
|
||||
"artifact-backfill",
|
||||
)
|
||||
.option("--limit <n>", "Batch size", (value) => Number.parseInt(value, 10), 10)
|
||||
.option("--cursor <cursor>", "Initial cursor for search-index-backfill")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPackageClawPackMigrationRunCreate(opts, options);
|
||||
});
|
||||
|
||||
packageClawPackCmd
|
||||
.command("continue-run")
|
||||
.description("Execute the next batch for a Claw Pack migration run")
|
||||
.argument("<run-id>", "Migration run id")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (runId, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPackageClawPackMigrationRunContinue(opts, runId, options);
|
||||
});
|
||||
|
||||
packageClawPackCmd
|
||||
.command("backfill")
|
||||
.description("Build Claw Pack artifacts for plugin releases")
|
||||
.option("--limit <n>", "Batch size", (value) => Number.parseInt(value, 10), 10)
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPackageClawPackBackfill(opts, options);
|
||||
});
|
||||
|
||||
packageClawPackCmd
|
||||
.command("retry-failures")
|
||||
.description("Retry failed Claw Pack artifact builds")
|
||||
.option("--limit <n>", "Batch size", (value) => Number.parseInt(value, 10), 10)
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPackageClawPackRetryFailures(opts, options);
|
||||
});
|
||||
|
||||
packageClawPackCmd
|
||||
.command("index-backfill")
|
||||
.description("Backfill Claw Pack host and environment lookup indexes")
|
||||
.option("--limit <n>", "Batch size", (value) => Number.parseInt(value, 10), 25)
|
||||
.option("--cursor <cursor>", "Continue cursor from the previous batch")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPackageClawPackIndexBackfill(opts, options);
|
||||
});
|
||||
|
||||
packageClawPackCmd
|
||||
.command("revoke")
|
||||
.description("Revoke a published Claw Pack artifact")
|
||||
.argument("<name>", "Package name")
|
||||
.argument("<version>", "Package version")
|
||||
.option("--reason <text>", "Moderation reason")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (name, version, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPackageClawPackRevoke(opts, name, version, options);
|
||||
});
|
||||
|
||||
packageCmd
|
||||
.command("publish")
|
||||
.description("Publish a code plugin or bundle plugin from a folder or GitHub source")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { zipSync } from "fflate";
|
||||
@@ -28,11 +29,24 @@ vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const {
|
||||
cmdDeletePackageTrustedPublisher,
|
||||
cmdDownloadPackage,
|
||||
cmdExplorePackages,
|
||||
cmdGetPackageTrustedPublisher,
|
||||
cmdInspectPackage,
|
||||
cmdPackageClawPackBackfill,
|
||||
cmdPackageClawPackIndexBackfill,
|
||||
cmdPackageClawPackMigrationDryRun,
|
||||
cmdPackageClawPackMigrationReadiness,
|
||||
cmdPackageClawPackMigrationRunContinue,
|
||||
cmdPackageClawPackMigrationRunCreate,
|
||||
cmdPackageClawPackMigrationRuns,
|
||||
cmdPackageClawPackMigrationStatus,
|
||||
cmdPackageClawPackRetryFailures,
|
||||
cmdPackageClawPackRevoke,
|
||||
cmdInspectPackageClawPack,
|
||||
cmdPublishPackage,
|
||||
cmdSetPackageTrustedPublisher,
|
||||
cmdVerifyPackageClawPack,
|
||||
} = await import("./packages");
|
||||
|
||||
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
@@ -137,6 +151,8 @@ describe("package commands", () => {
|
||||
await cmdExplorePackages(makeOpts(), "demo plugin", {
|
||||
family: "code-plugin",
|
||||
executesCode: true,
|
||||
hostTarget: "darwin-arm64",
|
||||
environment: "browser",
|
||||
});
|
||||
|
||||
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
@@ -145,6 +161,8 @@ describe("package commands", () => {
|
||||
expect(url.searchParams.get("q")).toBe("demo plugin");
|
||||
expect(url.searchParams.get("family")).toBe("code-plugin");
|
||||
expect(url.searchParams.get("executesCode")).toBe("true");
|
||||
expect(url.searchParams.get("hostTarget")).toBe("darwin-arm64");
|
||||
expect(url.searchParams.get("environment")).toBe("browser");
|
||||
});
|
||||
|
||||
it("supports skill family package browse requests", async () => {
|
||||
@@ -153,12 +171,19 @@ describe("package commands", () => {
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
await cmdExplorePackages(makeOpts(), "", { family: "skill", limit: 7 });
|
||||
await cmdExplorePackages(makeOpts(), "", {
|
||||
family: "skill",
|
||||
hostTarget: "linux-x64-glibc",
|
||||
environment: "desktop",
|
||||
limit: 7,
|
||||
});
|
||||
|
||||
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
const url = new URL(String(request?.url));
|
||||
expect(url.pathname).toBe("/api/v1/packages");
|
||||
expect(url.searchParams.get("family")).toBe("skill");
|
||||
expect(url.searchParams.get("hostTarget")).toBe("linux-x64-glibc");
|
||||
expect(url.searchParams.get("environment")).toBe("desktop");
|
||||
expect(url.searchParams.get("limit")).toBe("7");
|
||||
});
|
||||
|
||||
@@ -207,6 +232,483 @@ describe("package commands", () => {
|
||||
expect(url.searchParams.get("version")).toBeNull();
|
||||
});
|
||||
|
||||
it("prints Claw Pack metadata while inspecting a package", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
package: {
|
||||
name: "demo",
|
||||
displayName: "Demo",
|
||||
family: "code-plugin",
|
||||
runtimeId: "demo.plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
summary: null,
|
||||
latestVersion: "2.0.0",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
tags: { latest: "2.0.0" },
|
||||
compatibility: null,
|
||||
capabilities: { executesCode: true },
|
||||
verification: {
|
||||
tier: "structural",
|
||||
scope: "artifact-only",
|
||||
},
|
||||
clawpack: {
|
||||
available: true,
|
||||
specVersion: 1,
|
||||
format: "zip",
|
||||
sha256: "a".repeat(64),
|
||||
size: 123,
|
||||
fileCount: 3,
|
||||
manifestSha256: "b".repeat(64),
|
||||
builtAt: 1_763_000_000_000,
|
||||
buildVersion: "clawhub-clawpack-v1",
|
||||
hostTargets: [
|
||||
{ os: "darwin", arch: "arm64", supportState: "supported" },
|
||||
{ os: "linux", arch: "x64", libc: "glibc", supportState: "supported" },
|
||||
],
|
||||
environment: { requiresNetwork: true },
|
||||
runtimeBundles: [],
|
||||
},
|
||||
},
|
||||
owner: null,
|
||||
});
|
||||
|
||||
await cmdInspectPackage(makeOpts(), "demo", {});
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith("Claw Pack: available");
|
||||
expect(mockLog).toHaveBeenCalledWith(`Claw Pack SHA-256: ${"a".repeat(64)}`);
|
||||
expect(mockLog).toHaveBeenCalledWith("Claw Pack Targets: darwin-arm64, linux-x64-glibc");
|
||||
});
|
||||
|
||||
it("inspects remote Claw Pack metadata by package version", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
package: {
|
||||
name: "@openclaw/kitchen-sink",
|
||||
displayName: "Kitchen Sink",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: { version: "1.0.0", createdAt: 1 },
|
||||
clawpack: {
|
||||
available: true,
|
||||
specVersion: 1,
|
||||
format: "zip",
|
||||
sha256: "a".repeat(64),
|
||||
size: 123,
|
||||
fileCount: 3,
|
||||
manifestSha256: "b".repeat(64),
|
||||
builtAt: 1_763_000_000_000,
|
||||
buildVersion: "clawhub-clawpack-v1",
|
||||
hostTargets: [{ os: "darwin", arch: "arm64", supportState: "supported" }],
|
||||
environment: { requiresNetwork: true },
|
||||
runtimeBundles: [],
|
||||
},
|
||||
links: {
|
||||
download: "/api/v1/packages/%40openclaw%2Fkitchen-sink/download?version=1.0.0",
|
||||
immutable: `/api/v1/clawpacks/${"a".repeat(64)}`,
|
||||
manifest: "/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/manifest",
|
||||
},
|
||||
});
|
||||
|
||||
await cmdInspectPackageClawPack(makeOpts(), "@openclaw/kitchen-sink", { version: "1.0.0" });
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith("@openclaw/kitchen-sink@1.0.0");
|
||||
expect(mockLog).toHaveBeenCalledWith(`Claw Pack SHA-256: ${"a".repeat(64)}`);
|
||||
});
|
||||
|
||||
it("prints remote Claw Pack manifests and resolves latest versions", async () => {
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
package: {
|
||||
name: "demo",
|
||||
displayName: "Demo",
|
||||
family: "code-plugin",
|
||||
runtimeId: "demo.plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
summary: null,
|
||||
latestVersion: "2.0.0",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
tags: { latest: "2.0.0" },
|
||||
compatibility: null,
|
||||
capabilities: { executesCode: true },
|
||||
verification: {
|
||||
tier: "structural",
|
||||
scope: "artifact-only",
|
||||
},
|
||||
},
|
||||
owner: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
package: { name: "demo", displayName: "Demo", family: "code-plugin" },
|
||||
version: "2.0.0",
|
||||
clawpack: {
|
||||
available: true,
|
||||
specVersion: 1,
|
||||
format: "zip",
|
||||
sha256: "c".repeat(64),
|
||||
size: 123,
|
||||
fileCount: 3,
|
||||
manifestSha256: "d".repeat(64),
|
||||
builtAt: 1_763_000_000_000,
|
||||
buildVersion: "clawhub-clawpack-v1",
|
||||
hostTargets: [],
|
||||
environment: null,
|
||||
runtimeBundles: [],
|
||||
},
|
||||
manifest: { kind: "openclaw.clawpack", specVersion: 1 },
|
||||
});
|
||||
|
||||
await cmdInspectPackageClawPack(makeOpts(), "demo", { manifest: true });
|
||||
|
||||
const manifestCall = httpMocks.apiRequest.mock.calls[1];
|
||||
if (!manifestCall) throw new Error("Missing Claw Pack manifest request");
|
||||
const manifestRequest = manifestCall[1] as { path?: string };
|
||||
expect(manifestRequest.path).toBe("/api/v1/packages/demo/versions/2.0.0/clawpack/manifest");
|
||||
expect(mockWrite.mock.calls.map((call) => String(call[0])).join("")).toContain(
|
||||
`"kind": "openclaw.clawpack"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("downloads a Claw Pack package archive", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
const bytes = new Uint8Array([1, 2, 3, 4]);
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
"content-disposition": 'attachment; filename="demo.clawpack.zip"',
|
||||
"x-clawhub-clawpack-sha256": "c".repeat(64),
|
||||
"x-clawhub-clawpack-spec-version": "1",
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
try {
|
||||
await cmdDownloadPackage(makeOpts(workdir), "demo", { json: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL("https://clawhub.ai/api/v1/packages/demo/download"),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Accept: "application/zip" }),
|
||||
}),
|
||||
);
|
||||
expect(await readFile(join(workdir, "demo.clawpack.zip"))).toEqual(Buffer.from(bytes));
|
||||
expect(mockWrite.mock.calls.map((call) => String(call[0])).join("")).toContain(
|
||||
`"clawpackSha256": "${"c".repeat(64)}"`,
|
||||
);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("verifies Claw Pack archives and rejects digest mismatches", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const zip = zipSync({
|
||||
"package/CLAWPACK.json": new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
specVersion: 1,
|
||||
package: { name: "demo", version: "1.0.0" },
|
||||
}),
|
||||
),
|
||||
"package/package.json": new TextEncoder().encode("{}"),
|
||||
});
|
||||
const file = join(workdir, "demo.clawpack.zip");
|
||||
await writeFile(file, zip);
|
||||
const sha256 = createHash("sha256").update(zip).digest("hex");
|
||||
|
||||
await cmdVerifyPackageClawPack(file, { sha256, json: true });
|
||||
expect(mockWrite.mock.calls.map((call) => String(call[0])).join("")).toContain(`"ok": true`);
|
||||
await expect(cmdVerifyPackageClawPack(file, { sha256: "0".repeat(64) })).rejects.toThrow(
|
||||
"Claw Pack digest mismatch",
|
||||
);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fetches Claw Pack migration status for admins", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
missingSample: [],
|
||||
missingSampleSize: 0,
|
||||
generatedClawPackSampleSize: 2,
|
||||
generatedClawPackBytes: 2048,
|
||||
sampleLimit: 25,
|
||||
});
|
||||
|
||||
await cmdPackageClawPackMigrationStatus(makeOpts(), { limit: 25, json: true });
|
||||
|
||||
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as
|
||||
| { method?: string; url?: string; token?: string }
|
||||
| undefined;
|
||||
expect(request?.method).toBe("GET");
|
||||
expect(request?.token).toBe("tkn");
|
||||
const url = new URL(String(request?.url));
|
||||
expect(url.pathname).toBe("/api/v1/packages/clawpack/migration-status");
|
||||
expect(url.searchParams.get("limit")).toBe("25");
|
||||
expect(mockWrite.mock.calls.map((call) => String(call[0])).join("")).toContain(
|
||||
"generatedClawPackSampleSize",
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches official plugin migration readiness for admins", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
bundledPluginId: "opik",
|
||||
desiredPackageName: "@opik/opik-openclaw",
|
||||
readinessState: "clawpack-missing",
|
||||
blockers: ["clawpack-missing"],
|
||||
},
|
||||
],
|
||||
readyCount: 0,
|
||||
blockedCount: 1,
|
||||
generatedAt: 1,
|
||||
});
|
||||
|
||||
await cmdPackageClawPackMigrationReadiness(makeOpts());
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/v1/packages/clawpack/migration-readiness",
|
||||
token: "tkn",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith("Claw Pack migration readiness");
|
||||
expect(mockLog).toHaveBeenCalledWith("Ready: 0");
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"opik: clawpack-missing -> @opik/opik-openclaw [clawpack-missing]",
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches Claw Pack migration dry-run candidates for admins", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
operation: "artifact-backfill",
|
||||
candidateCount: 1,
|
||||
failureCount: 0,
|
||||
isDone: false,
|
||||
candidates: [{ name: "demo-plugin", version: "1.0.0" }],
|
||||
});
|
||||
|
||||
await cmdPackageClawPackMigrationDryRun(makeOpts(), {
|
||||
operation: "artifact-backfill",
|
||||
limit: 5,
|
||||
json: true,
|
||||
});
|
||||
|
||||
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as
|
||||
| { method?: string; url?: string; token?: string }
|
||||
| undefined;
|
||||
expect(request?.method).toBe("GET");
|
||||
expect(request?.token).toBe("tkn");
|
||||
const url = new URL(String(request?.url));
|
||||
expect(url.pathname).toBe("/api/v1/packages/clawpack/migration-runs/dry-run");
|
||||
expect(url.searchParams.get("operation")).toBe("artifact-backfill");
|
||||
expect(url.searchParams.get("limit")).toBe("5");
|
||||
expect(mockWrite.mock.calls.map((call) => String(call[0])).join("")).toContain(
|
||||
"candidateCount",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists Claw Pack migration runs for admins", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
_id: "clawPackMigrationRuns:1",
|
||||
operation: "failure-retry",
|
||||
status: "pending",
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await cmdPackageClawPackMigrationRuns(makeOpts(), { status: "pending", limit: 10 });
|
||||
|
||||
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as
|
||||
| { method?: string; url?: string; token?: string }
|
||||
| undefined;
|
||||
expect(request?.method).toBe("GET");
|
||||
const url = new URL(String(request?.url));
|
||||
expect(url.pathname).toBe("/api/v1/packages/clawpack/migration-runs");
|
||||
expect(url.searchParams.get("status")).toBe("pending");
|
||||
expect(url.searchParams.get("limit")).toBe("10");
|
||||
expect(mockLog).toHaveBeenCalledWith("Claw Pack migration runs");
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
"clawPackMigrationRuns:1 failure-retry pending processed=0 failed=0 cursor=unknown",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates and continues Claw Pack migration runs for admins", async () => {
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
_id: "clawPackMigrationRuns:1",
|
||||
operation: "search-index-backfill",
|
||||
status: "pending",
|
||||
processed: 0,
|
||||
failed: 0,
|
||||
cursor: "cursor:1",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
run: {
|
||||
_id: "clawPackMigrationRuns:1",
|
||||
operation: "search-index-backfill",
|
||||
status: "completed",
|
||||
processed: 2,
|
||||
failed: 0,
|
||||
},
|
||||
result: { processed: 2, succeeded: 2, failed: 0 },
|
||||
});
|
||||
|
||||
await cmdPackageClawPackMigrationRunCreate(makeOpts(), {
|
||||
operation: "search-index-backfill",
|
||||
limit: 2,
|
||||
cursor: "cursor:1",
|
||||
});
|
||||
await cmdPackageClawPackMigrationRunContinue(makeOpts(), "clawPackMigrationRuns:1");
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://clawhub.ai",
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/packages/clawpack/migration-runs",
|
||||
token: "tkn",
|
||||
body: { operation: "search-index-backfill", limit: 2, cursor: "cursor:1" },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(httpMocks.apiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://clawhub.ai",
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/packages/clawpack/migration-runs/clawPackMigrationRuns%3A1/continue",
|
||||
token: "tkn",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith("Claw Pack migration run created");
|
||||
expect(mockLog).toHaveBeenCalledWith("Claw Pack migration run continued");
|
||||
expect(mockLog).toHaveBeenCalledWith("Processed: 2");
|
||||
});
|
||||
|
||||
it("runs Claw Pack backfill batches for admins", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
processed: 2,
|
||||
succeeded: 2,
|
||||
failed: 0,
|
||||
results: [],
|
||||
});
|
||||
|
||||
await cmdPackageClawPackBackfill(makeOpts(), { limit: 2 });
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/packages/clawpack/backfill",
|
||||
token: "tkn",
|
||||
body: { limit: 2 },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith("Claw Pack backfill");
|
||||
expect(mockLog).toHaveBeenCalledWith("Succeeded: 2");
|
||||
});
|
||||
|
||||
it("runs Claw Pack index backfill batches for admins", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
processed: 2,
|
||||
succeeded: 2,
|
||||
failed: 0,
|
||||
results: [],
|
||||
continueCursor: "cursor:2",
|
||||
isDone: false,
|
||||
});
|
||||
|
||||
await cmdPackageClawPackIndexBackfill(makeOpts(), { limit: 2, cursor: "cursor:1" });
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/packages/clawpack/index-backfill",
|
||||
token: "tkn",
|
||||
body: { limit: 2, cursor: "cursor:1" },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith("Claw Pack index backfill");
|
||||
expect(mockLog).toHaveBeenCalledWith("Next cursor: cursor:2");
|
||||
});
|
||||
|
||||
it("retries failed Claw Pack backfill batches for admins", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
processed: 1,
|
||||
succeeded: 1,
|
||||
failed: 0,
|
||||
results: [],
|
||||
});
|
||||
|
||||
await cmdPackageClawPackRetryFailures(makeOpts(), { limit: 1 });
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/packages/clawpack/retry-failures",
|
||||
token: "tkn",
|
||||
body: { limit: 1 },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith("Claw Pack failure retry");
|
||||
expect(mockLog).toHaveBeenCalledWith("Processed: 1");
|
||||
expect(mockLog).toHaveBeenCalledWith("Succeeded: 1");
|
||||
expect(mockLog).toHaveBeenCalledWith("Failed: 0");
|
||||
});
|
||||
|
||||
it("revokes Claw Pack artifacts for moderators", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "packages:1",
|
||||
releaseId: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
sha256: "d".repeat(64),
|
||||
revokedArtifactCount: 1,
|
||||
});
|
||||
|
||||
await cmdPackageClawPackRevoke(makeOpts(), "@openclaw/kitchen-sink", "1.0.0", {
|
||||
reason: "malware confirmed",
|
||||
});
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai",
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/revoke",
|
||||
token: "tkn",
|
||||
body: { reason: "malware confirmed" },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith("Claw Pack revoked");
|
||||
expect(mockLog).toHaveBeenCalledWith(`SHA-256: ${"d".repeat(64)}`);
|
||||
expect(mockLog).toHaveBeenCalledWith("Revoked artifacts: 1");
|
||||
});
|
||||
|
||||
it("publishes a code plugin package with an exact explicit payload", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(123_456_789);
|
||||
@@ -279,6 +781,66 @@ describe("package commands", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes a code plugin package from a zip archive", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
let dateSpy: { mockRestore: () => void } | undefined;
|
||||
try {
|
||||
const archive = join(workdir, "demo-plugin.zip");
|
||||
const archiveBytes = zipSync({
|
||||
"demo-plugin/package.json": new TextEncoder().encode(
|
||||
makeCodePluginPackageJson({
|
||||
name: "@scope/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
),
|
||||
"demo-plugin/openclaw.plugin.json": new TextEncoder().encode(
|
||||
JSON.stringify({ id: "demo.plugin" }),
|
||||
),
|
||||
"demo-plugin/dist/index.js": new TextEncoder().encode("export const demo = true;\n"),
|
||||
});
|
||||
await writeFile(archive, archiveBytes);
|
||||
dateSpy = vi.spyOn(Date, "now").mockReturnValue(123_456_789);
|
||||
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "pkg_archive",
|
||||
releaseId: "rel_archive",
|
||||
});
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-plugin.zip", {
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
sourceCommit: "abc123",
|
||||
});
|
||||
|
||||
expect(getPublishPayload()).toMatchObject({
|
||||
name: "@scope/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.0.0",
|
||||
source: {
|
||||
kind: "github",
|
||||
url: "https://github.com/openclaw/demo-plugin",
|
||||
repo: "openclaw/demo-plugin",
|
||||
commit: "abc123",
|
||||
path: ".",
|
||||
importedAt: 123_456_789,
|
||||
},
|
||||
});
|
||||
expect(getUploadedFileNames()).toEqual([
|
||||
"dist/index.js",
|
||||
"openclaw.plugin.json",
|
||||
"package.json",
|
||||
]);
|
||||
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
|
||||
"OK. Published @scope/demo-plugin@1.0.0 (rel_archive)",
|
||||
);
|
||||
} finally {
|
||||
dateSpy?.mockRestore();
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("mints a short-lived publish token from GitHub Actions OIDC in CI", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { basename, join, relative, resolve, sep } from "node:path";
|
||||
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 { gunzipSync, unzipSync } from "fflate";
|
||||
import ignore from "ignore";
|
||||
import mime from "mime";
|
||||
import semver from "semver";
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
type PackageCapabilitySummary,
|
||||
type PackageCompatibility,
|
||||
type PackageFamily,
|
||||
type PackageClawPackSummary,
|
||||
type PackageTrustedPublisher,
|
||||
type PackageVerificationSummary,
|
||||
validateOpenClawExternalCodePluginPackageJson,
|
||||
@@ -53,6 +57,8 @@ type PackageExploreOptions = {
|
||||
family?: PackageFamily;
|
||||
official?: boolean;
|
||||
executesCode?: boolean;
|
||||
hostTarget?: string;
|
||||
environment?: string;
|
||||
limit?: number;
|
||||
json?: boolean;
|
||||
};
|
||||
@@ -76,6 +82,40 @@ type PackagePublishOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type PackageDownloadOptions = {
|
||||
version?: string;
|
||||
tag?: string;
|
||||
output?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type PackageClawPackInspectOptions = {
|
||||
version?: string;
|
||||
manifest?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type PackageVerifyOptions = {
|
||||
sha256?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type PackageClawPackMigrationOptions = {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type PackageClawPackMigrationRunOptions = PackageClawPackMigrationOptions & {
|
||||
operation?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type PackageClawPackRevokeOptions = {
|
||||
reason?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type PackageTrustedPublisherGetOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
@@ -97,6 +137,18 @@ type PackageFile = {
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
type PackageClawPackInspectResponse = {
|
||||
package: { name: string; displayName: string; family: PackageFamily };
|
||||
version: string | { version: string; createdAt?: number };
|
||||
clawpack: PackageClawPackSummary;
|
||||
links?: {
|
||||
download?: string;
|
||||
immutable?: string | null;
|
||||
manifest?: string;
|
||||
};
|
||||
manifest?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type InferredPublishSource = {
|
||||
repo?: string;
|
||||
commit?: string;
|
||||
@@ -172,6 +224,8 @@ export async function cmdExplorePackages(
|
||||
if (typeof options.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(options.executesCode));
|
||||
}
|
||||
if (options.hostTarget) url.searchParams.set("hostTarget", options.hostTarget);
|
||||
if (options.environment) url.searchParams.set("environment", options.environment);
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{ method: "GET", url: url.toString(), token },
|
||||
@@ -205,6 +259,8 @@ export async function cmdExplorePackages(
|
||||
if (typeof options.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(options.executesCode));
|
||||
}
|
||||
if (options.hostTarget) url.searchParams.set("hostTarget", options.hostTarget);
|
||||
if (options.environment) url.searchParams.set("environment", options.environment);
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{ method: "GET", url: url.toString(), token },
|
||||
@@ -310,12 +366,14 @@ export async function cmdInspectPackage(
|
||||
|
||||
if (shouldPrintMeta && versionResult?.version) {
|
||||
printVersionSummary(versionResult.version);
|
||||
printClawPack(versionResult.version.clawpack);
|
||||
printCompatibility(
|
||||
versionResult.version.compatibility ?? detail.package.compatibility ?? null,
|
||||
);
|
||||
printCapabilities(versionResult.version.capabilities ?? detail.package.capabilities ?? null);
|
||||
printVerification(versionResult.version.verification ?? detail.package.verification ?? null);
|
||||
} else if (shouldPrintMeta) {
|
||||
printClawPack(detail.package.clawpack);
|
||||
printCompatibility(detail.package.compatibility ?? null);
|
||||
printCapabilities(detail.package.capabilities ?? null);
|
||||
printVerification(detail.package.verification ?? null);
|
||||
@@ -536,6 +594,436 @@ export async function cmdPublishPackage(
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdDownloadPackage(
|
||||
opts: GlobalOpts,
|
||||
packageName: string,
|
||||
options: PackageDownloadOptions = {},
|
||||
) {
|
||||
const trimmed = normalizePackageNameOrFail(packageName);
|
||||
if (options.version && options.tag) fail("Use either --version or --tag");
|
||||
const token = await getOptionalAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const url = registryUrl(
|
||||
`${ApiRoutes.packages}/${encodeURIComponent(trimmed)}/download`,
|
||||
registry,
|
||||
);
|
||||
if (options.version) url.searchParams.set("version", options.version);
|
||||
if (options.tag) url.searchParams.set("tag", options.tag);
|
||||
const spinner = options.json ? null : createSpinner("Downloading Claw Pack");
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/zip",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error((await response.text()) || `Download failed (${response.status})`);
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const sha256 = sha256Hex(bytes);
|
||||
const filename =
|
||||
options.output?.trim() ||
|
||||
filenameFromContentDisposition(response.headers.get("content-disposition")) ||
|
||||
`${trimmed.replaceAll("/", "-")}.clawpack.zip`;
|
||||
const outputPath = resolve(opts.workdir, filename);
|
||||
await mkdir(resolve(outputPath, ".."), { recursive: true }).catch(() => undefined);
|
||||
await writeFile(outputPath, bytes);
|
||||
spinner?.succeed(`Downloaded ${outputPath}`);
|
||||
const result = {
|
||||
path: outputPath,
|
||||
bytes: bytes.byteLength,
|
||||
sha256,
|
||||
clawpackSha256: response.headers.get("x-clawhub-clawpack-sha256"),
|
||||
specVersion: response.headers.get("x-clawhub-clawpack-spec-version"),
|
||||
};
|
||||
if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
} catch (error) {
|
||||
spinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePackageClawPackVersion(
|
||||
registry: string,
|
||||
packageName: string,
|
||||
token: string | null,
|
||||
options: PackageClawPackInspectOptions,
|
||||
) {
|
||||
if (options.version?.trim()) return options.version.trim();
|
||||
const detail = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "GET",
|
||||
path: `${ApiRoutes.packages}/${encodeURIComponent(packageName)}`,
|
||||
...(token ? { token } : {}),
|
||||
},
|
||||
ApiV1PackageResponseSchema,
|
||||
);
|
||||
if (!detail.package) fail(`Package not found: ${packageName}`);
|
||||
const version = detail.package.latestVersion;
|
||||
if (!version) fail(`Package has no published versions: ${packageName}`);
|
||||
return version;
|
||||
}
|
||||
|
||||
export async function cmdInspectPackageClawPack(
|
||||
opts: GlobalOpts,
|
||||
packageName: string,
|
||||
options: PackageClawPackInspectOptions = {},
|
||||
) {
|
||||
const trimmed = normalizePackageNameOrFail(packageName);
|
||||
const token = (await getOptionalAuthToken()) ?? null;
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const version = await resolvePackageClawPackVersion(registry, trimmed, token, options);
|
||||
const response = await apiRequest<PackageClawPackInspectResponse>(registry, {
|
||||
method: "GET",
|
||||
path: `${ApiRoutes.packages}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(version)}/clawpack${
|
||||
options.manifest ? "/manifest" : ""
|
||||
}`,
|
||||
...(token ? { token } : {}),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.manifest) {
|
||||
process.stdout.write(`${JSON.stringify(response.manifest ?? {}, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const responseVersion =
|
||||
typeof response.version === "string" ? response.version : response.version.version;
|
||||
console.log(`${response.package.name}@${responseVersion}`);
|
||||
printClawPack(response.clawpack);
|
||||
if (response.links?.download) console.log(`Claw Pack Download: ${response.links.download}`);
|
||||
if (response.links?.immutable)
|
||||
console.log(`Claw Pack Immutable URL: ${response.links.immutable}`);
|
||||
if (response.links?.manifest) console.log(`Claw Pack Manifest URL: ${response.links.manifest}`);
|
||||
}
|
||||
|
||||
export async function cmdVerifyPackageClawPack(
|
||||
filePath: string,
|
||||
options: PackageVerifyOptions = {},
|
||||
) {
|
||||
const bytes = new Uint8Array(await readFile(resolve(filePath)));
|
||||
const sha256 = sha256Hex(bytes);
|
||||
const zipEntries = unzipSync(bytes);
|
||||
const manifestBytes = zipEntries["package/CLAWPACK.json"];
|
||||
if (!manifestBytes) fail("Missing package/CLAWPACK.json");
|
||||
const manifestText = new TextDecoder().decode(manifestBytes);
|
||||
const manifest = JSON.parse(manifestText) as Record<string, unknown>;
|
||||
const expected = options.sha256?.trim();
|
||||
const ok = expected ? sha256 === expected : true;
|
||||
const result = {
|
||||
ok,
|
||||
sha256,
|
||||
expectedSha256: expected || null,
|
||||
specVersion: manifest.specVersion ?? null,
|
||||
package: manifest.package ?? null,
|
||||
fileCount: Object.keys(zipEntries).length,
|
||||
};
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
console.log(`Claw Pack: ${ok ? "ok" : "mismatch"}`);
|
||||
console.log(`SHA-256: ${sha256}`);
|
||||
if (expected) console.log(`Expected: ${expected}`);
|
||||
console.log(`Spec: ${formatUnknownScalar(manifest.specVersion)}`);
|
||||
if (!ok) fail("Claw Pack digest mismatch");
|
||||
}
|
||||
|
||||
export async function cmdPackageClawPackMigrationStatus(
|
||||
opts: GlobalOpts,
|
||||
options: PackageClawPackMigrationOptions = {},
|
||||
) {
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const url = registryUrl(`${ApiRoutes.packages}/clawpack/migration-status`, registry);
|
||||
if (typeof options.limit === "number" && Number.isFinite(options.limit)) {
|
||||
url.searchParams.set("limit", String(options.limit));
|
||||
}
|
||||
const result = await apiRequest<Record<string, unknown>>(registry, {
|
||||
method: "GET",
|
||||
url: url.toString(),
|
||||
token,
|
||||
});
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
console.log("Claw Pack migration");
|
||||
console.log(`Missing sample: ${formatUnknownScalar(result.missingSampleSize)}`);
|
||||
console.log(`Open failures: ${formatUnknownScalar(result.failureSampleSize)}`);
|
||||
console.log(`Generated sample: ${formatUnknownScalar(result.generatedClawPackSampleSize)}`);
|
||||
console.log(`Generated bytes: ${formatUnknownScalar(result.generatedClawPackBytes)}`);
|
||||
}
|
||||
|
||||
export async function cmdPackageClawPackMigrationReadiness(
|
||||
opts: GlobalOpts,
|
||||
options: Pick<PackageClawPackMigrationOptions, "json"> = {},
|
||||
) {
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const result = await apiRequest<{
|
||||
items?: Array<{
|
||||
bundledPluginId?: string;
|
||||
displayName?: string;
|
||||
desiredPackageName?: string;
|
||||
readinessState?: string;
|
||||
blockers?: string[];
|
||||
}>;
|
||||
readyCount?: number;
|
||||
blockedCount?: number;
|
||||
generatedAt?: number;
|
||||
}>(registry, {
|
||||
method: "GET",
|
||||
path: `${ApiRoutes.packages}/clawpack/migration-readiness`,
|
||||
token,
|
||||
});
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
console.log("Claw Pack migration readiness");
|
||||
console.log(`Ready: ${formatUnknownScalar(result.readyCount)}`);
|
||||
console.log(`Blocked: ${formatUnknownScalar(result.blockedCount)}`);
|
||||
for (const item of result.items ?? []) {
|
||||
const blockers = item.blockers?.length ? ` [${item.blockers.join(", ")}]` : "";
|
||||
console.log(
|
||||
`${item.bundledPluginId ?? "unknown"}: ${item.readinessState ?? "unknown"} -> ${
|
||||
item.desiredPackageName ?? item.displayName ?? "unknown"
|
||||
}${blockers}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdPackageClawPackMigrationDryRun(
|
||||
opts: GlobalOpts,
|
||||
options: PackageClawPackMigrationRunOptions = {},
|
||||
) {
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const url = registryUrl(`${ApiRoutes.packages}/clawpack/migration-runs/dry-run`, registry);
|
||||
url.searchParams.set("operation", options.operation?.trim() || "artifact-backfill");
|
||||
if (typeof options.limit === "number" && Number.isFinite(options.limit)) {
|
||||
url.searchParams.set("limit", String(options.limit));
|
||||
}
|
||||
if (options.cursor?.trim()) url.searchParams.set("cursor", options.cursor.trim());
|
||||
const result = await apiRequest<Record<string, unknown>>(registry, {
|
||||
method: "GET",
|
||||
url: url.toString(),
|
||||
token,
|
||||
});
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
console.log("Claw Pack migration dry-run");
|
||||
console.log(`Operation: ${formatUnknownScalar(result.operation)}`);
|
||||
console.log(`Candidates: ${formatUnknownScalar(result.candidateCount)}`);
|
||||
console.log(`Open failures: ${formatUnknownScalar(result.failureCount)}`);
|
||||
console.log(`Next cursor: ${formatUnknownScalar(result.continueCursor)}`);
|
||||
console.log(`Done: ${formatUnknownScalar(result.isDone)}`);
|
||||
}
|
||||
|
||||
export async function cmdPackageClawPackMigrationRuns(
|
||||
opts: GlobalOpts,
|
||||
options: PackageClawPackMigrationRunOptions = {},
|
||||
) {
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const url = registryUrl(`${ApiRoutes.packages}/clawpack/migration-runs`, registry);
|
||||
if (typeof options.limit === "number" && Number.isFinite(options.limit)) {
|
||||
url.searchParams.set("limit", String(options.limit));
|
||||
}
|
||||
if (options.status?.trim()) url.searchParams.set("status", options.status.trim());
|
||||
const result = await apiRequest<
|
||||
{ items?: Array<Record<string, unknown>> } & Record<string, unknown>
|
||||
>(registry, {
|
||||
method: "GET",
|
||||
url: url.toString(),
|
||||
token,
|
||||
});
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
console.log("Claw Pack migration runs");
|
||||
for (const run of result.items ?? []) {
|
||||
console.log(formatClawPackMigrationRunLine(run));
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdPackageClawPackMigrationRunCreate(
|
||||
opts: GlobalOpts,
|
||||
options: PackageClawPackMigrationRunOptions = {},
|
||||
) {
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const result = await apiRequest<Record<string, unknown>>(registry, {
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.packages}/clawpack/migration-runs`,
|
||||
token,
|
||||
body: {
|
||||
operation: options.operation?.trim() || "artifact-backfill",
|
||||
...(typeof options.limit === "number" && Number.isFinite(options.limit)
|
||||
? { limit: options.limit }
|
||||
: {}),
|
||||
...(options.cursor?.trim() ? { cursor: options.cursor.trim() } : {}),
|
||||
},
|
||||
});
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
console.log("Claw Pack migration run created");
|
||||
console.log(formatClawPackMigrationRunLine(result));
|
||||
}
|
||||
|
||||
export async function cmdPackageClawPackMigrationRunContinue(
|
||||
opts: GlobalOpts,
|
||||
runId: string,
|
||||
options: Pick<PackageClawPackMigrationRunOptions, "json"> = {},
|
||||
) {
|
||||
const trimmedRunId = runId.trim();
|
||||
if (!trimmedRunId) fail("Run id required");
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const result = await apiRequest<{
|
||||
run?: Record<string, unknown> | null;
|
||||
result?: Record<string, unknown> | null;
|
||||
error?: string;
|
||||
}>(registry, {
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.packages}/clawpack/migration-runs/${encodeURIComponent(trimmedRunId)}/continue`,
|
||||
token,
|
||||
});
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
console.log("Claw Pack migration run continued");
|
||||
if (result.run) console.log(formatClawPackMigrationRunLine(result.run));
|
||||
if (result.result) {
|
||||
console.log(`Processed: ${formatUnknownScalar(result.result.processed)}`);
|
||||
console.log(`Succeeded: ${formatUnknownScalar(result.result.succeeded)}`);
|
||||
console.log(`Failed: ${formatUnknownScalar(result.result.failed)}`);
|
||||
console.log(`Next cursor: ${formatUnknownScalar(result.result.continueCursor)}`);
|
||||
}
|
||||
if (result.error) console.log(`Error: ${result.error}`);
|
||||
}
|
||||
|
||||
export async function cmdPackageClawPackBackfill(
|
||||
opts: GlobalOpts,
|
||||
options: PackageClawPackMigrationOptions = {},
|
||||
) {
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const result = await apiRequest<Record<string, unknown>>(registry, {
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.packages}/clawpack/backfill`,
|
||||
token,
|
||||
body:
|
||||
typeof options.limit === "number" && Number.isFinite(options.limit)
|
||||
? { limit: options.limit }
|
||||
: {},
|
||||
});
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
console.log("Claw Pack backfill");
|
||||
console.log(`Processed: ${formatUnknownScalar(result.processed)}`);
|
||||
console.log(`Succeeded: ${formatUnknownScalar(result.succeeded)}`);
|
||||
console.log(`Failed: ${formatUnknownScalar(result.failed)}`);
|
||||
}
|
||||
|
||||
export async function cmdPackageClawPackRetryFailures(
|
||||
opts: GlobalOpts,
|
||||
options: PackageClawPackMigrationOptions = {},
|
||||
) {
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const result = await apiRequest<Record<string, unknown>>(registry, {
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.packages}/clawpack/retry-failures`,
|
||||
token,
|
||||
body:
|
||||
typeof options.limit === "number" && Number.isFinite(options.limit)
|
||||
? { limit: options.limit }
|
||||
: {},
|
||||
});
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
console.log("Claw Pack failure retry");
|
||||
console.log(`Processed: ${formatUnknownScalar(result.processed)}`);
|
||||
console.log(`Succeeded: ${formatUnknownScalar(result.succeeded)}`);
|
||||
console.log(`Failed: ${formatUnknownScalar(result.failed)}`);
|
||||
}
|
||||
|
||||
export async function cmdPackageClawPackIndexBackfill(
|
||||
opts: GlobalOpts,
|
||||
options: PackageClawPackMigrationOptions = {},
|
||||
) {
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const result = await apiRequest<Record<string, unknown>>(registry, {
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.packages}/clawpack/index-backfill`,
|
||||
token,
|
||||
body: {
|
||||
...(typeof options.limit === "number" && Number.isFinite(options.limit)
|
||||
? { limit: options.limit }
|
||||
: {}),
|
||||
...(options.cursor?.trim() ? { cursor: options.cursor.trim() } : {}),
|
||||
},
|
||||
});
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
console.log("Claw Pack index backfill");
|
||||
console.log(`Processed: ${formatUnknownScalar(result.processed)}`);
|
||||
console.log(`Succeeded: ${formatUnknownScalar(result.succeeded)}`);
|
||||
console.log(`Failed: ${formatUnknownScalar(result.failed)}`);
|
||||
console.log(`Next cursor: ${formatUnknownScalar(result.continueCursor)}`);
|
||||
console.log(`Done: ${formatUnknownScalar(result.isDone)}`);
|
||||
}
|
||||
|
||||
export async function cmdPackageClawPackRevoke(
|
||||
opts: GlobalOpts,
|
||||
packageName: string,
|
||||
version: string,
|
||||
options: PackageClawPackRevokeOptions = {},
|
||||
) {
|
||||
const trimmed = normalizePackageNameOrFail(packageName);
|
||||
const trimmedVersion = version.trim();
|
||||
if (!trimmedVersion) fail("Version required");
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const reason = options.reason?.trim();
|
||||
const result = await apiRequest<Record<string, unknown>>(registry, {
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.packages}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(trimmedVersion)}/clawpack/revoke`,
|
||||
token,
|
||||
body: reason ? { reason } : {},
|
||||
});
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
console.log("Claw Pack revoked");
|
||||
console.log(`Package: ${trimmed}`);
|
||||
console.log(`Version: ${formatUnknownScalar(result.version ?? trimmedVersion)}`);
|
||||
console.log(`SHA-256: ${formatUnknownScalar(result.sha256)}`);
|
||||
console.log(`Revoked artifacts: ${formatUnknownScalar(result.revokedArtifactCount)}`);
|
||||
}
|
||||
|
||||
async function apiRequestPackageDetail(registry: string, name: string, token?: string) {
|
||||
return await apiRequest(
|
||||
registry,
|
||||
@@ -599,6 +1087,23 @@ function clampLimit(value: number, max: number) {
|
||||
return Math.max(1, Math.min(Math.round(value), max));
|
||||
}
|
||||
|
||||
function formatUnknownScalar(value: unknown) {
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === "boolean") return String(value);
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function formatClawPackMigrationRunLine(run: Record<string, unknown>) {
|
||||
const id = formatUnknownScalar(run._id);
|
||||
const operation = formatUnknownScalar(run.operation);
|
||||
const status = formatUnknownScalar(run.status);
|
||||
const processed = formatUnknownScalar(run.processed);
|
||||
const failed = formatUnknownScalar(run.failed);
|
||||
const cursor = formatUnknownScalar(run.continueCursor ?? run.cursor);
|
||||
return `${id} ${operation} ${status} processed=${processed} failed=${failed} cursor=${cursor}`;
|
||||
}
|
||||
|
||||
function formatPackageLine(item: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
@@ -702,6 +1207,21 @@ function printVerification(verification: PackageVerificationSummary | null | und
|
||||
if (verification.scanStatus) console.log(`Scan: ${verification.scanStatus}`);
|
||||
}
|
||||
|
||||
function printClawPack(clawpack: PackageClawPackSummary | null | undefined) {
|
||||
if (!clawpack) return;
|
||||
console.log(`Claw Pack: ${clawpack.available ? "available" : "unavailable"}`);
|
||||
if (!clawpack.available) return;
|
||||
if (clawpack.sha256) console.log(`Claw Pack SHA-256: ${clawpack.sha256}`);
|
||||
if (typeof clawpack.size === "number") console.log(`Claw Pack Size: ${clawpack.size}B`);
|
||||
if (clawpack.specVersion) console.log(`Claw Pack Spec: v${clawpack.specVersion}`);
|
||||
if (clawpack.hostTargets?.length) {
|
||||
const targets = clawpack.hostTargets
|
||||
.map((target) => [target.os, target.arch, target.libc].filter(Boolean).join("-"))
|
||||
.join(", ");
|
||||
console.log(`Claw Pack Targets: ${targets}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTags(tags: unknown): Record<string, string> {
|
||||
if (!tags || typeof tags !== "object") return {};
|
||||
const resolved: Record<string, string> = {};
|
||||
@@ -758,6 +1278,15 @@ function formatTimestamp(value: number) {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
function sha256Hex(bytes: Uint8Array) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function filenameFromContentDisposition(value: string | null) {
|
||||
const match = value?.match(/filename="([^"]+)"/i);
|
||||
return match?.[1] ? basename(match[1]) : null;
|
||||
}
|
||||
|
||||
async function readJsonFile(path: string) {
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
@@ -835,18 +1364,25 @@ async function preparePackagePublishPlan(
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const folderStat = await stat(folder).catch(() => null);
|
||||
if (!folderStat || !folderStat.isDirectory()) fail("Path must be a folder");
|
||||
|
||||
const localGitInfo = resolveLocalGitInfo(folder);
|
||||
if (localGitInfo) {
|
||||
inferredSource = {
|
||||
repo: localGitInfo.repo,
|
||||
commit: localGitInfo.commit,
|
||||
ref: localGitInfo.ref,
|
||||
path: localGitInfo.path,
|
||||
...(localGitInfo.repo ? { url: `https://github.com/${localGitInfo.repo}` } : {}),
|
||||
};
|
||||
const sourceStat = await stat(folder).catch(() => null);
|
||||
if (!sourceStat) fail("Path must be a folder or package archive");
|
||||
if (sourceStat.isFile()) {
|
||||
const extracted = await extractPackageArchive(folder);
|
||||
folder = extracted.folder;
|
||||
cleanup = extracted.cleanup;
|
||||
} else if (sourceStat.isDirectory()) {
|
||||
const localGitInfo = resolveLocalGitInfo(folder);
|
||||
if (localGitInfo) {
|
||||
inferredSource = {
|
||||
repo: localGitInfo.repo,
|
||||
commit: localGitInfo.commit,
|
||||
ref: localGitInfo.ref,
|
||||
path: localGitInfo.path,
|
||||
...(localGitInfo.repo ? { url: `https://github.com/${localGitInfo.repo}` } : {}),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
fail("Path must be a folder or package archive");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1100,7 +1636,7 @@ function describePublishSource(
|
||||
sourceInput.path !== "." ? `:${sourceInput.path}` : ""
|
||||
}`;
|
||||
}
|
||||
return `local:${folder}`;
|
||||
return `local:${sourceInput.path || folder}`;
|
||||
}
|
||||
|
||||
function printPackageDryRun(params: {
|
||||
@@ -1144,6 +1680,43 @@ function formatByteCount(value: number) {
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
async function extractPackageArchive(archivePath: string) {
|
||||
const archiveName = basename(archivePath);
|
||||
const lower = archiveName.toLowerCase();
|
||||
const bytes = new Uint8Array(await readFile(archivePath));
|
||||
const entries = lower.endsWith(".zip")
|
||||
? Object.entries(unzipSync(bytes)).map(([path, data]) => ({ path, data }))
|
||||
: lower.endsWith(".tgz") || lower.endsWith(".tar.gz")
|
||||
? untar(gunzipSync(bytes))
|
||||
: fail("Path must be a folder or .zip/.tgz package archive");
|
||||
const normalizedEntries = stripSingleArchiveRoot(entries);
|
||||
if (normalizedEntries.length === 0) fail("Package archive does not contain any files");
|
||||
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "clawhub-package-publish-"));
|
||||
try {
|
||||
for (const entry of normalizedEntries) {
|
||||
const relPath = normalizeArchivePath(entry.path);
|
||||
if (!relPath) continue;
|
||||
const destination = resolve(tempDir, relPath);
|
||||
if (!destination.startsWith(`${tempDir}${sep}`)) {
|
||||
throw new Error(`Unsafe archive path: ${entry.path}`);
|
||||
}
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, entry.data);
|
||||
}
|
||||
} catch (error) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
folder: tempDir,
|
||||
cleanup: async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function listPackageFiles(root: string) {
|
||||
const files: PackageFile[] = [];
|
||||
const absRoot = resolve(root);
|
||||
@@ -1164,6 +1737,35 @@ async function listPackageFiles(root: string) {
|
||||
return files;
|
||||
}
|
||||
|
||||
function stripSingleArchiveRoot(entries: Array<{ path: string; data: Uint8Array }>) {
|
||||
const normalized = entries
|
||||
.map((entry) => ({ path: normalizeArchivePath(entry.path), data: entry.data }))
|
||||
.filter((entry) => entry.path && !entry.path.endsWith("/"));
|
||||
const partsList = normalized.map((entry) => entry.path.split("/").filter(Boolean));
|
||||
if (partsList.length === 0 || partsList.some((parts) => parts.length < 2)) return normalized;
|
||||
|
||||
const first = partsList[0]?.[0];
|
||||
if (!first || !partsList.every((parts) => parts[0] === first)) return normalized;
|
||||
return normalized.map((entry) => ({
|
||||
path: entry.path.split("/").slice(1).join("/"),
|
||||
data: entry.data,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeArchivePath(path: string) {
|
||||
const normalized = path
|
||||
.replaceAll("\u0000", "")
|
||||
.replaceAll("\\", "/")
|
||||
.trim()
|
||||
.replace(/^\.\/+/, "")
|
||||
.replace(/^\/+/, "");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
if (parts.some((part) => part === "." || part === "..")) {
|
||||
throw new Error(`Unsafe archive path: ${path}`);
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function normalizePath(path: string) {
|
||||
return path
|
||||
.split(sep)
|
||||
@@ -1193,3 +1795,33 @@ async function addIgnoreFile(ig: ReturnType<typeof ignore>, path: string) {
|
||||
// optional
|
||||
}
|
||||
}
|
||||
|
||||
function untar(bytes: Uint8Array) {
|
||||
const entries: Array<{ path: string; data: Uint8Array }> = [];
|
||||
let offset = 0;
|
||||
while (offset + 512 <= bytes.length) {
|
||||
const header = bytes.subarray(offset, offset + 512);
|
||||
if (header.every((byte) => byte === 0)) break;
|
||||
const name = readTarString(header.subarray(0, 100));
|
||||
const size = readTarOctal(header.subarray(124, 136));
|
||||
const typeflag = header[156];
|
||||
offset += 512;
|
||||
const data = bytes.subarray(offset, offset + size);
|
||||
offset += Math.ceil(size / 512) * 512;
|
||||
if (!name || typeflag === 53) continue;
|
||||
entries.push({ path: name, data });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readTarString(bytes: Uint8Array) {
|
||||
const end = bytes.indexOf(0);
|
||||
const slice = end === -1 ? bytes : bytes.subarray(0, end);
|
||||
return new TextDecoder().decode(slice).trim();
|
||||
}
|
||||
|
||||
function readTarOctal(bytes: Uint8Array) {
|
||||
const raw = readTarString(bytes).replaceAll("\u0000", "").trim();
|
||||
if (!raw) return 0;
|
||||
return Number.parseInt(raw, 8);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,46 @@ export const PackageStaticScanSchema = type({
|
||||
});
|
||||
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
|
||||
|
||||
export const PackageHostTargetSchema = type({
|
||||
os: '"darwin"|"linux"|"win32"',
|
||||
arch: '"arm64"|"x64"',
|
||||
libc: '"glibc"|"musl"?',
|
||||
nodeRange: "string?",
|
||||
openclawRange: "string?",
|
||||
pluginApiRange: "string?",
|
||||
supportState: '"supported"|"setup-required"|"unsupported"?',
|
||||
unsupportedReason: "string?",
|
||||
});
|
||||
export type PackageHostTarget = (typeof PackageHostTargetSchema)[inferred];
|
||||
|
||||
export const PackageEnvironmentSummarySchema = type({
|
||||
requiresLocalDesktop: "boolean?",
|
||||
requiresBrowser: "boolean?",
|
||||
requiresAudioDevice: "boolean?",
|
||||
requiresNetwork: "boolean?",
|
||||
requiresExternalServices: "string[]?",
|
||||
requiresOsPermissions: "string[]?",
|
||||
supportsRemoteHost: "boolean?",
|
||||
knownUnsupported: "string[]?",
|
||||
});
|
||||
export type PackageEnvironmentSummary = (typeof PackageEnvironmentSummarySchema)[inferred];
|
||||
|
||||
export const PackageClawPackSummarySchema = type({
|
||||
available: "boolean",
|
||||
specVersion: "number|null",
|
||||
format: "string|null",
|
||||
sha256: "string|null",
|
||||
size: "number|null",
|
||||
fileCount: "number|null",
|
||||
manifestSha256: "string|null",
|
||||
builtAt: "number|null",
|
||||
buildVersion: "string|null",
|
||||
hostTargets: PackageHostTargetSchema.array(),
|
||||
environment: PackageEnvironmentSummarySchema.or("null"),
|
||||
runtimeBundles: "unknown[]",
|
||||
});
|
||||
export type PackageClawPackSummary = (typeof PackageClawPackSummarySchema)[inferred];
|
||||
|
||||
export const BundlePublishMetadataSchema = type({
|
||||
id: "string?",
|
||||
format: "string?",
|
||||
@@ -164,6 +204,10 @@ export const PackageListItemSchema = type({
|
||||
capabilityTags: "string[]?",
|
||||
executesCode: "boolean?",
|
||||
verificationTier: PackageVerificationTierSchema.or("null").optional(),
|
||||
clawpackAvailable: "boolean?",
|
||||
hostTargetKeys: "string[]?",
|
||||
environmentFlags: "string[]?",
|
||||
clawpack: PackageClawPackSummarySchema.optional(),
|
||||
});
|
||||
export type PackageListItem = (typeof PackageListItemSchema)[inferred];
|
||||
|
||||
@@ -196,6 +240,7 @@ export const ApiV1PackageResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
clawpack: PackageClawPackSummarySchema.optional(),
|
||||
stats: PackageStatsSchema.optional(),
|
||||
}).or("null"),
|
||||
owner: type({
|
||||
@@ -234,6 +279,7 @@ export const ApiV1PackageVersionResponseSchema = type({
|
||||
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
|
||||
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
|
||||
staticScan: PackageStaticScanSchema.or("null").optional(),
|
||||
clawpack: PackageClawPackSummarySchema.optional(),
|
||||
}).or("null"),
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ApiRoutes = {
|
||||
publishTokenMint: "/api/v1/publish/token/mint",
|
||||
skills: "/api/v1/skills",
|
||||
packages: "/api/v1/packages",
|
||||
clawpacks: "/api/v1/clawpacks",
|
||||
codePlugins: "/api/v1/code-plugins",
|
||||
bundlePlugins: "/api/v1/bundle-plugins",
|
||||
stars: "/api/v1/stars",
|
||||
|
||||
Vendored
+224
@@ -110,6 +110,61 @@ export declare const PackageStaticScanSchema: import("arktype/internal/variants/
|
||||
checkedAt: number;
|
||||
}, {}>;
|
||||
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
|
||||
export declare const PackageHostTargetSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
os: "darwin" | "linux" | "win32";
|
||||
arch: "arm64" | "x64";
|
||||
libc?: "glibc" | "musl" | undefined;
|
||||
nodeRange?: string | undefined;
|
||||
openclawRange?: string | undefined;
|
||||
pluginApiRange?: string | undefined;
|
||||
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
|
||||
unsupportedReason?: string | undefined;
|
||||
}, {}>;
|
||||
export type PackageHostTarget = (typeof PackageHostTargetSchema)[inferred];
|
||||
export declare const PackageEnvironmentSummarySchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
requiresLocalDesktop?: boolean | undefined;
|
||||
requiresBrowser?: boolean | undefined;
|
||||
requiresAudioDevice?: boolean | undefined;
|
||||
requiresNetwork?: boolean | undefined;
|
||||
requiresExternalServices?: string[] | undefined;
|
||||
requiresOsPermissions?: string[] | undefined;
|
||||
supportsRemoteHost?: boolean | undefined;
|
||||
knownUnsupported?: string[] | undefined;
|
||||
}, {}>;
|
||||
export type PackageEnvironmentSummary = (typeof PackageEnvironmentSummarySchema)[inferred];
|
||||
export declare const PackageClawPackSummarySchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
available: boolean;
|
||||
specVersion: number | null;
|
||||
format: string | null;
|
||||
sha256: string | null;
|
||||
size: number | null;
|
||||
fileCount: number | null;
|
||||
manifestSha256: string | null;
|
||||
builtAt: number | null;
|
||||
buildVersion: string | null;
|
||||
hostTargets: {
|
||||
os: "darwin" | "linux" | "win32";
|
||||
arch: "arm64" | "x64";
|
||||
libc?: "glibc" | "musl" | undefined;
|
||||
nodeRange?: string | undefined;
|
||||
openclawRange?: string | undefined;
|
||||
pluginApiRange?: string | undefined;
|
||||
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
|
||||
unsupportedReason?: string | undefined;
|
||||
}[];
|
||||
environment: {
|
||||
requiresLocalDesktop?: boolean | undefined;
|
||||
requiresBrowser?: boolean | undefined;
|
||||
requiresAudioDevice?: boolean | undefined;
|
||||
requiresNetwork?: boolean | undefined;
|
||||
requiresExternalServices?: string[] | undefined;
|
||||
requiresOsPermissions?: string[] | undefined;
|
||||
supportsRemoteHost?: boolean | undefined;
|
||||
knownUnsupported?: string[] | undefined;
|
||||
} | null;
|
||||
runtimeBundles: unknown[];
|
||||
}, {}>;
|
||||
export type PackageClawPackSummary = (typeof PackageClawPackSummarySchema)[inferred];
|
||||
export declare const BundlePublishMetadataSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
id?: string | undefined;
|
||||
format?: string | undefined;
|
||||
@@ -174,6 +229,41 @@ export declare const PackageListItemSchema: import("arktype/internal/variants/ob
|
||||
capabilityTags?: string[] | undefined;
|
||||
executesCode?: boolean | undefined;
|
||||
verificationTier?: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified" | null | undefined;
|
||||
clawpackAvailable?: boolean | undefined;
|
||||
hostTargetKeys?: string[] | undefined;
|
||||
environmentFlags?: string[] | undefined;
|
||||
clawpack?: {
|
||||
available: boolean;
|
||||
specVersion: number | null;
|
||||
format: string | null;
|
||||
sha256: string | null;
|
||||
size: number | null;
|
||||
fileCount: number | null;
|
||||
manifestSha256: string | null;
|
||||
builtAt: number | null;
|
||||
buildVersion: string | null;
|
||||
hostTargets: {
|
||||
os: "darwin" | "linux" | "win32";
|
||||
arch: "arm64" | "x64";
|
||||
libc?: "glibc" | "musl" | undefined;
|
||||
nodeRange?: string | undefined;
|
||||
openclawRange?: string | undefined;
|
||||
pluginApiRange?: string | undefined;
|
||||
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
|
||||
unsupportedReason?: string | undefined;
|
||||
}[];
|
||||
environment: {
|
||||
requiresLocalDesktop?: boolean | undefined;
|
||||
requiresBrowser?: boolean | undefined;
|
||||
requiresAudioDevice?: boolean | undefined;
|
||||
requiresNetwork?: boolean | undefined;
|
||||
requiresExternalServices?: string[] | undefined;
|
||||
requiresOsPermissions?: string[] | undefined;
|
||||
supportsRemoteHost?: boolean | undefined;
|
||||
knownUnsupported?: string[] | undefined;
|
||||
} | null;
|
||||
runtimeBundles: unknown[];
|
||||
} | undefined;
|
||||
}, {}>;
|
||||
export type PackageListItem = (typeof PackageListItemSchema)[inferred];
|
||||
export declare const ApiV1PackageListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
@@ -192,6 +282,41 @@ export declare const ApiV1PackageListResponseSchema: import("arktype/internal/va
|
||||
capabilityTags?: string[] | undefined;
|
||||
executesCode?: boolean | undefined;
|
||||
verificationTier?: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified" | null | undefined;
|
||||
clawpackAvailable?: boolean | undefined;
|
||||
hostTargetKeys?: string[] | undefined;
|
||||
environmentFlags?: string[] | undefined;
|
||||
clawpack?: {
|
||||
available: boolean;
|
||||
specVersion: number | null;
|
||||
format: string | null;
|
||||
sha256: string | null;
|
||||
size: number | null;
|
||||
fileCount: number | null;
|
||||
manifestSha256: string | null;
|
||||
builtAt: number | null;
|
||||
buildVersion: string | null;
|
||||
hostTargets: {
|
||||
os: "darwin" | "linux" | "win32";
|
||||
arch: "arm64" | "x64";
|
||||
libc?: "glibc" | "musl" | undefined;
|
||||
nodeRange?: string | undefined;
|
||||
openclawRange?: string | undefined;
|
||||
pluginApiRange?: string | undefined;
|
||||
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
|
||||
unsupportedReason?: string | undefined;
|
||||
}[];
|
||||
environment: {
|
||||
requiresLocalDesktop?: boolean | undefined;
|
||||
requiresBrowser?: boolean | undefined;
|
||||
requiresAudioDevice?: boolean | undefined;
|
||||
requiresNetwork?: boolean | undefined;
|
||||
requiresExternalServices?: string[] | undefined;
|
||||
requiresOsPermissions?: string[] | undefined;
|
||||
supportsRemoteHost?: boolean | undefined;
|
||||
knownUnsupported?: string[] | undefined;
|
||||
} | null;
|
||||
runtimeBundles: unknown[];
|
||||
} | undefined;
|
||||
}[];
|
||||
nextCursor: string | null;
|
||||
}, {}>;
|
||||
@@ -214,6 +339,41 @@ export declare const ApiV1PackageSearchResponseSchema: import("arktype/internal/
|
||||
capabilityTags?: string[] | undefined;
|
||||
executesCode?: boolean | undefined;
|
||||
verificationTier?: "structural" | "source-linked" | "provenance-verified" | "rebuild-verified" | null | undefined;
|
||||
clawpackAvailable?: boolean | undefined;
|
||||
hostTargetKeys?: string[] | undefined;
|
||||
environmentFlags?: string[] | undefined;
|
||||
clawpack?: {
|
||||
available: boolean;
|
||||
specVersion: number | null;
|
||||
format: string | null;
|
||||
sha256: string | null;
|
||||
size: number | null;
|
||||
fileCount: number | null;
|
||||
manifestSha256: string | null;
|
||||
builtAt: number | null;
|
||||
buildVersion: string | null;
|
||||
hostTargets: {
|
||||
os: "darwin" | "linux" | "win32";
|
||||
arch: "arm64" | "x64";
|
||||
libc?: "glibc" | "musl" | undefined;
|
||||
nodeRange?: string | undefined;
|
||||
openclawRange?: string | undefined;
|
||||
pluginApiRange?: string | undefined;
|
||||
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
|
||||
unsupportedReason?: string | undefined;
|
||||
}[];
|
||||
environment: {
|
||||
requiresLocalDesktop?: boolean | undefined;
|
||||
requiresBrowser?: boolean | undefined;
|
||||
requiresAudioDevice?: boolean | undefined;
|
||||
requiresNetwork?: boolean | undefined;
|
||||
requiresExternalServices?: string[] | undefined;
|
||||
requiresOsPermissions?: string[] | undefined;
|
||||
supportsRemoteHost?: boolean | undefined;
|
||||
knownUnsupported?: string[] | undefined;
|
||||
} | null;
|
||||
runtimeBundles: unknown[];
|
||||
} | undefined;
|
||||
};
|
||||
}[];
|
||||
}, {}>;
|
||||
@@ -268,6 +428,38 @@ export declare const ApiV1PackageResponseSchema: import("arktype/internal/varian
|
||||
hasProvenance?: boolean | undefined;
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
|
||||
} | null | undefined;
|
||||
clawpack?: {
|
||||
available: boolean;
|
||||
specVersion: number | null;
|
||||
format: string | null;
|
||||
sha256: string | null;
|
||||
size: number | null;
|
||||
fileCount: number | null;
|
||||
manifestSha256: string | null;
|
||||
builtAt: number | null;
|
||||
buildVersion: string | null;
|
||||
hostTargets: {
|
||||
os: "darwin" | "linux" | "win32";
|
||||
arch: "arm64" | "x64";
|
||||
libc?: "glibc" | "musl" | undefined;
|
||||
nodeRange?: string | undefined;
|
||||
openclawRange?: string | undefined;
|
||||
pluginApiRange?: string | undefined;
|
||||
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
|
||||
unsupportedReason?: string | undefined;
|
||||
}[];
|
||||
environment: {
|
||||
requiresLocalDesktop?: boolean | undefined;
|
||||
requiresBrowser?: boolean | undefined;
|
||||
requiresAudioDevice?: boolean | undefined;
|
||||
requiresNetwork?: boolean | undefined;
|
||||
requiresExternalServices?: string[] | undefined;
|
||||
requiresOsPermissions?: string[] | undefined;
|
||||
supportsRemoteHost?: boolean | undefined;
|
||||
knownUnsupported?: string[] | undefined;
|
||||
} | null;
|
||||
runtimeBundles: unknown[];
|
||||
} | undefined;
|
||||
stats?: {
|
||||
downloads: number;
|
||||
installs: number;
|
||||
@@ -379,6 +571,38 @@ export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
} | null | undefined;
|
||||
clawpack?: {
|
||||
available: boolean;
|
||||
specVersion: number | null;
|
||||
format: string | null;
|
||||
sha256: string | null;
|
||||
size: number | null;
|
||||
fileCount: number | null;
|
||||
manifestSha256: string | null;
|
||||
builtAt: number | null;
|
||||
buildVersion: string | null;
|
||||
hostTargets: {
|
||||
os: "darwin" | "linux" | "win32";
|
||||
arch: "arm64" | "x64";
|
||||
libc?: "glibc" | "musl" | undefined;
|
||||
nodeRange?: string | undefined;
|
||||
openclawRange?: string | undefined;
|
||||
pluginApiRange?: string | undefined;
|
||||
supportState?: "supported" | "setup-required" | "unsupported" | undefined;
|
||||
unsupportedReason?: string | undefined;
|
||||
}[];
|
||||
environment: {
|
||||
requiresLocalDesktop?: boolean | undefined;
|
||||
requiresBrowser?: boolean | undefined;
|
||||
requiresAudioDevice?: boolean | undefined;
|
||||
requiresNetwork?: boolean | undefined;
|
||||
requiresExternalServices?: string[] | undefined;
|
||||
requiresOsPermissions?: string[] | undefined;
|
||||
supportsRemoteHost?: boolean | undefined;
|
||||
knownUnsupported?: string[] | undefined;
|
||||
} | null;
|
||||
runtimeBundles: unknown[];
|
||||
} | undefined;
|
||||
} | null;
|
||||
}, {}>;
|
||||
export type ApiV1PackageVersionResponse = (typeof ApiV1PackageVersionResponseSchema)[inferred];
|
||||
|
||||
Vendored
+40
@@ -86,6 +86,40 @@ export const PackageStaticScanSchema = type({
|
||||
engineVersion: "string",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export const PackageHostTargetSchema = type({
|
||||
os: '"darwin"|"linux"|"win32"',
|
||||
arch: '"arm64"|"x64"',
|
||||
libc: '"glibc"|"musl"?',
|
||||
nodeRange: "string?",
|
||||
openclawRange: "string?",
|
||||
pluginApiRange: "string?",
|
||||
supportState: '"supported"|"setup-required"|"unsupported"?',
|
||||
unsupportedReason: "string?",
|
||||
});
|
||||
export const PackageEnvironmentSummarySchema = type({
|
||||
requiresLocalDesktop: "boolean?",
|
||||
requiresBrowser: "boolean?",
|
||||
requiresAudioDevice: "boolean?",
|
||||
requiresNetwork: "boolean?",
|
||||
requiresExternalServices: "string[]?",
|
||||
requiresOsPermissions: "string[]?",
|
||||
supportsRemoteHost: "boolean?",
|
||||
knownUnsupported: "string[]?",
|
||||
});
|
||||
export const PackageClawPackSummarySchema = type({
|
||||
available: "boolean",
|
||||
specVersion: "number|null",
|
||||
format: "string|null",
|
||||
sha256: "string|null",
|
||||
size: "number|null",
|
||||
fileCount: "number|null",
|
||||
manifestSha256: "string|null",
|
||||
builtAt: "number|null",
|
||||
buildVersion: "string|null",
|
||||
hostTargets: PackageHostTargetSchema.array(),
|
||||
environment: PackageEnvironmentSummarySchema.or("null"),
|
||||
runtimeBundles: "unknown[]",
|
||||
});
|
||||
export const BundlePublishMetadataSchema = type({
|
||||
id: "string?",
|
||||
format: "string?",
|
||||
@@ -129,6 +163,10 @@ export const PackageListItemSchema = type({
|
||||
capabilityTags: "string[]?",
|
||||
executesCode: "boolean?",
|
||||
verificationTier: PackageVerificationTierSchema.or("null").optional(),
|
||||
clawpackAvailable: "boolean?",
|
||||
hostTargetKeys: "string[]?",
|
||||
environmentFlags: "string[]?",
|
||||
clawpack: PackageClawPackSummarySchema.optional(),
|
||||
});
|
||||
export const ApiV1PackageListResponseSchema = type({
|
||||
items: PackageListItemSchema.array(),
|
||||
@@ -157,6 +195,7 @@ export const ApiV1PackageResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
clawpack: PackageClawPackSummarySchema.optional(),
|
||||
stats: PackageStatsSchema.optional(),
|
||||
}).or("null"),
|
||||
owner: type({
|
||||
@@ -193,6 +232,7 @@ export const ApiV1PackageVersionResponseSchema = type({
|
||||
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
|
||||
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
|
||||
staticScan: PackageStaticScanSchema.or("null").optional(),
|
||||
clawpack: PackageClawPackSummarySchema.optional(),
|
||||
}).or("null"),
|
||||
});
|
||||
export const ApiV1PackagePublishResponseSchema = type({
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -18,6 +18,7 @@ export declare const ApiRoutes: {
|
||||
readonly skills: "/api/v1/skills";
|
||||
readonly plugins: "/api/v1/plugins";
|
||||
readonly packages: "/api/v1/packages";
|
||||
readonly clawpacks: "/api/v1/clawpacks";
|
||||
readonly codePlugins: "/api/v1/code-plugins";
|
||||
readonly bundlePlugins: "/api/v1/bundle-plugins";
|
||||
readonly stars: "/api/v1/stars";
|
||||
|
||||
Vendored
+1
@@ -18,6 +18,7 @@ export const ApiRoutes = {
|
||||
skills: "/api/v1/skills",
|
||||
plugins: "/api/v1/plugins",
|
||||
packages: "/api/v1/packages",
|
||||
clawpacks: "/api/v1/clawpacks",
|
||||
codePlugins: "/api/v1/code-plugins",
|
||||
bundlePlugins: "/api/v1/bundle-plugins",
|
||||
stars: "/api/v1/stars",
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAC"}
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,UAAU,EAAE,oBAAoB;IAChC,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAC"}
|
||||
@@ -115,6 +115,46 @@ export const PackageStaticScanSchema = type({
|
||||
});
|
||||
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
|
||||
|
||||
export const PackageHostTargetSchema = type({
|
||||
os: '"darwin"|"linux"|"win32"',
|
||||
arch: '"arm64"|"x64"',
|
||||
libc: '"glibc"|"musl"?',
|
||||
nodeRange: "string?",
|
||||
openclawRange: "string?",
|
||||
pluginApiRange: "string?",
|
||||
supportState: '"supported"|"setup-required"|"unsupported"?',
|
||||
unsupportedReason: "string?",
|
||||
});
|
||||
export type PackageHostTarget = (typeof PackageHostTargetSchema)[inferred];
|
||||
|
||||
export const PackageEnvironmentSummarySchema = type({
|
||||
requiresLocalDesktop: "boolean?",
|
||||
requiresBrowser: "boolean?",
|
||||
requiresAudioDevice: "boolean?",
|
||||
requiresNetwork: "boolean?",
|
||||
requiresExternalServices: "string[]?",
|
||||
requiresOsPermissions: "string[]?",
|
||||
supportsRemoteHost: "boolean?",
|
||||
knownUnsupported: "string[]?",
|
||||
});
|
||||
export type PackageEnvironmentSummary = (typeof PackageEnvironmentSummarySchema)[inferred];
|
||||
|
||||
export const PackageClawPackSummarySchema = type({
|
||||
available: "boolean",
|
||||
specVersion: "number|null",
|
||||
format: "string|null",
|
||||
sha256: "string|null",
|
||||
size: "number|null",
|
||||
fileCount: "number|null",
|
||||
manifestSha256: "string|null",
|
||||
builtAt: "number|null",
|
||||
buildVersion: "string|null",
|
||||
hostTargets: PackageHostTargetSchema.array(),
|
||||
environment: PackageEnvironmentSummarySchema.or("null"),
|
||||
runtimeBundles: "unknown[]",
|
||||
});
|
||||
export type PackageClawPackSummary = (typeof PackageClawPackSummarySchema)[inferred];
|
||||
|
||||
export const BundlePublishMetadataSchema = type({
|
||||
id: "string?",
|
||||
format: "string?",
|
||||
@@ -164,6 +204,10 @@ export const PackageListItemSchema = type({
|
||||
capabilityTags: "string[]?",
|
||||
executesCode: "boolean?",
|
||||
verificationTier: PackageVerificationTierSchema.or("null").optional(),
|
||||
clawpackAvailable: "boolean?",
|
||||
hostTargetKeys: "string[]?",
|
||||
environmentFlags: "string[]?",
|
||||
clawpack: PackageClawPackSummarySchema.optional(),
|
||||
});
|
||||
export type PackageListItem = (typeof PackageListItemSchema)[inferred];
|
||||
|
||||
@@ -198,6 +242,7 @@ export const ApiV1PackageResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
clawpack: PackageClawPackSummarySchema.optional(),
|
||||
stats: PackageStatsSchema.optional(),
|
||||
}).or("null"),
|
||||
owner: type({
|
||||
@@ -239,6 +284,7 @@ export const ApiV1PackageVersionResponseSchema = type({
|
||||
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
|
||||
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
|
||||
staticScan: PackageStaticScanSchema.or("null").optional(),
|
||||
clawpack: PackageClawPackSummarySchema.optional(),
|
||||
}).or("null"),
|
||||
});
|
||||
export type ApiV1PackageVersionResponse = (typeof ApiV1PackageVersionResponseSchema)[inferred];
|
||||
|
||||
@@ -19,6 +19,7 @@ export const ApiRoutes = {
|
||||
skills: "/api/v1/skills",
|
||||
plugins: "/api/v1/plugins",
|
||||
packages: "/api/v1/packages",
|
||||
clawpacks: "/api/v1/clawpacks",
|
||||
codePlugins: "/api/v1/code-plugins",
|
||||
bundlePlugins: "/api/v1/bundle-plugins",
|
||||
stars: "/api/v1/stars",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: (path: string) => (config: { component: unknown }) => ({
|
||||
__config: config,
|
||||
__path: path,
|
||||
useParams: () => ({ releaseId: "packageReleases:1" }),
|
||||
}),
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
to: string;
|
||||
params?: Record<string, string>;
|
||||
search?: Record<string, unknown>;
|
||||
}) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
import {
|
||||
Route,
|
||||
ClawPackReleaseDetailPage,
|
||||
} from "../routes/management/clawpacks/releases/$releaseId";
|
||||
|
||||
function renderRoute() {
|
||||
render(
|
||||
createElement(ClawPackReleaseDetailPage as never, {
|
||||
releaseId: "packageReleases:1",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("Claw Pack release detail route", () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset();
|
||||
useAuthStatusMock.mockReset();
|
||||
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:mod", role: "moderator" },
|
||||
});
|
||||
useQueryMock.mockReturnValue({
|
||||
package: {
|
||||
packageId: "packages:1",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: true,
|
||||
scanStatus: "clean",
|
||||
updatedAt: Date.UTC(2026, 0, 2),
|
||||
},
|
||||
release: {
|
||||
releaseId: "packageReleases:1",
|
||||
version: "1.2.3",
|
||||
createdAt: Date.UTC(2026, 0, 1),
|
||||
fileCount: 2,
|
||||
fileSample: [
|
||||
{ path: "SKILL.md", size: 1200, sha256: "c".repeat(64) },
|
||||
{ path: "plugin.json", size: 400, sha256: "d".repeat(64) },
|
||||
],
|
||||
clawpackStorageId: "storage:1",
|
||||
clawpackSha256: "a".repeat(64),
|
||||
clawpackSize: 4096,
|
||||
clawpackSpecVersion: 1,
|
||||
clawpackFormat: "zip",
|
||||
clawpackFileCount: 2,
|
||||
clawpackManifestSha256: "b".repeat(64),
|
||||
clawpackBuiltAt: Date.UTC(2026, 0, 3),
|
||||
clawpackBuildVersion: "clawhub-clawpack-v1",
|
||||
clawpackRevokedAt: null,
|
||||
clawpackRevocationReason: null,
|
||||
hostTargetsSummary: [{ os: "darwin", arch: "arm64" }],
|
||||
environmentSummary: { requiresNetwork: true, requiresExternalServices: ["opik"] },
|
||||
source: {
|
||||
kind: "git",
|
||||
repo: "openclaw/demo-plugin",
|
||||
url: null,
|
||||
ref: "refs/tags/v1.2.3",
|
||||
commit: "abcdef1234567890",
|
||||
path: ".",
|
||||
},
|
||||
verificationScanStatus: "clean",
|
||||
vtStatus: "clean",
|
||||
vtVerdict: "clean",
|
||||
llmStatus: "clean",
|
||||
llmVerdict: "clean",
|
||||
staticScanStatus: "clean",
|
||||
staticScanSummary: "No findings",
|
||||
staticScanReasonCodes: [],
|
||||
},
|
||||
artifacts: [
|
||||
{
|
||||
artifactId: "packageReleaseArtifacts:1",
|
||||
kind: "clawpack",
|
||||
targetKey: null,
|
||||
storageId: "storage:1",
|
||||
sha256: "a".repeat(64),
|
||||
size: 4096,
|
||||
format: "zip",
|
||||
status: "active",
|
||||
createdAt: Date.UTC(2026, 0, 3),
|
||||
revokedAt: null,
|
||||
revocationReason: null,
|
||||
},
|
||||
],
|
||||
failures: [
|
||||
{
|
||||
failureId: "packageClawPackBackfillFailures:1",
|
||||
error: "previous zip build failed",
|
||||
attemptCount: 2,
|
||||
firstFailedAt: Date.UTC(2026, 0, 1),
|
||||
lastAttemptAt: Date.UTC(2026, 0, 2),
|
||||
lastFailedAt: Date.UTC(2026, 0, 2),
|
||||
resolvedAt: Date.UTC(2026, 0, 3),
|
||||
},
|
||||
],
|
||||
searchIndexRows: [
|
||||
{
|
||||
rowId: "packageClawPackSearchIndex:1",
|
||||
kind: "host-target",
|
||||
key: "darwin-arm64",
|
||||
updatedAt: Date.UTC(2026, 0, 3),
|
||||
createdAt: Date.UTC(2026, 0, 3),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the release drilldown route", () => {
|
||||
expect(Route).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders release artifact, failure, index, and provenance evidence", () => {
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Claw Pack release detail" })).toBeTruthy();
|
||||
expect(screen.getByText("Demo Plugin")).toBeTruthy();
|
||||
expect(screen.getByText("demo-plugin")).toBeTruthy();
|
||||
expect(screen.getByText("Artifact rows")).toBeTruthy();
|
||||
expect(screen.getByText("Failure ledger")).toBeTruthy();
|
||||
expect(screen.getByText("Lookup index")).toBeTruthy();
|
||||
expect(screen.getAllByText("previous zip build failed").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("darwin-arm64").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("service:opik")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("openclaw/demo-plugin / refs/tags/v1.2.3 / . / abcdef123456"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("SKILL.md")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("blocks non-staff users", () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:member", role: "user" },
|
||||
});
|
||||
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByText("Management access required")).toBeTruthy();
|
||||
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: (path: string) => (config: { component: unknown }) => ({
|
||||
__config: config,
|
||||
__path: path,
|
||||
}),
|
||||
Outlet: () => <div data-testid="outlet" />,
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
to: string;
|
||||
params?: Record<string, string>;
|
||||
search?: Record<string, unknown>;
|
||||
}) => <a href={to}>{children}</a>,
|
||||
useRouterState: () => "/management/clawpacks",
|
||||
}));
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const useActionMock = vi.fn();
|
||||
const useMutationMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
const startMigrationRun = vi.fn();
|
||||
const continueMigrationRun = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useAction: () => useActionMock(),
|
||||
useMutation: () => useMutationMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
import { Route, ClawPackManagementRoute } from "../routes/management/clawpacks";
|
||||
|
||||
function renderRoute() {
|
||||
render(createElement(ClawPackManagementRoute as never));
|
||||
}
|
||||
|
||||
describe("Claw Pack management route", () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset();
|
||||
useActionMock.mockReset();
|
||||
useMutationMock.mockReset();
|
||||
useAuthStatusMock.mockReset();
|
||||
startMigrationRun.mockReset();
|
||||
continueMigrationRun.mockReset();
|
||||
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:admin", role: "admin" },
|
||||
});
|
||||
useActionMock.mockReturnValue(continueMigrationRun);
|
||||
useMutationMock.mockReturnValue(startMigrationRun);
|
||||
|
||||
const migrationStatus = {
|
||||
missingSample: [
|
||||
{
|
||||
releaseId: "packageReleases:1",
|
||||
packageId: "packages:1",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.2.3",
|
||||
createdAt: Date.UTC(2026, 0, 1),
|
||||
fileCount: 3,
|
||||
},
|
||||
],
|
||||
failureSample: [
|
||||
{
|
||||
failureId: "packageClawPackBackfillFailures:1",
|
||||
releaseId: "packageReleases:2",
|
||||
packageId: "packages:2",
|
||||
name: "broken-plugin",
|
||||
version: "0.1.0",
|
||||
error: "Invalid Claw Pack file path",
|
||||
attemptCount: 2,
|
||||
firstFailedAt: Date.UTC(2026, 0, 1),
|
||||
lastAttemptAt: Date.UTC(2026, 0, 2),
|
||||
lastFailedAt: Date.UTC(2026, 0, 2),
|
||||
},
|
||||
],
|
||||
missingSampleSize: 1,
|
||||
failureSampleSize: 1,
|
||||
generatedClawPackSampleSize: 3,
|
||||
generatedClawPackBytes: 4096,
|
||||
sampleLimit: 25,
|
||||
};
|
||||
const migrationRunList = {
|
||||
items: [
|
||||
{
|
||||
_id: "clawPackMigrationRuns:1",
|
||||
actorUserId: "users:admin",
|
||||
operation: "failure-retry",
|
||||
status: "pending",
|
||||
limit: 10,
|
||||
processed: 0,
|
||||
generated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
bytesGenerated: 0,
|
||||
failureCounts: {},
|
||||
createdAt: Date.UTC(2026, 0, 3),
|
||||
updatedAt: Date.UTC(2026, 0, 3),
|
||||
actor: { userId: "users:admin", handle: "admin", name: "Admin", role: "admin" },
|
||||
},
|
||||
],
|
||||
limit: 12,
|
||||
status: null,
|
||||
hasMore: false,
|
||||
};
|
||||
const dryRunResult = {
|
||||
operation: "artifact-backfill",
|
||||
limit: 10,
|
||||
cursor: null,
|
||||
continueCursor: null,
|
||||
isDone: false,
|
||||
candidates: migrationStatus.missingSample,
|
||||
candidateCount: 1,
|
||||
failureCount: 1,
|
||||
};
|
||||
useQueryMock.mockImplementation((_ref: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "operation" in args) return dryRunResult;
|
||||
if (args && typeof args === "object" && "limit" in args) return migrationRunList;
|
||||
return migrationStatus;
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the dedicated management route", () => {
|
||||
expect(Route).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders migration status and dry-run sample rows", () => {
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Claw Pack operations" })).toBeTruthy();
|
||||
expect(screen.getByText("Plugin operations")).toBeTruthy();
|
||||
expect(screen.getByText("/publish-plugin")).toBeTruthy();
|
||||
expect(screen.getByText("/management/moderation")).toBeTruthy();
|
||||
expect(screen.getByText("/management/migrations")).toBeTruthy();
|
||||
expect(screen.getByText("75%")).toBeTruthy();
|
||||
expect(screen.getByText("4.0KB")).toBeTruthy();
|
||||
expect(screen.getByText("Failed artifact builds")).toBeTruthy();
|
||||
expect(screen.getByText(/Invalid Claw Pack file path/)).toBeTruthy();
|
||||
expect(screen.getByText("Create migration run")).toBeTruthy();
|
||||
expect(screen.getByText("Run next batch")).toBeTruthy();
|
||||
expect(screen.getByText("Migration runs")).toBeTruthy();
|
||||
expect(screen.getAllByText("Details").length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dry-run operation" }));
|
||||
|
||||
expect(screen.getAllByText("Demo Plugin").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/demo-plugin@1\.2\.3/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hides write buttons for moderators without admin role", () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:mod", role: "moderator" },
|
||||
});
|
||||
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByText("read only")).toBeTruthy();
|
||||
expect(screen.queryByText("Create migration run")).toBeNull();
|
||||
expect(screen.queryByText("Run next batch")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains missing management role for non-staff users", () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:member", role: "user" },
|
||||
});
|
||||
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByText("Management access required")).toBeTruthy();
|
||||
expect(screen.getByText(/role user/i)).toBeTruthy();
|
||||
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
|
||||
});
|
||||
|
||||
it("confirms and creates migration runs for admins", async () => {
|
||||
startMigrationRun.mockResolvedValueOnce({
|
||||
_id: "clawPackMigrationRuns:2",
|
||||
operation: "artifact-backfill",
|
||||
status: "pending",
|
||||
});
|
||||
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
|
||||
|
||||
renderRoute();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create migration run" }));
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Create a artifact backfill migration run"),
|
||||
);
|
||||
expect(startMigrationRun).toHaveBeenCalledWith({ operation: "artifact-backfill", limit: 10 });
|
||||
expect(await screen.findByText(/created clawPackMigrationRuns:2/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirms and continues migration run batches for admins", async () => {
|
||||
continueMigrationRun.mockResolvedValueOnce({
|
||||
run: {
|
||||
_id: "clawPackMigrationRuns:1",
|
||||
operation: "failure-retry",
|
||||
status: "completed",
|
||||
},
|
||||
result: { processed: 1, succeeded: 1, failed: 0 },
|
||||
});
|
||||
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
|
||||
|
||||
renderRoute();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Run next batch" }));
|
||||
|
||||
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining("Run next failure retry"));
|
||||
expect(continueMigrationRun).toHaveBeenCalledWith({ runId: "clawPackMigrationRuns:1" });
|
||||
expect(await screen.findByText(/processed 1 - succeeded 1 - failed 0/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: (path: string) => (config: { component: unknown }) => ({
|
||||
__config: config,
|
||||
__path: path,
|
||||
useParams: () => ({ bundledPluginId: "opik" }),
|
||||
}),
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
to: string;
|
||||
params?: Record<string, string>;
|
||||
search?: Record<string, unknown>;
|
||||
}) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
import {
|
||||
OfficialMigrationDetailPage,
|
||||
Route,
|
||||
} from "../routes/management/migrations/$bundledPluginId";
|
||||
|
||||
function renderDetail(bundledPluginId = "opik") {
|
||||
render(createElement(OfficialMigrationDetailPage as never, { bundledPluginId }));
|
||||
}
|
||||
|
||||
describe("official migration readiness detail route", () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset();
|
||||
useAuthStatusMock.mockReset();
|
||||
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:mod", role: "moderator" },
|
||||
});
|
||||
useQueryMock.mockReturnValue({
|
||||
readyCount: 0,
|
||||
blockedCount: 1,
|
||||
generatedAt: Date.UTC(2026, 4, 1),
|
||||
items: [
|
||||
{
|
||||
bundledPluginId: "opik",
|
||||
displayName: "Opik",
|
||||
desiredPackageName: "@opik/opik-openclaw",
|
||||
publisherHandle: "opik",
|
||||
sourceRepo: "comet-ml/opik-openclaw",
|
||||
sourcePath: "packages/openclaw",
|
||||
sourceCommit: "abc1234567890",
|
||||
sourceRef: null,
|
||||
requiredHostTargets: ["darwin-arm64", "linux-x64-glibc", "win32-x64"],
|
||||
readinessState: "metadata-incomplete",
|
||||
blockers: ["environment-metadata-incomplete"],
|
||||
gates: {
|
||||
packageExists: true,
|
||||
releaseExists: true,
|
||||
clawpackAvailable: true,
|
||||
hostMatrixComplete: true,
|
||||
environmentComplete: false,
|
||||
sourceLinked: true,
|
||||
scanClear: true,
|
||||
runtimeBundleStatus: "not-required",
|
||||
},
|
||||
package: {
|
||||
packageId: "packages:1",
|
||||
name: "@opik/opik-openclaw",
|
||||
displayName: "Opik",
|
||||
family: "code-plugin",
|
||||
runtimeId: "opik",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
scanStatus: "clean",
|
||||
updatedAt: Date.UTC(2026, 4, 1),
|
||||
},
|
||||
latestRelease: {
|
||||
releaseId: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: Date.UTC(2026, 4, 1),
|
||||
clawpackSha256: "b".repeat(64),
|
||||
clawpackFileCount: 8,
|
||||
hostTargetKeys: ["darwin-arm64", "linux-x64-glibc", "win32-x64"],
|
||||
environmentFlags: [],
|
||||
scanStatus: "clean",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the migration candidate route", () => {
|
||||
expect(Route).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders one candidate with gate evidence and blockers", () => {
|
||||
renderDetail();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Migration candidate" })).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "Opik" })).toBeTruthy();
|
||||
expect(screen.getByText("metadata incomplete")).toBeTruthy();
|
||||
expect(screen.getByText("8 files / bbbbbbbbbbbb")).toBeTruthy();
|
||||
expect(screen.getByText("packages/openclaw")).toBeTruthy();
|
||||
expect(screen.getByText("Environment complete")).toBeTruthy();
|
||||
expect(screen.getAllByText("blocked").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("environment metadata incomplete")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a not found state for unknown candidates", () => {
|
||||
renderDetail("missing");
|
||||
|
||||
expect(screen.getByText("not found")).toBeTruthy();
|
||||
expect(screen.getByText("missing")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("explains missing management role for non-staff users", () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:member", role: "user" },
|
||||
});
|
||||
|
||||
renderDetail();
|
||||
|
||||
expect(screen.getByText("Management access required")).toBeTruthy();
|
||||
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: (path: string) => (config: { component: unknown }) => ({
|
||||
__config: config,
|
||||
__path: path,
|
||||
}),
|
||||
Outlet: () => <div data-testid="outlet" />,
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
to: string;
|
||||
params?: Record<string, string>;
|
||||
search?: Record<string, unknown>;
|
||||
}) => <a href={to}>{children}</a>,
|
||||
useRouterState: () => "/management/migrations",
|
||||
}));
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
import { OfficialMigrationRoute, Route } from "../routes/management/migrations";
|
||||
|
||||
function renderRoute() {
|
||||
render(createElement(OfficialMigrationRoute as never));
|
||||
}
|
||||
|
||||
describe("official migration readiness route", () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset();
|
||||
useAuthStatusMock.mockReset();
|
||||
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:mod", role: "moderator" },
|
||||
});
|
||||
useQueryMock.mockReturnValue({
|
||||
readyCount: 1,
|
||||
blockedCount: 1,
|
||||
generatedAt: Date.UTC(2026, 4, 1),
|
||||
items: [
|
||||
{
|
||||
bundledPluginId: "opik",
|
||||
displayName: "Opik",
|
||||
desiredPackageName: "@opik/opik-openclaw",
|
||||
publisherHandle: "opik",
|
||||
sourceRepo: "comet-ml/opik-openclaw",
|
||||
sourcePath: ".",
|
||||
sourceCommit: "abc1234567890",
|
||||
sourceRef: null,
|
||||
requiredHostTargets: ["darwin-arm64", "linux-x64-glibc", "win32-x64"],
|
||||
readinessState: "ready-for-openclaw",
|
||||
blockers: [],
|
||||
gates: {
|
||||
packageExists: true,
|
||||
releaseExists: true,
|
||||
clawpackAvailable: true,
|
||||
hostMatrixComplete: true,
|
||||
environmentComplete: true,
|
||||
sourceLinked: true,
|
||||
scanClear: true,
|
||||
runtimeBundleStatus: "not-required",
|
||||
},
|
||||
package: {
|
||||
packageId: "packages:1",
|
||||
name: "@opik/opik-openclaw",
|
||||
displayName: "Opik",
|
||||
family: "code-plugin",
|
||||
runtimeId: "opik",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
scanStatus: "clean",
|
||||
updatedAt: Date.UTC(2026, 4, 1),
|
||||
},
|
||||
latestRelease: {
|
||||
releaseId: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: Date.UTC(2026, 4, 1),
|
||||
clawpackSha256: "a".repeat(64),
|
||||
clawpackFileCount: 5,
|
||||
hostTargetKeys: ["darwin-arm64", "linux-x64-glibc", "win32-x64"],
|
||||
environmentFlags: ["network"],
|
||||
scanStatus: "clean",
|
||||
},
|
||||
},
|
||||
{
|
||||
bundledPluginId: "qqbot",
|
||||
displayName: "QQbot",
|
||||
desiredPackageName: "@tencent-connect/openclaw-qqbot",
|
||||
publisherHandle: "tencent-connect",
|
||||
sourceRepo: "tencent-connect/openclaw-qqbot",
|
||||
sourcePath: ".",
|
||||
sourceCommit: null,
|
||||
sourceRef: null,
|
||||
requiredHostTargets: ["darwin-arm64", "linux-x64-glibc", "win32-x64"],
|
||||
readinessState: "clawpack-missing",
|
||||
blockers: ["clawpack-missing", "source-ref-missing"],
|
||||
gates: {
|
||||
packageExists: true,
|
||||
releaseExists: true,
|
||||
clawpackAvailable: false,
|
||||
hostMatrixComplete: false,
|
||||
environmentComplete: false,
|
||||
sourceLinked: false,
|
||||
scanClear: false,
|
||||
runtimeBundleStatus: "not-required",
|
||||
},
|
||||
package: {
|
||||
packageId: "packages:2",
|
||||
name: "@tencent-connect/openclaw-qqbot",
|
||||
displayName: "QQbot",
|
||||
family: "code-plugin",
|
||||
runtimeId: "qqbot",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
scanStatus: "pending",
|
||||
updatedAt: Date.UTC(2026, 4, 1),
|
||||
},
|
||||
latestRelease: {
|
||||
releaseId: "packageReleases:2",
|
||||
version: "1.0.0",
|
||||
createdAt: Date.UTC(2026, 4, 1),
|
||||
clawpackSha256: null,
|
||||
clawpackFileCount: null,
|
||||
hostTargetKeys: [],
|
||||
environmentFlags: [],
|
||||
scanStatus: "pending",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the dedicated migrations route", () => {
|
||||
expect(Route).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders readiness counts, gates, blockers, and deep links", () => {
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "OpenClaw migration readiness" })).toBeTruthy();
|
||||
expect(screen.getByText("Plugin operations")).toBeTruthy();
|
||||
expect(screen.getByText("/publish-plugin")).toBeTruthy();
|
||||
expect(screen.getByText("/management/moderation")).toBeTruthy();
|
||||
expect(screen.getByText("/management/clawpacks")).toBeTruthy();
|
||||
expect(screen.getByText("Opik")).toBeTruthy();
|
||||
expect(screen.getByText("QQbot")).toBeTruthy();
|
||||
expect(screen.getByText("ready for openclaw")).toBeTruthy();
|
||||
expect(screen.getByText("claw pack missing")).toBeTruthy();
|
||||
expect(screen.getByText("5 files / aaaaaaaaaaaa")).toBeTruthy();
|
||||
expect(screen.getByText(/claw pack missing, source ref missing/i)).toBeTruthy();
|
||||
expect(screen.getAllByRole("link", { name: "Plugin page" })[0]?.getAttribute("href")).toBe(
|
||||
"/plugins/$name",
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: "Details" })[0]?.getAttribute("href")).toBe(
|
||||
"/management/migrations/$bundledPluginId",
|
||||
);
|
||||
});
|
||||
|
||||
it("explains missing management role for non-staff users", () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:member", role: "user" },
|
||||
});
|
||||
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByText("Management access required")).toBeTruthy();
|
||||
expect(screen.getByText(/role user/i)).toBeTruthy();
|
||||
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: (path: string) => (config: { component: unknown }) => ({
|
||||
__config: config,
|
||||
__path: path,
|
||||
}),
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
to: string;
|
||||
params?: Record<string, string>;
|
||||
search?: Record<string, unknown>;
|
||||
}) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const useMutationMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
const setVerdict = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useMutation: () => useMutationMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
import { PluginModerationRoute, Route } from "../routes/management/moderation";
|
||||
|
||||
function renderRoute() {
|
||||
render(createElement(PluginModerationRoute as never));
|
||||
}
|
||||
|
||||
describe("plugin moderation route", () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset();
|
||||
useMutationMock.mockReset();
|
||||
useAuthStatusMock.mockReset();
|
||||
setVerdict.mockReset();
|
||||
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:mod", role: "moderator" },
|
||||
});
|
||||
useMutationMock.mockReturnValue(setVerdict);
|
||||
setVerdict.mockResolvedValue({ ok: true });
|
||||
useQueryMock.mockReturnValue({
|
||||
status: "needs-review",
|
||||
limit: 30,
|
||||
hasMore: false,
|
||||
counts: {
|
||||
"needs-review": { value: 3, capped: false },
|
||||
pending: { value: 1, capped: false },
|
||||
suspicious: { value: 2, capped: false },
|
||||
malicious: { value: 0, capped: false },
|
||||
"not-run": { value: 0, capped: false },
|
||||
clean: { value: 100, capped: true },
|
||||
},
|
||||
items: [
|
||||
{
|
||||
packageId: "packages:1",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
ownerHandle: "openclaw",
|
||||
ownerKind: "org",
|
||||
summary: "A demo plugin.",
|
||||
latestVersion: "1.2.3",
|
||||
runtimeId: "demo.plugin",
|
||||
executesCode: true,
|
||||
verificationTier: "structural",
|
||||
clawpackAvailable: true,
|
||||
hostTargetKeys: ["darwin-arm64", "linux-x64-glibc"],
|
||||
environmentFlags: ["network", "desktop"],
|
||||
scanStatus: "suspicious",
|
||||
updatedAt: Date.UTC(2026, 0, 2),
|
||||
latestRelease: {
|
||||
releaseId: "packageReleases:1",
|
||||
version: "1.2.3",
|
||||
createdAt: Date.UTC(2026, 0, 1),
|
||||
clawpackAvailable: true,
|
||||
clawpackSha256: "a".repeat(64),
|
||||
clawpackFileCount: 4,
|
||||
clawpackSize: 2048,
|
||||
clawpackManifestSha256: "b".repeat(64),
|
||||
source: {
|
||||
kind: "github",
|
||||
repo: "openclaw/demo-plugin",
|
||||
url: "https://github.com/openclaw/demo-plugin",
|
||||
ref: "refs/tags/v1.2.3",
|
||||
commit: "abc1234567890",
|
||||
path: ".",
|
||||
},
|
||||
verificationScanStatus: "suspicious",
|
||||
vtStatus: "clean",
|
||||
vtVerdict: "clean",
|
||||
llmStatus: "clean",
|
||||
llmVerdict: "clean",
|
||||
llmSummary: "No policy findings.",
|
||||
staticScanStatus: "suspicious",
|
||||
staticScanSummary: "Dynamic execution found.",
|
||||
staticScanReasonCodes: ["suspicious.dynamic_code_execution"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the dedicated moderation route", () => {
|
||||
expect(Route).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders plugin risk, Claw Pack, and compatibility context", () => {
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Plugin moderation" })).toBeTruthy();
|
||||
expect(screen.getByText("Plugin operations")).toBeTruthy();
|
||||
expect(screen.getByText("/publish-plugin")).toBeTruthy();
|
||||
expect(screen.getByText("/management/clawpacks")).toBeTruthy();
|
||||
expect(screen.getByText("/management/migrations")).toBeTruthy();
|
||||
expect(screen.getByText("Demo Plugin")).toBeTruthy();
|
||||
expect(screen.getAllByText("suspicious").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Code Plugin")).toBeTruthy();
|
||||
expect(screen.getByText("4 files / aaaaaaaaaaaa")).toBeTruthy();
|
||||
expect(screen.getByText("openclaw/demo-plugin / refs/tags/v1.2.3 / .")).toBeTruthy();
|
||||
expect(screen.getByText("suspicious / suspicious.dynamic_code_execution")).toBeTruthy();
|
||||
expect(screen.getByText("clean / clean / No policy findings.")).toBeTruthy();
|
||||
expect(screen.getByText("zip aaaaaaaaaaaa / manifest bbbbbbbbbbbb")).toBeTruthy();
|
||||
expect(screen.getByText("darwin-arm64")).toBeTruthy();
|
||||
expect(screen.getByText("linux-x64-glibc")).toBeTruthy();
|
||||
expect(screen.getByText("100+")).toBeTruthy();
|
||||
expect(screen.getByText("Needs review")).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Manage" }).getAttribute("href")).toBe("/management");
|
||||
});
|
||||
|
||||
it("requires an audit note and confirms before writing a verdict", async () => {
|
||||
const prompt = vi.spyOn(window, "prompt").mockReturnValue("reviewed source and artifact");
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
renderRoute();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Approve clean" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setVerdict).toHaveBeenCalledWith({
|
||||
packageId: "packages:1",
|
||||
verdict: "clean",
|
||||
note: "reviewed source and artifact",
|
||||
});
|
||||
});
|
||||
expect(prompt).toHaveBeenCalledWith("Audit note for demo-plugin -> clean");
|
||||
expect(confirm.mock.calls[0]?.[0]).toContain(
|
||||
"writes a package moderation verdict and audit log in Convex",
|
||||
);
|
||||
|
||||
prompt.mockRestore();
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it("blocks non-staff users", () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:member", role: "user" },
|
||||
});
|
||||
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByText("Management access required")).toBeTruthy();
|
||||
expect(screen.getByText(/role user/i)).toBeTruthy();
|
||||
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: (path: string) => (config: { component: unknown }) => ({
|
||||
__config: config,
|
||||
__path: path,
|
||||
useParams: () => ({ name: "demo-plugin" }),
|
||||
}),
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
to: string;
|
||||
params?: Record<string, string>;
|
||||
search?: Record<string, unknown>;
|
||||
}) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const useMutationMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
const setBatch = vi.fn();
|
||||
const setVerdict = vi.fn();
|
||||
const revokeClawPack = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useMutation: () => useMutationMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
import { PluginManagementDetailPage, Route } from "../routes/management/plugins/$name";
|
||||
|
||||
function renderRoute() {
|
||||
render(createElement(PluginManagementDetailPage as never, { name: "demo-plugin" }));
|
||||
}
|
||||
|
||||
describe("plugin management detail route", () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset();
|
||||
useMutationMock.mockReset();
|
||||
useAuthStatusMock.mockReset();
|
||||
setBatch.mockReset();
|
||||
setVerdict.mockReset();
|
||||
revokeClawPack.mockReset();
|
||||
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:mod", role: "moderator" },
|
||||
});
|
||||
useMutationMock.mockReturnValue((args: Record<string, unknown>) => {
|
||||
if ("releaseId" in args) return revokeClawPack(args);
|
||||
if ("verdict" in args) return setVerdict(args);
|
||||
return setBatch(args);
|
||||
});
|
||||
setBatch.mockResolvedValue({ ok: true });
|
||||
setVerdict.mockResolvedValue({ ok: true });
|
||||
revokeClawPack.mockResolvedValue({ ok: true });
|
||||
useQueryMock.mockReturnValue({
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
_creationTime: 1,
|
||||
name: "demo-plugin",
|
||||
normalizedName: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
ownerUserId: "users:owner",
|
||||
summary: "A demo plugin.",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
latestVersion: "1.2.3",
|
||||
executesCode: true,
|
||||
runtimeId: "demo.plugin",
|
||||
verification: { tier: "structural" },
|
||||
scanStatus: "suspicious",
|
||||
stats: {},
|
||||
createdAt: Date.UTC(2026, 0, 1),
|
||||
updatedAt: Date.UTC(2026, 0, 2),
|
||||
},
|
||||
latestRelease: {
|
||||
_id: "packageReleases:1",
|
||||
_creationTime: 1,
|
||||
packageId: "packages:1",
|
||||
version: "1.2.3",
|
||||
files: [],
|
||||
tags: ["latest"],
|
||||
createdAt: Date.UTC(2026, 0, 1),
|
||||
clawpackStorageId: "storage:1",
|
||||
clawpackSha256: "a".repeat(64),
|
||||
clawpackManifestSha256: "b".repeat(64),
|
||||
clawpackFileCount: 4,
|
||||
clawpackSize: 4096,
|
||||
clawpackBuiltAt: Date.UTC(2026, 0, 3),
|
||||
hostTargetsSummary: [{ os: "darwin", arch: "arm64" }],
|
||||
environmentSummary: { requiresNetwork: true, requiresExternalServices: ["opik"] },
|
||||
source: {
|
||||
repo: "openclaw/demo-plugin",
|
||||
ref: "refs/tags/v1.2.3",
|
||||
path: ".",
|
||||
commit: "abcdef1234567890",
|
||||
},
|
||||
verification: { scanStatus: "suspicious" },
|
||||
staticScan: { status: "suspicious" },
|
||||
vtAnalysis: { status: "clean" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
},
|
||||
owner: { handle: "openclaw" },
|
||||
highlighted: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the dedicated plugin detail route", () => {
|
||||
expect(Route).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders package, Claw Pack, and release provenance details", () => {
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Plugin package detail" })).toBeTruthy();
|
||||
expect(screen.getByText("Demo Plugin")).toBeTruthy();
|
||||
expect(screen.getByText("demo.plugin")).toBeTruthy();
|
||||
expect(screen.getByText(/active /)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("darwin-arm64")).toBeTruthy();
|
||||
expect(screen.getByText("network, service:opik")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("openclaw/demo-plugin / refs/tags/v1.2.3 / . / abcdef123456"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirms and writes moderation verdicts", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
|
||||
|
||||
renderRoute();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Required"), {
|
||||
target: { value: "reviewed clawpack and source" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save verdict" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setVerdict).toHaveBeenCalledWith({
|
||||
packageId: "packages:1",
|
||||
verdict: "suspicious",
|
||||
note: "reviewed clawpack and source",
|
||||
});
|
||||
});
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining("writes a package moderation verdict and audit log in Convex"),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a revocation reason before Claw Pack writes", async () => {
|
||||
vi.spyOn(window, "prompt").mockReturnValueOnce("bad artifact");
|
||||
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
|
||||
|
||||
renderRoute();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Revoke Claw Pack" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(revokeClawPack).toHaveBeenCalledWith({
|
||||
releaseId: "packageReleases:1",
|
||||
reason: "bad artifact",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks non-staff users", () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:member", role: "user" },
|
||||
});
|
||||
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByText("Management access required")).toBeTruthy();
|
||||
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: (path: string) => (config: { component: unknown }) => ({
|
||||
__config: config,
|
||||
__path: path,
|
||||
}),
|
||||
Outlet: () => <div data-testid="outlet" />,
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
to: string;
|
||||
params?: Record<string, string>;
|
||||
search?: Record<string, unknown>;
|
||||
}) => <a href={to}>{children}</a>,
|
||||
useRouterState: () => "/management/plugins",
|
||||
}));
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
import { PluginManagementRoute, Route } from "../routes/management/plugins";
|
||||
|
||||
function renderRoute() {
|
||||
render(createElement(PluginManagementRoute as never));
|
||||
}
|
||||
|
||||
describe("plugin management route", () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset();
|
||||
useAuthStatusMock.mockReset();
|
||||
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:mod", role: "moderator" },
|
||||
});
|
||||
useQueryMock.mockReturnValue({
|
||||
status: "needs-review",
|
||||
limit: 30,
|
||||
hasMore: false,
|
||||
items: [
|
||||
{
|
||||
packageId: "packages:1",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
ownerHandle: "openclaw",
|
||||
summary: "A demo plugin.",
|
||||
latestVersion: "1.2.3",
|
||||
runtimeId: "demo.plugin",
|
||||
executesCode: true,
|
||||
verificationTier: "structural",
|
||||
clawpackAvailable: true,
|
||||
hostTargetKeys: ["darwin-arm64", "linux-x64-glibc"],
|
||||
environmentFlags: ["network", "desktop"],
|
||||
scanStatus: "suspicious",
|
||||
updatedAt: Date.UTC(2026, 0, 2),
|
||||
latestRelease: {
|
||||
releaseId: "packageReleases:1",
|
||||
version: "1.2.3",
|
||||
createdAt: Date.UTC(2026, 0, 1),
|
||||
clawpackAvailable: true,
|
||||
clawpackSha256: "a".repeat(64),
|
||||
clawpackFileCount: 4,
|
||||
source: {
|
||||
repo: "openclaw/demo-plugin",
|
||||
ref: "refs/tags/v1.2.3",
|
||||
path: ".",
|
||||
},
|
||||
verificationScanStatus: "suspicious",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the dedicated plugin management route", () => {
|
||||
expect(Route).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders plugin queue rows with management drilldown links", () => {
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Plugin management" })).toBeTruthy();
|
||||
expect(screen.getByText("Plugin operations")).toBeTruthy();
|
||||
expect(screen.getByText("/management/plugins")).toBeTruthy();
|
||||
expect(screen.getByText("Demo Plugin")).toBeTruthy();
|
||||
expect(screen.getByText("suspicious")).toBeTruthy();
|
||||
expect(screen.getByText("4 files / aaaaaaaaaaaa")).toBeTruthy();
|
||||
expect(screen.getByText("openclaw/demo-plugin / refs/tags/v1.2.3 / .")).toBeTruthy();
|
||||
expect(screen.getByText("darwin-arm64, linux-x64-glibc")).toBeTruthy();
|
||||
expect(screen.getByText("network, desktop")).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Manage" }).getAttribute("href")).toBe(
|
||||
"/management/plugins/$name",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the query status filter", () => {
|
||||
renderRoute();
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue("Needs review"), { target: { value: "clean" } });
|
||||
|
||||
expect(useQueryMock).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
status: "clean",
|
||||
limit: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks non-staff users", () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:member", role: "user" },
|
||||
});
|
||||
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByText("Management access required")).toBeTruthy();
|
||||
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: (path: string) => (config: { component: unknown }) => ({
|
||||
__config: config,
|
||||
__path: path,
|
||||
}),
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
to: string;
|
||||
search?: Record<string, unknown>;
|
||||
}) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const useMutationMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
const setRole = vi.fn();
|
||||
const banUser = vi.fn();
|
||||
const unbanUser = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useMutation: () => useMutationMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
import { Route, UserManagementRoute } from "../routes/management/users";
|
||||
|
||||
function renderRoute() {
|
||||
render(createElement(UserManagementRoute as never));
|
||||
}
|
||||
|
||||
describe("user management route", () => {
|
||||
beforeEach(() => {
|
||||
useQueryMock.mockReset();
|
||||
useMutationMock.mockReset();
|
||||
useAuthStatusMock.mockReset();
|
||||
setRole.mockReset();
|
||||
banUser.mockReset();
|
||||
unbanUser.mockReset();
|
||||
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:admin", role: "admin" },
|
||||
});
|
||||
useMutationMock.mockReturnValue((args: Record<string, unknown>) => {
|
||||
if ("role" in args) return setRole(args);
|
||||
if ("reason" in args && args.userId === "users:banned") return unbanUser(args);
|
||||
return banUser(args);
|
||||
});
|
||||
setRole.mockResolvedValue({ ok: true });
|
||||
banUser.mockResolvedValue({ ok: true });
|
||||
unbanUser.mockResolvedValue({ ok: true });
|
||||
useQueryMock.mockReturnValue({
|
||||
total: 2,
|
||||
items: [
|
||||
{
|
||||
_id: "users:target",
|
||||
_creationTime: Date.UTC(2026, 0, 1),
|
||||
handle: "target",
|
||||
name: "target",
|
||||
email: "target@example.com",
|
||||
role: "user",
|
||||
createdAt: Date.UTC(2026, 0, 1),
|
||||
},
|
||||
{
|
||||
_id: "users:banned",
|
||||
_creationTime: Date.UTC(2026, 0, 2),
|
||||
handle: "banned",
|
||||
name: "banned",
|
||||
email: "banned@example.com",
|
||||
role: "moderator",
|
||||
createdAt: Date.UTC(2026, 0, 2),
|
||||
deletedAt: Date.UTC(2026, 0, 3),
|
||||
banReason: "malware",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the dedicated user management route", () => {
|
||||
expect(Route).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders role management and confirms moderator writes", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
|
||||
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "User roles" })).toBeTruthy();
|
||||
expect(screen.getByText("/management/users")).toBeTruthy();
|
||||
expect(screen.getByText("@target")).toBeTruthy();
|
||||
expect(screen.getByText("malware")).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getAllByRole("combobox")[0]!, { target: { value: "moderator" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setRole).toHaveBeenCalledWith({
|
||||
userId: "users:target",
|
||||
role: "moderator",
|
||||
});
|
||||
});
|
||||
expect(window.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Set @target role from user to moderator"),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a reason for ban writes", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValueOnce(true);
|
||||
vi.spyOn(window, "prompt").mockReturnValueOnce("security abuse");
|
||||
|
||||
renderRoute();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Ban" })[0]!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(banUser).toHaveBeenCalledWith({
|
||||
userId: "users:target",
|
||||
reason: "security abuse",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks non-admin users", () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:mod", role: "moderator" },
|
||||
});
|
||||
|
||||
renderRoute();
|
||||
|
||||
expect(screen.getByText("Management access required")).toBeTruthy();
|
||||
expect(useQueryMock).toHaveBeenCalledWith(expect.anything(), "skip");
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,30 @@ function makeCodePluginPackageJson(overrides: Record<string, unknown>) {
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function textResponse(body: string, contentType = "text/plain") {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
}
|
||||
|
||||
function byteLength(value: string) {
|
||||
return new TextEncoder().encode(value).byteLength;
|
||||
}
|
||||
|
||||
function fetchInputUrl(input: RequestInfo | URL) {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
return input.url;
|
||||
}
|
||||
|
||||
function getFileInput() {
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
if (!(input instanceof HTMLInputElement)) throw new Error("Missing file input");
|
||||
@@ -140,7 +164,7 @@ describe("plugins publish route", () => {
|
||||
expect(archiveClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens only the archive picker when clicking Browse files", () => {
|
||||
it("opens only the archive picker when clicking Upload ZIP/TGZ", () => {
|
||||
renderPublishRoute();
|
||||
|
||||
const [archiveInput, directoryInput] = getFileInputs();
|
||||
@@ -149,7 +173,10 @@ describe("plugins publish route", () => {
|
||||
archiveInput.click = archiveClick;
|
||||
directoryInput.click = directoryClick;
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Browse files" }));
|
||||
expect(archiveInput.getAttribute("accept")).toContain(".zip");
|
||||
expect(archiveInput.getAttribute("accept")).toContain(".tgz");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upload ZIP/TGZ" }));
|
||||
|
||||
expect(archiveClick).toHaveBeenCalledTimes(1);
|
||||
expect(directoryClick).not.toHaveBeenCalled();
|
||||
@@ -232,6 +259,281 @@ describe("plugins publish route", () => {
|
||||
]),
|
||||
}),
|
||||
});
|
||||
expect(screen.getByText("demo-plugin@1.2.3")).toBeTruthy();
|
||||
expect(screen.queryByRole("link", { name: "View release" })).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "Download Claw Pack" })).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "Open dashboard" }).getAttribute("href")).toBe(
|
||||
"/dashboard",
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches a GitHub URL, fills the form, and publishes the resolved package files", async () => {
|
||||
const commit = "a".repeat(40);
|
||||
const tree = "b".repeat(40);
|
||||
const blob = "c".repeat(40);
|
||||
const packageJsonBody = makeCodePluginPackageJson({
|
||||
name: "github-plugin",
|
||||
displayName: "GitHub Plugin",
|
||||
version: "2.0.0",
|
||||
repository: "https://github.com/owner/repo.git",
|
||||
});
|
||||
const pluginManifestBody = '{"id":"github.plugin","name":"GitHub Plugin"}';
|
||||
const sourceBody = "export const githubPlugin = true;\n";
|
||||
fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = fetchInputUrl(input);
|
||||
if (url === "https://api.github.com/repos/owner/repo/commits/main") {
|
||||
return jsonResponse({ sha: commit, commit: { tree: { sha: tree } } });
|
||||
}
|
||||
if (url.startsWith("https://api.github.com/repos/owner/repo/commits/")) {
|
||||
return jsonResponse({}, 404);
|
||||
}
|
||||
if (url === `https://api.github.com/repos/owner/repo/git/trees/${tree}?recursive=1`) {
|
||||
return jsonResponse({
|
||||
tree: [
|
||||
{
|
||||
path: "packages/demo/package.json",
|
||||
type: "blob",
|
||||
sha: blob,
|
||||
size: byteLength(packageJsonBody),
|
||||
},
|
||||
{
|
||||
path: "packages/demo/openclaw.plugin.json",
|
||||
type: "blob",
|
||||
sha: blob,
|
||||
size: byteLength(pluginManifestBody),
|
||||
},
|
||||
{
|
||||
path: "packages/demo/index.ts",
|
||||
type: "blob",
|
||||
sha: blob,
|
||||
size: byteLength(sourceBody),
|
||||
},
|
||||
{ path: "other/package.json", type: "blob", sha: blob, size: 2 },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (
|
||||
url === `https://raw.githubusercontent.com/owner/repo/${commit}/packages/demo/package.json`
|
||||
) {
|
||||
return textResponse(packageJsonBody, "application/json");
|
||||
}
|
||||
if (
|
||||
url ===
|
||||
`https://raw.githubusercontent.com/owner/repo/${commit}/packages/demo/openclaw.plugin.json`
|
||||
) {
|
||||
return textResponse(pluginManifestBody, "application/json");
|
||||
}
|
||||
if (url === `https://raw.githubusercontent.com/owner/repo/${commit}/packages/demo/index.ts`) {
|
||||
return textResponse(sourceBody, "text/plain");
|
||||
}
|
||||
if (url === "https://upload.local") {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
storageId: `storage:${((init?.body as File | undefined)?.name ?? "unknown").replaceAll("/", "_")}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
|
||||
renderPublishRoute();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("GitHub plugin source URL"), {
|
||||
target: { value: "https://github.com/owner/repo/tree/main/packages/demo" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use URL" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Fetched 3 files from owner\/repo/i)).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("github-plugin")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("GitHub Plugin")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("2.0.0")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue(commit)).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("packages/demo")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Changelog"), {
|
||||
target: { value: "Fetched from GitHub" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Publish" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(publishRelease).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(generateUploadUrl).toHaveBeenCalledTimes(3);
|
||||
expect(publishRelease).toHaveBeenCalledWith({
|
||||
payload: expect.objectContaining({
|
||||
name: "github-plugin",
|
||||
displayName: "GitHub Plugin",
|
||||
family: "code-plugin",
|
||||
version: "2.0.0",
|
||||
changelog: "Fetched from GitHub",
|
||||
source: expect.objectContaining({
|
||||
repo: "owner/repo",
|
||||
url: "https://github.com/owner/repo",
|
||||
ref: "main",
|
||||
commit,
|
||||
path: "packages/demo",
|
||||
}),
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({ path: "package.json" }),
|
||||
expect.objectContaining({ path: "openclaw.plugin.json" }),
|
||||
expect.objectContaining({ path: "index.ts" }),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("previews the generated Claw Pack manifest before publish", async () => {
|
||||
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",
|
||||
);
|
||||
const suppliedClawPack = withRelativePath(
|
||||
new File(['{"kind":"user.supplied"}'], "CLAWPACK.json", { type: "application/json" }),
|
||||
"demo-plugin/CLAWPACK.json",
|
||||
);
|
||||
|
||||
fireEvent.change(getFileInput(), {
|
||||
target: { files: [packageJson, manifest, dist, suppliedClawPack] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Claw Pack preview" })).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Claw Pack checks" })).toBeTruthy();
|
||||
expect(screen.getByText("Archive contract")).toBeTruthy();
|
||||
expect(screen.getByText("Source provenance")).toBeTruthy();
|
||||
expect(screen.getByText("Platform matrix")).toBeTruthy();
|
||||
expect(screen.getByText("Security review")).toBeTruthy();
|
||||
expect(screen.getByText("4 files including CLAWPACK.json")).toBeTruthy();
|
||||
expect(screen.getAllByText("darwin-arm64").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("linux-x64-glibc").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("win32-x64").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Existing pack manifest will be replaced by ClawHub.")).toBeTruthy();
|
||||
expect(screen.getByText(/"kind": "openclaw\.clawpack"/i)).toBeTruthy();
|
||||
expect(screen.getByText(/"sha256": "computed-on-publish"/i)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Copy Claw Pack preview manifest" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("imports a Claw Pack archive as package source before publish", async () => {
|
||||
renderPublishRoute();
|
||||
|
||||
const clawpackManifest = withRelativePath(
|
||||
new File(
|
||||
[
|
||||
JSON.stringify({
|
||||
kind: "openclaw.clawpack",
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.2.3",
|
||||
family: "code-plugin",
|
||||
},
|
||||
release: {
|
||||
source: {
|
||||
repo: "openclaw/demo-plugin",
|
||||
commit: "abc123",
|
||||
ref: "refs/tags/v1.2.3",
|
||||
path: ".",
|
||||
},
|
||||
},
|
||||
hostTargets: [
|
||||
{ os: "darwin", arch: "arm64" },
|
||||
{ os: "linux", arch: "x64", libc: "glibc" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
"CLAWPACK.json",
|
||||
{ type: "application/json" },
|
||||
),
|
||||
"demo.clawpack/CLAWPACK.json",
|
||||
);
|
||||
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.clawpack/package/package.json",
|
||||
);
|
||||
const pluginManifest = withRelativePath(
|
||||
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
|
||||
"demo.clawpack/package/openclaw.plugin.json",
|
||||
);
|
||||
|
||||
fireEvent.change(getFileInput(), {
|
||||
target: { files: [clawpackManifest, packageJson, pluginManifest] },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Claw Pack import")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("demo-plugin")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("Demo Plugin")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("1.2.3")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("abc123")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Changelog"), {
|
||||
target: { value: "Imported Claw Pack" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Publish" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(publishRelease).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(publishRelease).toHaveBeenCalledWith({
|
||||
payload: expect.objectContaining({
|
||||
name: "demo-plugin",
|
||||
version: "1.2.3",
|
||||
source: expect.objectContaining({
|
||||
repo: "openclaw/demo-plugin",
|
||||
commit: "abc123",
|
||||
ref: "refs/tags/v1.2.3",
|
||||
}),
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({ path: "package.json" }),
|
||||
expect.objectContaining({ path: "openclaw.plugin.json" }),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
const payload = publishRelease.mock.calls[0]?.[0]?.payload as {
|
||||
files: Array<{ path: string }>;
|
||||
};
|
||||
expect(payload.files.some((file) => file.path.toLowerCase() === "clawpack.json")).toBe(false);
|
||||
expect(payload.files.some((file) => file.path.startsWith("package/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces missing OpenClaw compatibility metadata before publish", async () => {
|
||||
@@ -275,8 +577,8 @@ describe("plugins publish route", () => {
|
||||
expect(screen.getByText(/Missing required OpenClaw package metadata:/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/openclaw\.compat\.pluginApi/i)).toBeTruthy();
|
||||
expect(screen.getByText(/openclaw\.build\.openclawVersion/i)).toBeTruthy();
|
||||
expect(screen.getAllByText(/openclaw\.compat\.pluginApi/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/openclaw\.build\.openclawVersion/i).length).toBeGreaterThan(0);
|
||||
const docsLink = screen.getByRole("link", { name: /Plugin Setup and Config/i });
|
||||
expect(docsLink.getAttribute("href")).toBe(
|
||||
"https://docs.openclaw.ai/plugins/sdk-setup#package-metadata",
|
||||
@@ -297,7 +599,7 @@ describe("plugins publish route", () => {
|
||||
fireEvent.change(getFileInput(), { target: { files: [bigFile] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Each file must be 10MB or smaller/i)).toBeTruthy();
|
||||
expect(screen.getAllByText(/Each file must be 10MB or smaller/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const summaryBorders = document.querySelectorAll(".border-emerald-300\\/40");
|
||||
@@ -343,7 +645,7 @@ describe("plugins publish route", () => {
|
||||
expect((screen.getAllByRole("combobox")[0] as HTMLSelectElement).value).toBe("bundle-plugin");
|
||||
expect(screen.getByDisplayValue("openclaw-bundle")).toBeTruthy();
|
||||
expect(screen.getByDisplayValue("desktop, mobile")).toBeTruthy();
|
||||
expect(screen.getByText(/Browse files/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Upload ZIP\/TGZ/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Choose folder/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -533,7 +835,9 @@ describe("plugins publish route", () => {
|
||||
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, huge] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Each file must be 10MB or smaller: plugin\.wasm/i)).toBeTruthy();
|
||||
expect(
|
||||
screen.getAllByText(/Each file must be 10MB or smaller: plugin\.wasm/i).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Publish" }).getAttribute("disabled")).not.toBeNull();
|
||||
expect(publishRelease).not.toHaveBeenCalled();
|
||||
@@ -577,8 +881,11 @@ describe("plugins publish route", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Publish" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Pending security checks and verification before public listing\./i),
|
||||
).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getAllByText(/Pending security checks and verification before public listing\./i)
|
||||
.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
component?: unknown;
|
||||
head?: unknown;
|
||||
}) => ({ __config: config }),
|
||||
notFound: () => ({ notFound: true }),
|
||||
redirect: (options: unknown) => ({ redirect: options }),
|
||||
}));
|
||||
|
||||
@@ -93,6 +94,12 @@ describe("skill route loader", () => {
|
||||
expect(() => runBeforeLoad({ owner: "publishers:abc123", slug: "weather" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("blocks static asset paths from the skill route", async () => {
|
||||
await expect(runBeforeLoad({ owner: "assets", slug: "index-old.js" })).rejects.toEqual({
|
||||
notFound: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSkillPageDataMock.mockReset();
|
||||
});
|
||||
@@ -268,7 +275,7 @@ describe("skill route loader", () => {
|
||||
links: [
|
||||
{
|
||||
rel: "canonical",
|
||||
href: "https://clawhub.ai/steipete/weather",
|
||||
href: "http://localhost/steipete/weather",
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -277,14 +284,14 @@ describe("skill route loader", () => {
|
||||
expect.arrayContaining([
|
||||
{ title: "Weather — ClawHub" },
|
||||
{ name: "description", content: "Get current weather." },
|
||||
{ property: "og:url", content: "https://clawhub.ai/steipete/weather" },
|
||||
{ property: "og:url", content: "http://localhost/steipete/weather" },
|
||||
{
|
||||
property: "og:image",
|
||||
content: "https://clawhub.ai/og/skill.png?v=5&slug=weather&owner=steipete&version=1.0.0",
|
||||
content: "http://localhost/og/skill.png?v=5&slug=weather&owner=steipete&version=1.0.0",
|
||||
},
|
||||
{
|
||||
name: "twitter:image",
|
||||
content: "https://clawhub.ai/og/skill.png?v=5&slug=weather&owner=steipete&version=1.0.0",
|
||||
content: "http://localhost/og/skill.png?v=5&slug=weather&owner=steipete&version=1.0.0",
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -295,12 +302,12 @@ describe("skill route loader", () => {
|
||||
links: [
|
||||
{
|
||||
rel: "canonical",
|
||||
href: "https://clawhub.ai/steipete/weather",
|
||||
href: "http://localhost/steipete/weather",
|
||||
},
|
||||
],
|
||||
meta: expect.arrayContaining([
|
||||
{ title: "weather — ClawHub" },
|
||||
{ property: "og:url", content: "https://clawhub.ai/steipete/weather" },
|
||||
{ property: "og:url", content: "http://localhost/steipete/weather" },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
import { Card } from "./ui/card";
|
||||
|
||||
type Props = {
|
||||
me: Doc<"users"> | null | undefined;
|
||||
requiredRole?: "admin" | "moderator";
|
||||
};
|
||||
|
||||
export function ManagementAccessNotice({ me, requiredRole = "moderator" }: Props) {
|
||||
const role = me?.role ?? "none";
|
||||
const requirement = requiredRole === "admin" ? "admin" : "admin or moderator";
|
||||
const identity = me?.handle || me?.displayName || me?.name || me?._id || "not signed in";
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<Card>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">access</span>
|
||||
<strong className="text-[color:var(--ink)]">Management access required</strong>
|
||||
</div>
|
||||
<p className="section-subtitle m-0 mt-2">
|
||||
Signed in as {identity} with role {role}. This page requires {requirement} access.
|
||||
</p>
|
||||
<p className="section-subtitle m-0 mt-2">
|
||||
Use the Users panel on the root management page, or an admin CLI token, to grant the
|
||||
correct role before returning here.
|
||||
</p>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PackageCompatibility } from "clawhub-schema";
|
||||
import { Package } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { GitBranch, Package, UploadCloud } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ClawPackImportSummary } from "../lib/clawpackImport";
|
||||
import { formatPackageCompatibility } from "../lib/pluginPublishPrefill";
|
||||
import { expandDroppedItems } from "../lib/uploadFiles";
|
||||
import { formatBytes } from "../routes/upload/-utils";
|
||||
@@ -17,17 +18,37 @@ export function PackageSourceChooser(props: {
|
||||
normalizedPaths: string[];
|
||||
normalizedPathSet: Set<string>;
|
||||
ignoredPaths: string[];
|
||||
sourceUrl: string;
|
||||
sourceUrlError: string | null;
|
||||
sourceUrlStatus: string | null;
|
||||
sourceUrlBusy: boolean;
|
||||
intakeStatus: string | null;
|
||||
detectedPrefillFields: string[];
|
||||
family: "code-plugin" | "bundle-plugin";
|
||||
validationError: string | null;
|
||||
codePluginFieldIssues: string[];
|
||||
codePluginCompatibility: PackageCompatibility | null;
|
||||
clawPackImport: ClawPackImportSummary | null;
|
||||
hostTargets?: string;
|
||||
onSourceUrlChange: (value: string) => void;
|
||||
onApplySourceUrl: () => void | Promise<void>;
|
||||
onPickFiles: (selected: File[]) => Promise<void>;
|
||||
}) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isClientReady, setIsClientReady] = useState(false);
|
||||
const archiveInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const directoryInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const isMetadataLocked = props.files.length === 0 || Boolean(props.validationError);
|
||||
const hostTargetLabels =
|
||||
props.hostTargets
|
||||
?.split(",")
|
||||
.map((target) => target.trim())
|
||||
.filter(Boolean) ?? [];
|
||||
const effectiveHostTargets =
|
||||
hostTargetLabels.length > 0
|
||||
? hostTargetLabels
|
||||
: ["darwin-arm64", "linux-x64-glibc", "win32-x64"];
|
||||
const environmentSignals = deriveEnvironmentSignals(props.normalizedPaths);
|
||||
|
||||
const setDirectoryInputRef = (node: HTMLInputElement | null) => {
|
||||
directoryInputRef.current = node;
|
||||
@@ -37,13 +58,18 @@ export function PackageSourceChooser(props: {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsClientReady(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="mb-5">
|
||||
<Card className="mb-5" data-upload-ready={isClientReady ? "true" : "false"}>
|
||||
<input
|
||||
ref={archiveInputRef}
|
||||
className="hidden"
|
||||
type="file"
|
||||
multiple
|
||||
aria-label="Package archive input"
|
||||
accept=".zip,.tgz,.tar.gz,application/zip,application/gzip,application/x-gzip,application/x-tar"
|
||||
onChange={(event) => {
|
||||
const selected = Array.from(event.target.files ?? []);
|
||||
@@ -60,6 +86,65 @@ export function PackageSourceChooser(props: {
|
||||
void props.onPickFiles(selected);
|
||||
}}
|
||||
/>
|
||||
<div className="mb-5 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-4">
|
||||
<div className="mb-3">
|
||||
<h2 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Plugin onboarding changed
|
||||
</h2>
|
||||
<p className="m-0 mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
Start with files or a GitHub URL. ClawHub fills what it can and builds the Claw Pack
|
||||
when you publish.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<UploadCloud size={18} className="text-[color:var(--accent)]" />
|
||||
<strong className="text-[color:var(--ink)]">Give us your files</strong>
|
||||
</div>
|
||||
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
|
||||
Drop a folder, zip, tgz, or tarball and we will expand it, ignore local junk, and
|
||||
prefill the form.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<GitBranch size={18} className="text-[color:var(--accent)]" />
|
||||
<strong className="text-[color:var(--ink)]">Paste a GitHub URL</strong>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="min-h-[38px] min-w-0 flex-1 rounded-[var(--radius-sm)] border border-[rgba(29,59,78,0.22)] bg-[rgba(255,255,255,0.94)] px-3 text-sm text-[color:var(--ink)] dark:border-[rgba(255,255,255,0.12)] dark:bg-[rgba(14,28,37,0.84)]"
|
||||
value={props.sourceUrl}
|
||||
onChange={(event) => props.onSourceUrlChange(event.target.value)}
|
||||
placeholder="https://github.com/owner/repo"
|
||||
aria-label="GitHub plugin source URL"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={props.sourceUrlBusy}
|
||||
onClick={() => {
|
||||
void props.onApplySourceUrl();
|
||||
}}
|
||||
>
|
||||
{props.sourceUrlBusy ? "Fetching" : "Use URL"}
|
||||
</Button>
|
||||
</div>
|
||||
{props.sourceUrlStatus ? (
|
||||
<p className="m-0 mt-2 text-xs text-[color:var(--ink-soft)]">
|
||||
{props.sourceUrlStatus}
|
||||
</p>
|
||||
) : null}
|
||||
{props.sourceUrlError ? (
|
||||
<p className="m-0 mt-2 text-xs text-red-700 dark:text-red-200">
|
||||
{props.sourceUrlError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex flex-col items-center gap-4 rounded-[var(--radius-md)] border-2 border-dashed p-8 text-center transition-colors ${
|
||||
isDragging
|
||||
@@ -96,20 +181,46 @@ export function PackageSourceChooser(props: {
|
||||
</span>
|
||||
</div>
|
||||
<span className="max-w-md text-sm text-[color:var(--ink-soft)]">
|
||||
Drag a folder, zip, or tgz here. We inspect the package to unlock and prefill the rest
|
||||
of the form.
|
||||
Drag a package archive, folder, zip, or tgz here. ClawHub expands the source package,
|
||||
ignores local junk, then generates the Claw Pack itself.
|
||||
</span>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={() => archiveInputRef.current?.click()}>
|
||||
Browse files
|
||||
<div className="flex flex-wrap justify-center gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!isClientReady}
|
||||
onClick={() => archiveInputRef.current?.click()}
|
||||
>
|
||||
Upload ZIP/TGZ
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => directoryInputRef.current?.click()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!isClientReady}
|
||||
onClick={() => directoryInputRef.current?.click()}
|
||||
>
|
||||
Choose folder
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid max-w-xl gap-2 pt-2 text-left text-xs text-[color:var(--ink-soft)] sm:grid-cols-2">
|
||||
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3">
|
||||
<strong className="block text-[color:var(--ink)]">archive upload</strong>
|
||||
<span>.zip, .tgz, and .tar.gz are expanded before publish.</span>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3">
|
||||
<strong className="block text-[color:var(--ink)]">folder upload</strong>
|
||||
<span>directory picks preserve paths for manifest and package detection.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.intakeStatus ? (
|
||||
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] px-4 py-3 text-sm text-[color:var(--ink)]">
|
||||
{props.intakeStatus}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`rounded-[var(--radius-sm)] border px-4 py-3 transition-colors ${
|
||||
isMetadataLocked
|
||||
@@ -149,6 +260,7 @@ export function PackageSourceChooser(props: {
|
||||
{props.ignoredPaths.length > 0 ? (
|
||||
<Badge>Ignored {props.ignoredPaths.length} files</Badge>
|
||||
) : null}
|
||||
{props.clawPackImport ? <Badge>Claw Pack import</Badge> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -174,6 +286,50 @@ export function PackageSourceChooser(props: {
|
||||
Compatibility: {formatPackageCompatibility(props.codePluginCompatibility)}
|
||||
</p>
|
||||
) : null}
|
||||
{props.normalizedPaths.length > 0 ? (
|
||||
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<strong className="text-sm text-[color:var(--ink)]">Claw Pack readiness</strong>
|
||||
<span className="text-xs text-[color:var(--ink-soft)]">generated on publish</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<Badge>Deterministic archive</Badge>
|
||||
<Badge>Generated manifest</Badge>
|
||||
{effectiveHostTargets.map((target) => (
|
||||
<Badge key={target} variant="compact">
|
||||
{target}
|
||||
</Badge>
|
||||
))}
|
||||
{environmentSignals.map((signal) => (
|
||||
<Badge key={signal} variant="compact">
|
||||
{signal}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[color:var(--ink-soft)]">
|
||||
{props.clawPackImport
|
||||
? `Imported ${props.clawPackImport.packageFileCount} package files from a Claw Pack archive. ClawHub will rebuild the canonical manifest and digests on publish.`
|
||||
: "ClawHub will package these files with a Claw Pack manifest, host target summary, file digests, and environment hints for OpenClaw clients."}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function deriveEnvironmentSignals(paths: string[]) {
|
||||
const lowerPaths = paths.map((path) => path.toLowerCase());
|
||||
const signals = [
|
||||
lowerPaths.some((path) => path.includes("playwright") || path.includes("browser"))
|
||||
? "browser"
|
||||
: null,
|
||||
lowerPaths.some((path) => path.includes("desktop") || path.includes("imessage"))
|
||||
? "desktop"
|
||||
: null,
|
||||
lowerPaths.some((path) => path.includes("audio") || path.includes("microphone"))
|
||||
? "audio"
|
||||
: null,
|
||||
"network",
|
||||
].filter((signal): signal is string => Boolean(signal));
|
||||
return [...new Set(signals)];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { DatabaseZap, Gauge, ListChecks, ShieldCheck, UploadCloud, UserCog } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
type OperationKey = "publish" | "plugins" | "moderation" | "clawpacks" | "migrations" | "users";
|
||||
|
||||
const pluginPublishSearch = {
|
||||
ownerHandle: undefined,
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
};
|
||||
|
||||
export function PluginOperationsNav({ current }: { current?: OperationKey }) {
|
||||
return (
|
||||
<section className="mb-5">
|
||||
<div className="mb-3 flex flex-col gap-1">
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Plugin operations
|
||||
</h2>
|
||||
<p className="section-subtitle m-0">
|
||||
Publisher, moderation, artifact, and migration surfaces for the ClawHub plugin platform.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-6">
|
||||
<OperationLink
|
||||
current={current === "publish"}
|
||||
icon={<UploadCloud className="h-4 w-4" aria-hidden="true" />}
|
||||
title="Publish plugin"
|
||||
path="/publish-plugin"
|
||||
description="Upload a folder, zip, or Claw Pack archive and review the generated contract."
|
||||
>
|
||||
<Link to="/publish-plugin" search={pluginPublishSearch}>
|
||||
Open publish
|
||||
</Link>
|
||||
</OperationLink>
|
||||
<OperationLink
|
||||
current={current === "plugins"}
|
||||
icon={<ListChecks className="h-4 w-4" aria-hidden="true" />}
|
||||
title="Plugin management"
|
||||
path="/management/plugins"
|
||||
description="Open package drilldowns with release, Claw Pack, badge, and verdict controls."
|
||||
>
|
||||
<Link to="/management/plugins" search={{ skill: undefined, plugin: undefined }}>
|
||||
Open plugins
|
||||
</Link>
|
||||
</OperationLink>
|
||||
<OperationLink
|
||||
current={current === "moderation"}
|
||||
icon={<ShieldCheck className="h-4 w-4" aria-hidden="true" />}
|
||||
title="Plugin moderation"
|
||||
path="/management/moderation"
|
||||
description="Review code and bundle plugins by scan state, Claw Pack status, and release risk."
|
||||
>
|
||||
<Link to="/management/moderation" search={{ skill: undefined, plugin: undefined }}>
|
||||
Open queue
|
||||
</Link>
|
||||
</OperationLink>
|
||||
<OperationLink
|
||||
current={current === "clawpacks"}
|
||||
icon={<DatabaseZap className="h-4 w-4" aria-hidden="true" />}
|
||||
title="Claw Pack ops"
|
||||
path="/management/clawpacks"
|
||||
description="Dry-run migration samples, build artifacts, retry failures, and rebuild lookup rows."
|
||||
>
|
||||
<Link to="/management/clawpacks" search={{ skill: undefined, plugin: undefined }}>
|
||||
Open Claw Pack ops
|
||||
</Link>
|
||||
</OperationLink>
|
||||
<OperationLink
|
||||
current={current === "migrations"}
|
||||
icon={<Gauge className="h-4 w-4" aria-hidden="true" />}
|
||||
title="Migration readiness"
|
||||
path="/management/migrations"
|
||||
description="Track ClawHub gates for future OpenClaw bundled-plugin externalization."
|
||||
>
|
||||
<Link to="/management/migrations" search={{ skill: undefined, plugin: undefined }}>
|
||||
Open readiness
|
||||
</Link>
|
||||
</OperationLink>
|
||||
<OperationLink
|
||||
current={current === "users"}
|
||||
icon={<UserCog className="h-4 w-4" aria-hidden="true" />}
|
||||
title="User roles"
|
||||
path="/management/users"
|
||||
description="Find users, grant moderator/admin roles, and manage account bans."
|
||||
>
|
||||
<Link to="/management/users" search={{ skill: undefined, plugin: undefined }}>
|
||||
Open users
|
||||
</Link>
|
||||
</OperationLink>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationLink({
|
||||
children,
|
||||
current,
|
||||
description,
|
||||
icon,
|
||||
path,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
current: boolean;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
path: string;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3">
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 font-semibold text-[color:var(--ink)]">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
{current ? <Badge variant="compact">current</Badge> : null}
|
||||
</div>
|
||||
<div className="mono mb-2 text-xs text-[color:var(--ink-soft)]">{path}</div>
|
||||
<p className="section-subtitle m-0 mb-3">{description}</p>
|
||||
<div className="text-sm font-semibold text-[color:var(--accent)]">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ describe("skill detail install helpers", () => {
|
||||
});
|
||||
|
||||
expect(prompt).toContain("steipete/weather");
|
||||
expect(prompt).toContain("https://clawhub.ai/steipete/weather");
|
||||
expect(prompt).toContain("http://localhost/steipete/weather");
|
||||
expect(prompt).toContain("WEATHER_API_KEY");
|
||||
expect(prompt).toContain("curl");
|
||||
expect(prompt).toContain("~/.weatherrc");
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeClawPackImport } from "./clawpackImport";
|
||||
|
||||
function withRelativePath(file: File, path: string) {
|
||||
Object.defineProperty(file, "webkitRelativePath", {
|
||||
value: path,
|
||||
configurable: true,
|
||||
});
|
||||
return file;
|
||||
}
|
||||
|
||||
describe("clawpack import", () => {
|
||||
it("unwraps package files and prefill metadata from a Claw Pack archive", async () => {
|
||||
const manifest = withRelativePath(
|
||||
new File(
|
||||
[
|
||||
JSON.stringify({
|
||||
kind: "openclaw.clawpack",
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.2.3",
|
||||
family: "code-plugin",
|
||||
},
|
||||
release: {
|
||||
source: {
|
||||
repo: "openclaw/demo-plugin",
|
||||
commit: "abc123",
|
||||
ref: "refs/tags/v1.2.3",
|
||||
path: ".",
|
||||
},
|
||||
},
|
||||
hostTargets: [
|
||||
{ os: "darwin", arch: "arm64" },
|
||||
{ os: "linux", arch: "x64", libc: "glibc" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
"CLAWPACK.json",
|
||||
{ type: "application/json" },
|
||||
),
|
||||
"demo.clawpack/CLAWPACK.json",
|
||||
);
|
||||
const packageJson = withRelativePath(
|
||||
new File(['{"name":"demo-plugin"}'], "package.json", { type: "application/json" }),
|
||||
"demo.clawpack/package/package.json",
|
||||
);
|
||||
const pluginManifest = withRelativePath(
|
||||
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
|
||||
"demo.clawpack/package/openclaw.plugin.json",
|
||||
);
|
||||
|
||||
const imported = await normalizeClawPackImport([manifest, packageJson, pluginManifest]);
|
||||
|
||||
expect(imported.summary).toMatchObject({
|
||||
packageName: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.2.3",
|
||||
family: "code-plugin",
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
sourceCommit: "abc123",
|
||||
sourceRef: "refs/tags/v1.2.3",
|
||||
sourcePath: ".",
|
||||
hostTargets: ["darwin-arm64", "linux-x64-glibc"],
|
||||
packageFileCount: 2,
|
||||
});
|
||||
expect(imported.files.map((file) => file.name)).toEqual([
|
||||
"package.json",
|
||||
"openclaw.plugin.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects malformed Claw Pack manifests", async () => {
|
||||
const manifest = new File(['{"kind":"other"}'], "CLAWPACK.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
const packageJson = new File(["{}"], "package/package.json", { type: "application/json" });
|
||||
|
||||
await expect(normalizeClawPackImport([manifest, packageJson])).rejects.toThrow(
|
||||
/not an OpenClaw Claw Pack/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { normalizePackageUploadFiles } from "./packageUpload";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type ClawPackImportSummary = {
|
||||
packageName?: string;
|
||||
displayName?: string;
|
||||
version?: string;
|
||||
family?: "code-plugin" | "bundle-plugin";
|
||||
sourceRepo?: string;
|
||||
sourceCommit?: string;
|
||||
sourceRef?: string;
|
||||
sourcePath?: string;
|
||||
hostTargets: string[];
|
||||
packageFileCount: number;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function getClawPackTarget(value: unknown) {
|
||||
if (!isRecord(value)) return null;
|
||||
const os = getString(value.os);
|
||||
const arch = getString(value.arch);
|
||||
const libc = getString(value.libc);
|
||||
if (!os || !arch) return null;
|
||||
return [os, arch, libc].filter(Boolean).join("-");
|
||||
}
|
||||
|
||||
function createPathFile(source: File, path: string) {
|
||||
return new File([source], path, {
|
||||
type: source.type,
|
||||
lastModified: source.lastModified,
|
||||
});
|
||||
}
|
||||
|
||||
function hasGenericPackageRoot(files: Array<{ path: string }>) {
|
||||
return files.some((entry) => {
|
||||
const path = entry.path.toLowerCase();
|
||||
return (
|
||||
path === "package.json" || path === "openclaw.plugin.json" || path === "openclaw.bundle.json"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function normalizeClawPackImport(files: File[]) {
|
||||
const normalized = normalizePackageUploadFiles(files);
|
||||
const manifestEntry = normalized.find((entry) => {
|
||||
const fileName = entry.path.toLowerCase().split("/").at(-1);
|
||||
return fileName === "clawpack.json";
|
||||
});
|
||||
if (!manifestEntry) return { files, summary: null };
|
||||
|
||||
let manifest: JsonRecord;
|
||||
try {
|
||||
const parsed = JSON.parse((await manifestEntry.file.text()).replace(/^\uFEFF/, "")) as unknown;
|
||||
if (!isRecord(parsed)) throw new Error("Invalid manifest");
|
||||
manifest = parsed;
|
||||
} catch {
|
||||
if (hasGenericPackageRoot(normalized)) return { files, summary: null };
|
||||
throw new Error("Claw Pack manifest is not valid JSON.");
|
||||
}
|
||||
|
||||
if (manifest.kind !== "openclaw.clawpack") {
|
||||
if (hasGenericPackageRoot(normalized)) return { files, summary: null };
|
||||
throw new Error("Manifest is not an OpenClaw Claw Pack.");
|
||||
}
|
||||
|
||||
const manifestPath = manifestEntry.path;
|
||||
const manifestDir = manifestPath.includes("/")
|
||||
? `${manifestPath.split("/").slice(0, -1).join("/")}/`
|
||||
: "";
|
||||
const packageRoot = `${manifestDir}package/`;
|
||||
const packageFiles = normalized
|
||||
.filter((entry) => entry.path !== manifestPath && entry.path.startsWith(packageRoot))
|
||||
.map((entry) => createPathFile(entry.file, entry.path.slice(packageRoot.length)));
|
||||
|
||||
if (packageFiles.length === 0) {
|
||||
throw new Error("Claw Pack archive does not contain package files under package/.");
|
||||
}
|
||||
|
||||
const packageInfo = isRecord(manifest.package) ? manifest.package : {};
|
||||
const releaseInfo = isRecord(manifest.release) ? manifest.release : {};
|
||||
const sourceInfo = isRecord(releaseInfo.source) ? releaseInfo.source : {};
|
||||
const hostTargets = Array.isArray(manifest.hostTargets)
|
||||
? manifest.hostTargets
|
||||
.map(getClawPackTarget)
|
||||
.filter((target): target is string => Boolean(target))
|
||||
: [];
|
||||
const family = getString(packageInfo.family);
|
||||
|
||||
return {
|
||||
files: packageFiles,
|
||||
summary: {
|
||||
packageName: getString(packageInfo.name) ?? getString(packageInfo.slug),
|
||||
displayName: getString(packageInfo.displayName) ?? getString(packageInfo.name),
|
||||
version: getString(packageInfo.version),
|
||||
family: family === "code-plugin" || family === "bundle-plugin" ? family : undefined,
|
||||
sourceRepo: getString(sourceInfo.repo),
|
||||
sourceCommit: getString(sourceInfo.commit),
|
||||
sourceRef: getString(sourceInfo.ref),
|
||||
sourcePath: getString(sourceInfo.path),
|
||||
hostTargets,
|
||||
packageFileCount: packageFiles.length,
|
||||
} satisfies ClawPackImportSummary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchGitHubPackageSource } from "./githubPackageSource";
|
||||
|
||||
const COMMIT = "a".repeat(40);
|
||||
const TREE = "b".repeat(40);
|
||||
const BLOB = "c".repeat(40);
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function textResponse(body: string, contentType = "text/plain") {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
}
|
||||
|
||||
function fetchInputUrl(input: RequestInfo | URL) {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
return input.url;
|
||||
}
|
||||
|
||||
describe("fetchGitHubPackageSource", () => {
|
||||
it("resolves a repo URL, downloads package files, and reports source metadata", async () => {
|
||||
const progress: string[] = [];
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = fetchInputUrl(input);
|
||||
if (url === "https://api.github.com/repos/owner/repo") {
|
||||
return jsonResponse({ default_branch: "main" });
|
||||
}
|
||||
if (url === "https://api.github.com/repos/owner/repo/commits/main") {
|
||||
return jsonResponse({ sha: COMMIT, commit: { tree: { sha: TREE } } });
|
||||
}
|
||||
if (url === `https://api.github.com/repos/owner/repo/git/trees/${TREE}?recursive=1`) {
|
||||
return jsonResponse({
|
||||
tree: [
|
||||
{ path: "package.json", type: "blob", sha: BLOB, size: 37 },
|
||||
{ path: "src/index.ts", type: "blob", sha: BLOB, size: 17 },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url === `https://raw.githubusercontent.com/owner/repo/${COMMIT}/package.json`) {
|
||||
return textResponse('{"name":"demo","version":"1.0.0"}', "application/json");
|
||||
}
|
||||
if (url === `https://raw.githubusercontent.com/owner/repo/${COMMIT}/src/index.ts`) {
|
||||
return textResponse("export const x=1;\n");
|
||||
}
|
||||
throw new Error(`unexpected ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchGitHubPackageSource("https://github.com/owner/repo", {
|
||||
fetcher,
|
||||
onProgress: (entry) => progress.push(entry.phase),
|
||||
});
|
||||
|
||||
expect(result.source).toEqual({
|
||||
repo: "owner/repo",
|
||||
url: "https://github.com/owner/repo",
|
||||
ref: "main",
|
||||
commit: COMMIT,
|
||||
path: ".",
|
||||
});
|
||||
expect(result.files.map((file) => file.name)).toEqual(["package.json", "src/index.ts"]);
|
||||
expect(await result.files[0]?.text()).toBe('{"name":"demo","version":"1.0.0"}');
|
||||
expect(progress).toContain("resolving");
|
||||
expect(progress).toContain("listing");
|
||||
expect(progress.filter((phase) => phase === "downloading")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("handles branch names with slashes and trims tree URL paths", async () => {
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = fetchInputUrl(input);
|
||||
if (url === "https://api.github.com/repos/owner/repo/commits/feature%2Fnew-ui") {
|
||||
return jsonResponse({ sha: COMMIT, commit: { tree: { sha: TREE } } });
|
||||
}
|
||||
if (url === `https://api.github.com/repos/owner/repo/git/trees/${TREE}?recursive=1`) {
|
||||
return jsonResponse({
|
||||
tree: [
|
||||
{ path: "plugins/demo/package.json", type: "blob", sha: BLOB, size: 15 },
|
||||
{ path: "plugins/demo/openclaw.plugin.json", type: "blob", sha: BLOB, size: 11 },
|
||||
{ path: "other/package.json", type: "blob", sha: BLOB, size: 2 },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (
|
||||
url === `https://raw.githubusercontent.com/owner/repo/${COMMIT}/plugins/demo/package.json`
|
||||
) {
|
||||
return textResponse('{"name":"demo"}', "application/json");
|
||||
}
|
||||
if (
|
||||
url ===
|
||||
`https://raw.githubusercontent.com/owner/repo/${COMMIT}/plugins/demo/openclaw.plugin.json`
|
||||
) {
|
||||
return textResponse('{"id":"demo"}', "application/json");
|
||||
}
|
||||
return jsonResponse({}, 404);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await fetchGitHubPackageSource(
|
||||
"https://github.com/owner/repo/tree/feature/new-ui/plugins/demo",
|
||||
{ fetcher },
|
||||
);
|
||||
|
||||
expect(result.source.ref).toBe("feature/new-ui");
|
||||
expect(result.source.path).toBe("plugins/demo");
|
||||
expect(result.files.map((file) => file.name)).toEqual(["openclaw.plugin.json", "package.json"]);
|
||||
});
|
||||
|
||||
it("rejects oversized GitHub packages before downloading files", async () => {
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = fetchInputUrl(input);
|
||||
if (url === "https://api.github.com/repos/owner/repo") {
|
||||
return jsonResponse({ default_branch: "main" });
|
||||
}
|
||||
if (url === "https://api.github.com/repos/owner/repo/commits/main") {
|
||||
return jsonResponse({ sha: COMMIT, commit: { tree: { sha: TREE } } });
|
||||
}
|
||||
if (url === `https://api.github.com/repos/owner/repo/git/trees/${TREE}?recursive=1`) {
|
||||
return jsonResponse({
|
||||
tree: [{ path: "package.json", type: "blob", sha: BLOB, size: 11 * 1024 * 1024 }],
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
await expect(
|
||||
fetchGitHubPackageSource("https://github.com/owner/repo", { fetcher }),
|
||||
).rejects.toThrow(/10MB/);
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
type GitHubPackageSource = {
|
||||
repo: string;
|
||||
url: string;
|
||||
ref: string;
|
||||
commit: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type GitHubPackageSourceResult = {
|
||||
files: File[];
|
||||
source: GitHubPackageSource;
|
||||
};
|
||||
|
||||
type Fetcher = typeof fetch;
|
||||
|
||||
type GitHubRepoResponse = {
|
||||
default_branch?: unknown;
|
||||
};
|
||||
|
||||
type GitHubCommitResponse = {
|
||||
sha?: unknown;
|
||||
commit?: { tree?: { sha?: unknown } };
|
||||
};
|
||||
|
||||
type GitHubTreeEntry = {
|
||||
path?: unknown;
|
||||
type?: unknown;
|
||||
sha?: unknown;
|
||||
size?: unknown;
|
||||
};
|
||||
|
||||
type GitHubTreeResponse = {
|
||||
tree?: unknown;
|
||||
truncated?: unknown;
|
||||
};
|
||||
|
||||
export type GitHubPackageSourceProgress = {
|
||||
phase: "resolving" | "listing" | "downloading";
|
||||
current?: number;
|
||||
total?: number;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
const GITHUB_API = "https://api.github.com";
|
||||
const GITHUB_RAW = "https://raw.githubusercontent.com";
|
||||
const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]);
|
||||
const DEFAULT_MAX_FILES = 500;
|
||||
const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
type ParsedGitHubUrl = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
kind: "repo" | "tree" | "blob";
|
||||
segments: string[];
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function fetchGitHubPackageSource(
|
||||
input: string,
|
||||
options: {
|
||||
fetcher?: Fetcher;
|
||||
maxFiles?: number;
|
||||
maxFileBytes?: number;
|
||||
maxTotalBytes?: number;
|
||||
onProgress?: (progress: GitHubPackageSourceProgress) => void;
|
||||
} = {},
|
||||
): Promise<GitHubPackageSourceResult> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const parsed = parseGitHubPackageUrl(input);
|
||||
options.onProgress?.({ phase: "resolving" });
|
||||
|
||||
const resolved = await resolveGitHubSource(parsed, fetcher);
|
||||
options.onProgress?.({ phase: "listing" });
|
||||
|
||||
const entries = await listGitHubTreeFiles(parsed.owner, parsed.repo, resolved.treeSha, fetcher);
|
||||
const selected = filterGitHubTreeEntries(entries, resolved.path);
|
||||
if (selected.length === 0) {
|
||||
throw new Error(`GitHub path "${resolved.path}" does not contain package files.`);
|
||||
}
|
||||
|
||||
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
||||
if (selected.length > maxFiles) {
|
||||
throw new Error(
|
||||
`GitHub path has too many files (${selected.length}). Upload an archive instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
|
||||
const maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
|
||||
const totalBytes = selected.reduce((sum, entry) => sum + entry.size, 0);
|
||||
if (selected.some((entry) => entry.size > maxFileBytes)) {
|
||||
throw new Error("One or more GitHub files exceeds the 10MB per-file limit.");
|
||||
}
|
||||
if (totalBytes > maxTotalBytes) {
|
||||
throw new Error("GitHub package exceeds the 50MB publish limit.");
|
||||
}
|
||||
|
||||
const files: File[] = [];
|
||||
for (const [index, entry] of selected.entries()) {
|
||||
options.onProgress?.({
|
||||
phase: "downloading",
|
||||
current: index + 1,
|
||||
total: selected.length,
|
||||
path: entry.path,
|
||||
});
|
||||
const response = await fetcher(
|
||||
`${GITHUB_RAW}/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/${encodeURIComponent(
|
||||
resolved.commit,
|
||||
)}/${entry.path.split("/").map(encodeURIComponent).join("/")}`,
|
||||
);
|
||||
if (!response.ok) throw new Error(`Could not download ${entry.path} from GitHub.`);
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (bytes.byteLength !== entry.size && bytes.byteLength > maxFileBytes) {
|
||||
throw new Error(`GitHub file ${entry.path} exceeds the 10MB per-file limit.`);
|
||||
}
|
||||
files.push(
|
||||
new File([bytes], entry.relativePath, {
|
||||
type: response.headers.get("content-type")?.split(";")[0] ?? "",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
source: {
|
||||
repo: `${parsed.owner}/${parsed.repo}`,
|
||||
url: `https://github.com/${parsed.owner}/${parsed.repo}`,
|
||||
ref: resolved.ref,
|
||||
commit: resolved.commit,
|
||||
path: resolved.path,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseGitHubPackageUrl(input: string): ParsedGitHubUrl {
|
||||
const value = input.trim().replace(/^git@github\.com:/i, "https://github.com/");
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new Error("Paste a GitHub repo, tree, or blob URL.");
|
||||
}
|
||||
if (url.protocol !== "https:" || !GITHUB_HOSTS.has(url.hostname)) {
|
||||
throw new Error("Paste a GitHub repo, tree, or blob URL.");
|
||||
}
|
||||
const segments = url.pathname
|
||||
.replace(/\.git$/i, "")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map(decodePathSegment);
|
||||
const [owner, repo, kind, ...rest] = segments;
|
||||
if (!owner || !repo) throw new Error("GitHub URL must include owner and repo.");
|
||||
if (kind && kind !== "tree" && kind !== "blob") {
|
||||
return { owner, repo, kind: "repo", segments: [], url: `https://github.com/${owner}/${repo}` };
|
||||
}
|
||||
return {
|
||||
owner,
|
||||
repo,
|
||||
kind: kind === "tree" || kind === "blob" ? kind : "repo",
|
||||
segments: rest,
|
||||
url: `https://github.com/${owner}/${repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveGitHubSource(parsed: ParsedGitHubUrl, fetcher: Fetcher) {
|
||||
if (parsed.kind === "repo") {
|
||||
const defaultBranch = await fetchDefaultBranch(parsed.owner, parsed.repo, fetcher);
|
||||
const commit = await fetchCommit(parsed.owner, parsed.repo, defaultBranch, fetcher);
|
||||
return {
|
||||
ref: defaultBranch,
|
||||
commit: commit.sha,
|
||||
treeSha: commit.treeSha,
|
||||
path: ".",
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.segments.length === 0) throw new Error("GitHub URL is missing a ref.");
|
||||
const minPathSegments = parsed.kind === "blob" ? 1 : 0;
|
||||
const maxRefSegments = parsed.segments.length - minPathSegments;
|
||||
for (let refSegmentCount = maxRefSegments; refSegmentCount >= 1; refSegmentCount -= 1) {
|
||||
const ref = parsed.segments.slice(0, refSegmentCount).join("/");
|
||||
const pathSegments = parsed.segments.slice(refSegmentCount);
|
||||
const candidate = await tryFetchCommit(parsed.owner, parsed.repo, ref, fetcher);
|
||||
if (!candidate) continue;
|
||||
const rawPath =
|
||||
parsed.kind === "blob" ? pathSegments.slice(0, -1).join("/") : pathSegments.join("/");
|
||||
return {
|
||||
ref,
|
||||
commit: candidate.sha,
|
||||
treeSha: candidate.treeSha,
|
||||
path: normalizeRepoPath(rawPath) || ".",
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("GitHub ref not found.");
|
||||
}
|
||||
|
||||
async function fetchDefaultBranch(owner: string, repo: string, fetcher: Fetcher) {
|
||||
const response = await githubJson(`${GITHUB_API}/repos/${owner}/${repo}`, fetcher);
|
||||
const parsed = (await response.json()) as GitHubRepoResponse;
|
||||
const branch = typeof parsed.default_branch === "string" ? parsed.default_branch.trim() : "";
|
||||
if (!branch) throw new Error("GitHub repo default branch missing.");
|
||||
return branch;
|
||||
}
|
||||
|
||||
async function tryFetchCommit(owner: string, repo: string, ref: string, fetcher: Fetcher) {
|
||||
const response = await fetcher(
|
||||
`${GITHUB_API}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
|
||||
{
|
||||
headers: githubHeaders(),
|
||||
},
|
||||
);
|
||||
if (!response.ok) return null;
|
||||
return parseCommitResponse((await response.json()) as GitHubCommitResponse);
|
||||
}
|
||||
|
||||
async function fetchCommit(owner: string, repo: string, ref: string, fetcher: Fetcher) {
|
||||
const response = await githubJson(
|
||||
`${GITHUB_API}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
|
||||
fetcher,
|
||||
);
|
||||
return parseCommitResponse((await response.json()) as GitHubCommitResponse);
|
||||
}
|
||||
|
||||
function parseCommitResponse(parsed: GitHubCommitResponse) {
|
||||
const sha = typeof parsed.sha === "string" ? parsed.sha.trim().toLowerCase() : "";
|
||||
const treeSha =
|
||||
typeof parsed.commit?.tree?.sha === "string" ? parsed.commit.tree.sha.trim().toLowerCase() : "";
|
||||
if (!/^[a-f0-9]{40}$/.test(sha) || !/^[a-f0-9]{40}$/.test(treeSha)) {
|
||||
throw new Error("GitHub commit metadata missing.");
|
||||
}
|
||||
return { sha, treeSha };
|
||||
}
|
||||
|
||||
async function listGitHubTreeFiles(owner: string, repo: string, treeSha: string, fetcher: Fetcher) {
|
||||
const response = await githubJson(
|
||||
`${GITHUB_API}/repos/${owner}/${repo}/git/trees/${treeSha}?recursive=1`,
|
||||
fetcher,
|
||||
);
|
||||
const parsed = (await response.json()) as GitHubTreeResponse;
|
||||
if (parsed.truncated) throw new Error("GitHub tree is too large. Upload an archive instead.");
|
||||
if (!Array.isArray(parsed.tree)) throw new Error("GitHub tree metadata missing.");
|
||||
return parsed.tree
|
||||
.map(normalizeTreeEntry)
|
||||
.filter((entry): entry is { path: string; sha: string; size: number } => Boolean(entry));
|
||||
}
|
||||
|
||||
function normalizeTreeEntry(entry: GitHubTreeEntry) {
|
||||
if (entry.type !== "blob") return null;
|
||||
const path = typeof entry.path === "string" ? normalizeRepoPath(entry.path) : "";
|
||||
const sha = typeof entry.sha === "string" ? entry.sha.trim() : "";
|
||||
const size = typeof entry.size === "number" ? entry.size : Number.NaN;
|
||||
if (!path || !/^[a-f0-9]{40}$/i.test(sha) || !Number.isFinite(size) || size < 0) return null;
|
||||
return { path, sha: sha.toLowerCase(), size };
|
||||
}
|
||||
|
||||
function filterGitHubTreeEntries(
|
||||
entries: Array<{ path: string; sha: string; size: number }>,
|
||||
rootPath: string,
|
||||
) {
|
||||
const root = normalizeRepoPath(rootPath);
|
||||
const prefix = root && root !== "." ? `${root}/` : "";
|
||||
return entries
|
||||
.filter((entry) => !prefix || entry.path.startsWith(prefix))
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
relativePath: prefix ? entry.path.slice(prefix.length) : entry.path,
|
||||
}))
|
||||
.filter((entry) => entry.relativePath && !entry.relativePath.endsWith("/"))
|
||||
.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||
}
|
||||
|
||||
async function githubJson(url: string, fetcher: Fetcher) {
|
||||
const response = await fetcher(url, { headers: githubHeaders() });
|
||||
if (response.ok) return response;
|
||||
if (response.status === 403 || response.status === 429) {
|
||||
throw new Error("GitHub rate limit hit. Try again shortly or upload an archive.");
|
||||
}
|
||||
if (response.status === 404) throw new Error("GitHub repo or ref not found.");
|
||||
throw new Error("GitHub request failed.");
|
||||
}
|
||||
|
||||
function githubHeaders() {
|
||||
return {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRepoPath(value: string) {
|
||||
if (value.trim() === ".") return ".";
|
||||
const parts = value
|
||||
.replaceAll("\u0000", "")
|
||||
.replaceAll("\\", "/")
|
||||
.split("/")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.some((part) => part === "." || part === "..")) {
|
||||
throw new Error("Invalid GitHub path.");
|
||||
}
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function decodePathSegment(segment: string) {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
throw new Error("Invalid GitHub URL.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
|
||||
type MigrationReadinessState =
|
||||
| "package-missing"
|
||||
| "release-missing"
|
||||
| "clawpack-missing"
|
||||
| "metadata-incomplete"
|
||||
| "scan-blocked"
|
||||
| "ready-for-openclaw";
|
||||
|
||||
export type MigrationReadinessItem = {
|
||||
bundledPluginId: string;
|
||||
displayName: string;
|
||||
desiredPackageName: string;
|
||||
publisherHandle: string;
|
||||
sourceRepo: string | null;
|
||||
sourcePath: string;
|
||||
sourceCommit: string | null;
|
||||
sourceRef: string | null;
|
||||
requiredHostTargets: string[];
|
||||
readinessState: MigrationReadinessState;
|
||||
blockers: string[];
|
||||
gates: {
|
||||
packageExists: boolean;
|
||||
releaseExists: boolean;
|
||||
clawpackAvailable: boolean;
|
||||
hostMatrixComplete: boolean;
|
||||
environmentComplete: boolean;
|
||||
sourceLinked: boolean;
|
||||
scanClear: boolean;
|
||||
runtimeBundleStatus: string;
|
||||
};
|
||||
package: {
|
||||
packageId: Id<"packages">;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: "skill" | "code-plugin" | "bundle-plugin";
|
||||
runtimeId: string | null;
|
||||
channel: "official" | "community" | "private";
|
||||
isOfficial: boolean;
|
||||
scanStatus: string;
|
||||
updatedAt: number;
|
||||
} | null;
|
||||
latestRelease: {
|
||||
releaseId: Id<"packageReleases">;
|
||||
version: string;
|
||||
createdAt: number;
|
||||
clawpackSha256: string | null;
|
||||
clawpackFileCount: number | null;
|
||||
clawpackRevokedAt?: number;
|
||||
hostTargetKeys: string[];
|
||||
environmentFlags: string[];
|
||||
scanStatus: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type MigrationReadinessResult = {
|
||||
items: MigrationReadinessItem[];
|
||||
readyCount: number;
|
||||
blockedCount: number;
|
||||
generatedAt: number;
|
||||
};
|
||||
|
||||
export function readinessStateLabel(state: MigrationReadinessState) {
|
||||
if (state === "clawpack-missing") return "claw pack missing";
|
||||
return state.replaceAll("-", " ");
|
||||
}
|
||||
|
||||
export function readinessBlockerLabel(blocker: string) {
|
||||
if (blocker === "clawpack-missing") {
|
||||
return "claw pack missing";
|
||||
}
|
||||
return blocker.replaceAll("-", " ");
|
||||
}
|
||||
|
||||
export function formatReadinessClawPack(item: MigrationReadinessItem) {
|
||||
if (item.latestRelease?.clawpackRevokedAt) return "revoked";
|
||||
if (!item.latestRelease?.clawpackSha256) return "missing";
|
||||
const digest = item.latestRelease.clawpackSha256.slice(0, 12);
|
||||
const count = item.latestRelease.clawpackFileCount;
|
||||
return [count ? `${count} files` : null, digest].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
export function formatReadinessSource(item: MigrationReadinessItem) {
|
||||
const ref = item.sourceCommit ?? item.sourceRef;
|
||||
return `${item.sourceRepo ?? "missing"}${ref ? ` @ ${ref.slice(0, 12)}` : ""}`;
|
||||
}
|
||||
@@ -11,10 +11,16 @@ vi.mock("@tanstack/react-start/server", () => ({
|
||||
import {
|
||||
fetchPackageDetail,
|
||||
fetchPackageReadme,
|
||||
fetchPackageClawPack,
|
||||
fetchPackageClawPackManifest,
|
||||
fetchPackageVersion,
|
||||
fetchPluginCatalog,
|
||||
fetchPackages,
|
||||
getPackageApiHref,
|
||||
getPackageClawPackHref,
|
||||
getPackageDownloadPath,
|
||||
getPackageDownloadHref,
|
||||
getPackageClawPackPath,
|
||||
PackageApiError,
|
||||
} from "./packageApi";
|
||||
|
||||
@@ -374,6 +380,59 @@ describe("fetchPackages", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches package Claw Pack details from the encoded release route", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
package: {
|
||||
name: "@openclaw/kitchen-sink",
|
||||
displayName: "Kitchen Sink",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: { version: "1.0.0", createdAt: 1 },
|
||||
clawpack: { available: true, specVersion: 1, sha256: "a".repeat(64), size: 12 },
|
||||
links: {
|
||||
download: "/api/v1/packages/%40openclaw%2Fkitchen-sink/download?version=1.0.0",
|
||||
immutable: `/api/v1/clawpacks/${"a".repeat(64)}`,
|
||||
manifest:
|
||||
"/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/manifest",
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchPackageClawPack("@openclaw/kitchen-sink", "1.0.0+build/meta");
|
||||
|
||||
expect(result?.clawpack.sha256).toBe("a".repeat(64));
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://registry.example/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0%2Bbuild%2Fmeta/clawpack",
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches package Claw Pack manifests from the encoded release route", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
package: { name: "demo-plugin", displayName: "Demo Plugin", family: "code-plugin" },
|
||||
version: { version: "1.0.0", createdAt: 1 },
|
||||
clawpack: { available: true, specVersion: 1, sha256: "b".repeat(64), size: 12 },
|
||||
manifest: { kind: "openclaw.clawpack", specVersion: 1 },
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchPackageClawPackManifest("demo-plugin", "1.0.0");
|
||||
|
||||
expect(result?.manifest).toEqual({ kind: "openclaw.clawpack", specVersion: 1 });
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://registry.example/api/v1/packages/demo-plugin/versions/1.0.0/clawpack/manifest",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when no supported README variant exists", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
@@ -419,6 +478,26 @@ describe("fetchPackages", () => {
|
||||
expect(getPackageDownloadPath("private-plugin")).toBe(
|
||||
"/api/v1/packages/private-plugin/download",
|
||||
);
|
||||
expect(getPackageClawPackPath("@openclaw/kitchen-sink", "1.0.0+build/meta")).toBe(
|
||||
"/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0%2Bbuild%2Fmeta/clawpack",
|
||||
);
|
||||
expect(getPackageClawPackPath("@openclaw/kitchen-sink", "1.0.0", "manifest")).toBe(
|
||||
"/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/manifest",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds absolute package asset hrefs from the Convex site URL", () => {
|
||||
vi.stubEnv("VITE_CONVEX_SITE_URL", "https://registry.example");
|
||||
|
||||
expect(getPackageApiHref("/api/v1/packages/private-plugin/download")).toBe(
|
||||
"https://registry.example/api/v1/packages/private-plugin/download",
|
||||
);
|
||||
expect(getPackageDownloadHref("private-plugin", "1.0.0")).toBe(
|
||||
"https://registry.example/api/v1/packages/private-plugin/download?version=1.0.0",
|
||||
);
|
||||
expect(getPackageClawPackHref("@openclaw/kitchen-sink", "1.0.0", "manifest")).toBe(
|
||||
"https://registry.example/api/v1/packages/%40openclaw%2Fkitchen-sink/versions/1.0.0/clawpack/manifest",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,39 @@ import { ApiRoutes } from "clawhub-schema/routes";
|
||||
import { hasOwnProperty } from "./hasOwnProperty";
|
||||
import { getRequiredRuntimeEnv, getRuntimeEnv } from "./runtimeEnv";
|
||||
|
||||
type PackageClawPackSummary = {
|
||||
available: boolean;
|
||||
specVersion: number | null;
|
||||
sha256: string | null;
|
||||
size: number | null;
|
||||
format: string | null;
|
||||
fileCount: number | null;
|
||||
manifestSha256: string | null;
|
||||
builtAt: number | null;
|
||||
buildVersion: string | null;
|
||||
hostTargets?: Array<{
|
||||
os: "darwin" | "linux" | "win32";
|
||||
arch: "arm64" | "x64";
|
||||
libc?: "glibc" | "musl";
|
||||
nodeRange?: string;
|
||||
openclawRange?: string;
|
||||
pluginApiRange?: string;
|
||||
supportState?: "supported" | "setup-required" | "unsupported";
|
||||
unsupportedReason?: string;
|
||||
}>;
|
||||
environment: {
|
||||
requiresLocalDesktop?: boolean;
|
||||
requiresBrowser?: boolean;
|
||||
requiresAudioDevice?: boolean;
|
||||
requiresNetwork?: boolean;
|
||||
requiresExternalServices?: string[];
|
||||
requiresOsPermissions?: string[];
|
||||
supportsRemoteHost?: boolean;
|
||||
knownUnsupported?: string[];
|
||||
} | null;
|
||||
runtimeBundles: unknown[];
|
||||
};
|
||||
|
||||
export type PackageListItem = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
@@ -23,6 +56,10 @@ export type PackageListItem = {
|
||||
capabilityTags?: string[];
|
||||
executesCode?: boolean;
|
||||
verificationTier?: string | null;
|
||||
clawpackAvailable?: boolean;
|
||||
hostTargetKeys?: string[];
|
||||
environmentFlags?: string[];
|
||||
clawpack?: PackageClawPackSummary;
|
||||
};
|
||||
|
||||
export type PackageDetailResponse = ApiV1PackageResponse;
|
||||
@@ -86,9 +123,38 @@ export type PackageVersionDetail = {
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
} | null;
|
||||
clawpack?: PackageClawPackSummary;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PackageClawPackReleaseDetail = {
|
||||
package: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: "skill" | "code-plugin" | "bundle-plugin";
|
||||
};
|
||||
version: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
distTags?: string[];
|
||||
verification?: PackageVerificationSummary | null;
|
||||
sha256hash?: string | null;
|
||||
vtAnalysis?: NonNullable<PackageVersionDetail["version"]>["vtAnalysis"];
|
||||
llmAnalysis?: NonNullable<PackageVersionDetail["version"]>["llmAnalysis"];
|
||||
staticScan?: NonNullable<PackageVersionDetail["version"]>["staticScan"];
|
||||
};
|
||||
clawpack: PackageClawPackSummary;
|
||||
links: {
|
||||
download: string;
|
||||
immutable: string | null;
|
||||
manifest: string;
|
||||
};
|
||||
};
|
||||
|
||||
type PackageClawPackManifestDetail = Omit<PackageClawPackReleaseDetail, "links"> & {
|
||||
manifest: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type PluginFamily = "code-plugin" | "bundle-plugin";
|
||||
|
||||
type PluginCatalogResult = {
|
||||
@@ -179,6 +245,38 @@ export function getPackageDownloadPath(name: string, version?: string | null) {
|
||||
return `${path}?version=${encodeURIComponent(version)}`;
|
||||
}
|
||||
|
||||
export function getPackageApiHref(path: string) {
|
||||
try {
|
||||
return new URL(path).toString();
|
||||
} catch {
|
||||
// Relative API links are expected here.
|
||||
}
|
||||
const normalizedPath = normalizeApiPath(path);
|
||||
const base = resolveAbsoluteBaseUrl(
|
||||
getRuntimeEnv("VITE_CONVEX_SITE_URL"),
|
||||
getRuntimeEnv("VITE_CONVEX_URL"),
|
||||
);
|
||||
if (base) return new URL(normalizedPath, base).toString();
|
||||
if (typeof window !== "undefined")
|
||||
return new URL(normalizedPath, window.location.origin).toString();
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
export function getPackageDownloadHref(name: string, version?: string | null) {
|
||||
return getPackageApiHref(getPackageDownloadPath(name, version));
|
||||
}
|
||||
|
||||
export function getPackageClawPackPath(name: string, version: string, suffix?: "manifest") {
|
||||
const path = normalizeApiPath(
|
||||
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}/clawpack`,
|
||||
);
|
||||
return suffix ? `${path}/${suffix}` : path;
|
||||
}
|
||||
|
||||
export function getPackageClawPackHref(name: string, version: string, suffix?: "manifest") {
|
||||
return getPackageApiHref(getPackageClawPackPath(name, version, suffix));
|
||||
}
|
||||
|
||||
async function getForwardedHeaders() {
|
||||
if (typeof window !== "undefined" || !import.meta.env.SSR) return {};
|
||||
try {
|
||||
@@ -258,6 +356,8 @@ export async function fetchPackages(params: {
|
||||
featured?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
hostTarget?: string;
|
||||
environment?: string;
|
||||
limit?: number;
|
||||
}) {
|
||||
if (params.q?.trim()) {
|
||||
@@ -273,6 +373,8 @@ export async function fetchPackages(params: {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
if (params.capabilityTag) url.searchParams.set("capabilityTag", params.capabilityTag);
|
||||
if (params.hostTarget) url.searchParams.set("hostTarget", params.hostTarget);
|
||||
if (params.environment) url.searchParams.set("environment", params.environment);
|
||||
return await fetchJson<{ results: Array<{ score: number; package: PackageListItem }> }>(url);
|
||||
}
|
||||
|
||||
@@ -294,6 +396,8 @@ export async function fetchPackages(params: {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
if (params.capabilityTag) url.searchParams.set("capabilityTag", params.capabilityTag);
|
||||
if (params.hostTarget) url.searchParams.set("hostTarget", params.hostTarget);
|
||||
if (params.environment) url.searchParams.set("environment", params.environment);
|
||||
return await fetchJson<{ items: PackageListItem[]; nextCursor: string | null }>(url);
|
||||
}
|
||||
|
||||
@@ -304,6 +408,8 @@ export async function fetchPluginCatalog(params: {
|
||||
isOfficial?: boolean;
|
||||
featured?: boolean;
|
||||
executesCode?: boolean;
|
||||
hostTarget?: string;
|
||||
environment?: string;
|
||||
limit?: number;
|
||||
}): Promise<PluginCatalogResult> {
|
||||
if (params.family) {
|
||||
@@ -314,6 +420,8 @@ export async function fetchPluginCatalog(params: {
|
||||
isOfficial: params.isOfficial,
|
||||
featured: params.featured,
|
||||
executesCode: params.executesCode,
|
||||
hostTarget: params.hostTarget,
|
||||
environment: params.environment,
|
||||
limit: params.limit,
|
||||
});
|
||||
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
|
||||
@@ -341,6 +449,8 @@ export async function fetchPluginCatalog(params: {
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
if (params.hostTarget) url.searchParams.set("hostTarget", params.hostTarget);
|
||||
if (params.environment) url.searchParams.set("environment", params.environment);
|
||||
const response = await fetchJson<{
|
||||
results?: Array<{ score: number; package: PackageListItem }>;
|
||||
}>(url);
|
||||
@@ -362,6 +472,8 @@ export async function fetchPluginCatalog(params: {
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
if (params.hostTarget) url.searchParams.set("hostTarget", params.hostTarget);
|
||||
if (params.environment) url.searchParams.set("environment", params.environment);
|
||||
const result = await fetchJson<PluginCatalogResult>(url);
|
||||
return {
|
||||
items: result?.items ?? [],
|
||||
@@ -394,6 +506,30 @@ export async function fetchPackageVersion(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPackageClawPack(
|
||||
name: string,
|
||||
version: string,
|
||||
): Promise<PackageClawPackReleaseDetail | null> {
|
||||
try {
|
||||
const url = await packageApiUrl(getPackageClawPackPath(name, version));
|
||||
return await fetchJson<PackageClawPackReleaseDetail>(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPackageClawPackManifest(
|
||||
name: string,
|
||||
version: string,
|
||||
): Promise<PackageClawPackManifestDetail | null> {
|
||||
try {
|
||||
const url = await packageApiUrl(getPackageClawPackPath(name, version, "manifest"));
|
||||
return await fetchJson<PackageClawPackManifestDetail>(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPackageReadme(
|
||||
name: string,
|
||||
version?: string | null,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { derivePublishLifecycle, deriveClawPackLifecycle } from "./packageLifecycle";
|
||||
|
||||
describe("package lifecycle", () => {
|
||||
it("marks publish as blocked when Claw Pack preview has blockers", () => {
|
||||
const lifecycle = derivePublishLifecycle({
|
||||
hasFiles: true,
|
||||
isAuthenticated: true,
|
||||
blockers: ["Source commit is required."],
|
||||
status: null,
|
||||
});
|
||||
|
||||
expect(lifecycle.state).toBe("metadata-blocked");
|
||||
expect(lifecycle.label).toBe("Blocked before publish");
|
||||
expect(lifecycle.steps.find((step) => step.key === "manifest")?.status).toBe("blocked");
|
||||
});
|
||||
|
||||
it("tracks the post-publish scan pending state", () => {
|
||||
const lifecycle = derivePublishLifecycle({
|
||||
hasFiles: true,
|
||||
isAuthenticated: true,
|
||||
blockers: [],
|
||||
status: "Published. Pending security checks and verification before public listing.",
|
||||
});
|
||||
|
||||
expect(lifecycle.state).toBe("scan-pending");
|
||||
expect(lifecycle.steps.find((step) => step.key === "scan")?.status).toBe("active");
|
||||
});
|
||||
|
||||
it("keeps built Claw Packs pending until scans are clean", () => {
|
||||
const lifecycle = deriveClawPackLifecycle({
|
||||
available: true,
|
||||
verificationScanStatus: "clean",
|
||||
vtStatus: "not-run",
|
||||
staticScanStatus: "clean",
|
||||
});
|
||||
|
||||
expect(lifecycle.state).toBe("scan-pending");
|
||||
expect(lifecycle.action).toMatch(/Wait for scans/i);
|
||||
});
|
||||
|
||||
it("marks clean built Claw Packs as ready", () => {
|
||||
const lifecycle = deriveClawPackLifecycle({
|
||||
available: true,
|
||||
verificationScanStatus: "clean",
|
||||
vtStatus: "clean",
|
||||
llmStatus: "clean",
|
||||
staticScanStatus: "clean",
|
||||
});
|
||||
|
||||
expect(lifecycle.state).toBe("ready");
|
||||
expect(lifecycle.steps.every((step) => step.status === "done")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps suspicious Claw Packs blocked even when the artifact exists", () => {
|
||||
const lifecycle = deriveClawPackLifecycle({
|
||||
available: true,
|
||||
verificationScanStatus: "suspicious",
|
||||
vtStatus: "clean",
|
||||
});
|
||||
|
||||
expect(lifecycle.state).toBe("blocked");
|
||||
expect(lifecycle.severity).toBe("danger");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
type PackageLifecycleState =
|
||||
| "needs-input"
|
||||
| "metadata-blocked"
|
||||
| "ready-to-submit"
|
||||
| "uploading"
|
||||
| "publishing"
|
||||
| "scan-pending"
|
||||
| "clawpack-missing"
|
||||
| "ready"
|
||||
| "revoked"
|
||||
| "blocked"
|
||||
| "failed";
|
||||
|
||||
type PackageLifecycleSeverity = "neutral" | "info" | "success" | "warning" | "danger";
|
||||
|
||||
type PackageLifecycleStepStatus = "waiting" | "active" | "done" | "blocked";
|
||||
|
||||
type PackageLifecycleStep = {
|
||||
key: string;
|
||||
label: string;
|
||||
status: PackageLifecycleStepStatus;
|
||||
};
|
||||
|
||||
type PackageLifecycle = {
|
||||
state: PackageLifecycleState;
|
||||
label: string;
|
||||
description: string;
|
||||
severity: PackageLifecycleSeverity;
|
||||
action: string | null;
|
||||
steps: PackageLifecycleStep[];
|
||||
};
|
||||
|
||||
type ScanStatus = string | null | undefined;
|
||||
|
||||
const DONE_STEPS = {
|
||||
upload: { key: "upload", label: "Upload", status: "done" },
|
||||
manifest: { key: "manifest", label: "Manifest", status: "done" },
|
||||
build: { key: "build", label: "Build", status: "done" },
|
||||
scan: { key: "scan", label: "Scan", status: "done" },
|
||||
available: { key: "available", label: "Available", status: "done" },
|
||||
} satisfies Record<string, PackageLifecycleStep>;
|
||||
|
||||
function step(
|
||||
key: string,
|
||||
label: string,
|
||||
status: PackageLifecycleStepStatus,
|
||||
): PackageLifecycleStep {
|
||||
return { key, label, status };
|
||||
}
|
||||
|
||||
function normalizeScanStatus(...statuses: ScanStatus[]) {
|
||||
const normalized = statuses
|
||||
.map((status) => status?.trim().toLowerCase())
|
||||
.filter((status): status is string => Boolean(status));
|
||||
if (normalized.some((status) => status === "malicious" || status === "blocked")) {
|
||||
return "malicious";
|
||||
}
|
||||
if (normalized.some((status) => status === "suspicious")) return "suspicious";
|
||||
if (normalized.some((status) => status === "pending" || status === "queued")) return "pending";
|
||||
if (normalized.some((status) => status === "error" || status === "failed")) return "failed";
|
||||
if (normalized.length === 0 || normalized.some((status) => status === "not-run")) {
|
||||
return "not-run";
|
||||
}
|
||||
if (normalized.every((status) => status === "clean" || status === "harmless")) return "clean";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function lifecycle(input: Omit<PackageLifecycle, "steps"> & { steps: PackageLifecycleStep[] }) {
|
||||
return input;
|
||||
}
|
||||
|
||||
export function derivePublishLifecycle(input: {
|
||||
hasFiles: boolean;
|
||||
isAuthenticated: boolean;
|
||||
blockers: string[];
|
||||
status: string | null;
|
||||
}): PackageLifecycle {
|
||||
if (!input.hasFiles) {
|
||||
return lifecycle({
|
||||
state: "needs-input",
|
||||
label: "Waiting for package",
|
||||
description:
|
||||
"Upload a plugin folder, archive, or package source before metadata is editable.",
|
||||
severity: "neutral",
|
||||
action: "Choose a package source.",
|
||||
steps: [
|
||||
step("upload", "Upload", "active"),
|
||||
step("manifest", "Manifest", "waiting"),
|
||||
step("build", "Build", "waiting"),
|
||||
step("scan", "Scan", "waiting"),
|
||||
step("available", "Available", "waiting"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (input.status?.toLowerCase().includes("uploading")) {
|
||||
return lifecycle({
|
||||
state: "uploading",
|
||||
label: "Uploading files",
|
||||
description: "Package files are being written to ClawHub storage.",
|
||||
severity: "info",
|
||||
action: null,
|
||||
steps: [
|
||||
step("upload", "Upload", "active"),
|
||||
step("manifest", "Manifest", "done"),
|
||||
step("build", "Build", "waiting"),
|
||||
step("scan", "Scan", "waiting"),
|
||||
step("available", "Available", "waiting"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (input.status?.toLowerCase().includes("publishing")) {
|
||||
return lifecycle({
|
||||
state: "publishing",
|
||||
label: "Building Claw Pack",
|
||||
description: "ClawHub is creating the canonical Claw Pack artifact and release record.",
|
||||
severity: "info",
|
||||
action: null,
|
||||
steps: [
|
||||
DONE_STEPS.upload,
|
||||
DONE_STEPS.manifest,
|
||||
step("build", "Build", "active"),
|
||||
step("scan", "Scan", "waiting"),
|
||||
step("available", "Available", "waiting"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (input.status?.toLowerCase().includes("pending security")) {
|
||||
return lifecycle({
|
||||
state: "scan-pending",
|
||||
label: "Published, scan pending",
|
||||
description:
|
||||
"The release exists, but public confidence depends on security checks finishing.",
|
||||
severity: "warning",
|
||||
action: "Watch the release until scans clear.",
|
||||
steps: [
|
||||
DONE_STEPS.upload,
|
||||
DONE_STEPS.manifest,
|
||||
DONE_STEPS.build,
|
||||
step("scan", "Scan", "active"),
|
||||
step("available", "Available", "waiting"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (!input.isAuthenticated) {
|
||||
return lifecycle({
|
||||
state: "metadata-blocked",
|
||||
label: "Login required",
|
||||
description: "The package is parsed, but publishing requires an authenticated ClawHub user.",
|
||||
severity: "warning",
|
||||
action: "Log in before publishing.",
|
||||
steps: [
|
||||
DONE_STEPS.upload,
|
||||
DONE_STEPS.manifest,
|
||||
step("build", "Build", "blocked"),
|
||||
step("scan", "Scan", "waiting"),
|
||||
step("available", "Available", "waiting"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (input.blockers.length > 0) {
|
||||
return lifecycle({
|
||||
state: "metadata-blocked",
|
||||
label: "Blocked before publish",
|
||||
description: "ClawHub can preview the Claw Pack, but required metadata is incomplete.",
|
||||
severity: "danger",
|
||||
action: input.blockers[0] ?? "Resolve the blocking metadata.",
|
||||
steps: [
|
||||
DONE_STEPS.upload,
|
||||
step("manifest", "Manifest", "blocked"),
|
||||
step("build", "Build", "waiting"),
|
||||
step("scan", "Scan", "waiting"),
|
||||
step("available", "Available", "waiting"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return lifecycle({
|
||||
state: "ready-to-submit",
|
||||
label: "Ready to publish",
|
||||
description: "Metadata and package files are ready for the canonical Claw Pack build.",
|
||||
severity: "success",
|
||||
action: "Publish to start build and security checks.",
|
||||
steps: [
|
||||
DONE_STEPS.upload,
|
||||
DONE_STEPS.manifest,
|
||||
step("build", "Build", "waiting"),
|
||||
step("scan", "Scan", "waiting"),
|
||||
step("available", "Available", "waiting"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function deriveClawPackLifecycle(input: {
|
||||
available?: boolean | null;
|
||||
revokedAt?: number | null;
|
||||
buildError?: string | null;
|
||||
verificationScanStatus?: ScanStatus;
|
||||
vtStatus?: ScanStatus;
|
||||
vtVerdict?: ScanStatus;
|
||||
llmStatus?: ScanStatus;
|
||||
llmVerdict?: ScanStatus;
|
||||
staticScanStatus?: ScanStatus;
|
||||
}): PackageLifecycle {
|
||||
if (input.revokedAt) {
|
||||
return lifecycle({
|
||||
state: "revoked",
|
||||
label: "Revoked",
|
||||
description: "This Claw Pack has been revoked and should not be installed.",
|
||||
severity: "danger",
|
||||
action: "Publish a replacement release or keep the artifact unavailable.",
|
||||
steps: [
|
||||
DONE_STEPS.upload,
|
||||
DONE_STEPS.manifest,
|
||||
DONE_STEPS.build,
|
||||
step("scan", "Scan", "blocked"),
|
||||
step("available", "Available", "blocked"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (input.buildError) {
|
||||
return lifecycle({
|
||||
state: "failed",
|
||||
label: "Build failed",
|
||||
description: input.buildError,
|
||||
severity: "danger",
|
||||
action: "Retry the Claw Pack build after fixing the source package.",
|
||||
steps: [
|
||||
DONE_STEPS.upload,
|
||||
DONE_STEPS.manifest,
|
||||
step("build", "Build", "blocked"),
|
||||
step("scan", "Scan", "waiting"),
|
||||
step("available", "Available", "waiting"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (!input.available) {
|
||||
return lifecycle({
|
||||
state: "clawpack-missing",
|
||||
label: "Claw Pack missing",
|
||||
description: "The release exists but does not have a generated Claw Pack artifact yet.",
|
||||
severity: "warning",
|
||||
action: "Run or retry Claw Pack artifact backfill.",
|
||||
steps: [
|
||||
DONE_STEPS.upload,
|
||||
step("manifest", "Manifest", "waiting"),
|
||||
step("build", "Build", "active"),
|
||||
step("scan", "Scan", "waiting"),
|
||||
step("available", "Available", "waiting"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const scanStatus = normalizeScanStatus(
|
||||
input.verificationScanStatus,
|
||||
input.vtStatus,
|
||||
input.vtVerdict,
|
||||
input.llmStatus,
|
||||
input.llmVerdict,
|
||||
input.staticScanStatus,
|
||||
);
|
||||
|
||||
if (scanStatus === "malicious" || scanStatus === "suspicious") {
|
||||
return lifecycle({
|
||||
state: "blocked",
|
||||
label: scanStatus === "malicious" ? "Blocked as malicious" : "Needs review",
|
||||
description: "The Claw Pack exists, but security signals prevent a clean install decision.",
|
||||
severity: "danger",
|
||||
action: "Open moderation evidence and resolve or revoke the artifact.",
|
||||
steps: [
|
||||
DONE_STEPS.upload,
|
||||
DONE_STEPS.manifest,
|
||||
DONE_STEPS.build,
|
||||
step("scan", "Scan", "blocked"),
|
||||
step("available", "Available", "blocked"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (scanStatus === "failed" || scanStatus === "pending" || scanStatus === "not-run") {
|
||||
return lifecycle({
|
||||
state: "scan-pending",
|
||||
label: "Scan pending",
|
||||
description:
|
||||
"The Claw Pack is built, but all security checks have not reached a clean state.",
|
||||
severity: "warning",
|
||||
action: "Wait for scans or request a rescan if this is stale.",
|
||||
steps: [
|
||||
DONE_STEPS.upload,
|
||||
DONE_STEPS.manifest,
|
||||
DONE_STEPS.build,
|
||||
step("scan", "Scan", "active"),
|
||||
step("available", "Available", "waiting"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return lifecycle({
|
||||
state: "ready",
|
||||
label: "Ready",
|
||||
description: "The Claw Pack is built and current security signals are clean.",
|
||||
severity: "success",
|
||||
action: null,
|
||||
steps: [
|
||||
DONE_STEPS.upload,
|
||||
DONE_STEPS.manifest,
|
||||
DONE_STEPS.build,
|
||||
DONE_STEPS.scan,
|
||||
DONE_STEPS.available,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -92,16 +92,28 @@ describe("buildPackageUploadEntries", () => {
|
||||
.fn()
|
||||
.mockResolvedValueOnce("storage:1")
|
||||
.mockResolvedValueOnce("storage:2");
|
||||
const onProgress = vi.fn();
|
||||
|
||||
const uploaded = await buildPackageUploadEntries(files, {
|
||||
generateUploadUrl,
|
||||
hashFile,
|
||||
uploadFile,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
expect(generateUploadUrl).toHaveBeenCalledTimes(2);
|
||||
expect(uploadFile).toHaveBeenNthCalledWith(1, "upload-1", files[0]);
|
||||
expect(uploadFile).toHaveBeenNthCalledWith(2, "upload-2", files[1]);
|
||||
expect(onProgress).toHaveBeenNthCalledWith(1, {
|
||||
current: 1,
|
||||
total: 2,
|
||||
path: "package.json",
|
||||
});
|
||||
expect(onProgress).toHaveBeenNthCalledWith(2, {
|
||||
current: 2,
|
||||
total: 2,
|
||||
path: "dist/index.js",
|
||||
});
|
||||
expect(uploaded.map((entry) => entry.path)).toEqual(["package.json", "dist/index.js"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ export async function buildPackageUploadEntries<TFile extends UploadablePackageF
|
||||
generateUploadUrl: () => Promise<string>;
|
||||
hashFile: (file: TFile) => Promise<string>;
|
||||
uploadFile: (uploadUrl: string, file: TFile) => Promise<string>;
|
||||
onProgress?: (progress: { current: number; total: number; path: string }) => void;
|
||||
},
|
||||
) {
|
||||
const uploaded: Array<{
|
||||
@@ -129,7 +130,9 @@ export async function buildPackageUploadEntries<TFile extends UploadablePackageF
|
||||
contentType?: string;
|
||||
}> = [];
|
||||
|
||||
for (const { file, path } of normalizePackageUploadFiles(files)) {
|
||||
const normalizedFiles = normalizePackageUploadFiles(files);
|
||||
for (const [index, { file, path }] of normalizedFiles.entries()) {
|
||||
options.onProgress?.({ current: index + 1, total: normalizedFiles.length, path });
|
||||
const sha256 = await options.hashFile(file);
|
||||
const uploadUrl = await options.generateUploadUrl();
|
||||
const storageId = await options.uploadFile(uploadUrl, file);
|
||||
|
||||
@@ -49,6 +49,22 @@ describe("site helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the current browser origin for local ClawHub previews", () => {
|
||||
vi.stubGlobal("window", {
|
||||
location: { hostname: "127.0.0.1", origin: "http://127.0.0.1:3000" },
|
||||
} as unknown as Window);
|
||||
expect(getClawHubSiteUrl()).toBe("http://127.0.0.1:3000");
|
||||
});
|
||||
|
||||
it("keeps local navigation on the current origin even when env points at production", () => {
|
||||
withServerEnv({ VITE_SITE_URL: "https://clawhub.ai" }, () => {
|
||||
vi.stubGlobal("window", {
|
||||
location: { hostname: "localhost", origin: "http://localhost:3000" },
|
||||
} as unknown as Window);
|
||||
expect(getClawHubSiteUrl()).toBe("http://localhost:3000");
|
||||
});
|
||||
});
|
||||
|
||||
it("picks SoulHub URL from explicit env", () => {
|
||||
withServerEnv({ VITE_SOULHUB_SITE_URL: "https://souls.example.com" }, () => {
|
||||
expect(getOnlyCrabsSiteUrl()).toBe("https://souls.example.com");
|
||||
|
||||
+13
-6
@@ -6,6 +6,7 @@ const DEFAULT_CLAWHUB_SITE_URL = "https://clawhub.ai";
|
||||
const DEFAULT_ONLYCRABS_SITE_URL = "https://onlycrabs.ai";
|
||||
const DEFAULT_ONLYCRABS_HOST = "onlycrabs.ai";
|
||||
const LEGACY_CLAWDHUB_HOSTS = new Set(["clawdhub.com", "www.clawdhub.com", "auth.clawdhub.com"]);
|
||||
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0"]);
|
||||
|
||||
export function normalizeClawHubSiteOrigin(value?: string | null) {
|
||||
if (!value) return null;
|
||||
@@ -21,7 +22,11 @@ export function normalizeClawHubSiteOrigin(value?: string | null) {
|
||||
}
|
||||
|
||||
export function getClawHubSiteUrl() {
|
||||
return normalizeClawHubSiteOrigin(getRuntimeEnv("VITE_SITE_URL")) ?? DEFAULT_CLAWHUB_SITE_URL;
|
||||
return (
|
||||
getLocalBrowserOrigin() ??
|
||||
normalizeClawHubSiteOrigin(getRuntimeEnv("VITE_SITE_URL")) ??
|
||||
DEFAULT_CLAWHUB_SITE_URL
|
||||
);
|
||||
}
|
||||
|
||||
export function getOnlyCrabsSiteUrl() {
|
||||
@@ -32,11 +37,7 @@ export function getOnlyCrabsSiteUrl() {
|
||||
if (siteUrl) {
|
||||
try {
|
||||
const url = new URL(siteUrl);
|
||||
if (
|
||||
url.hostname === "localhost" ||
|
||||
url.hostname === "127.0.0.1" ||
|
||||
url.hostname === "0.0.0.0"
|
||||
) {
|
||||
if (LOCAL_HOSTS.has(url.hostname)) {
|
||||
return url.origin;
|
||||
}
|
||||
} catch {
|
||||
@@ -98,3 +99,9 @@ export function getSiteDescription(mode: SiteMode = getSiteMode()) {
|
||||
export function getSiteUrlForMode(mode: SiteMode = getSiteMode()) {
|
||||
return mode === "souls" ? getOnlyCrabsSiteUrl() : getClawHubSiteUrl();
|
||||
}
|
||||
|
||||
function getLocalBrowserOrigin() {
|
||||
if (typeof window === "undefined") return null;
|
||||
const { hostname, origin } = window.location;
|
||||
return LOCAL_HOSTS.has(hostname) ? origin : null;
|
||||
}
|
||||
|
||||
+214
-5
@@ -33,10 +33,18 @@ import { Route as PluginsNameRouteImport } from './routes/plugins/$name'
|
||||
import { Route as PackagesNewRouteImport } from './routes/packages/new'
|
||||
import { Route as PackagesNameRouteImport } from './routes/packages/$name'
|
||||
import { Route as OrgsHandleRouteImport } from './routes/orgs/$handle'
|
||||
import { Route as ManagementUsersRouteImport } from './routes/management/users'
|
||||
import { Route as ManagementPluginsRouteImport } from './routes/management/plugins'
|
||||
import { Route as ManagementModerationRouteImport } from './routes/management/moderation'
|
||||
import { Route as ManagementMigrationsRouteImport } from './routes/management/migrations'
|
||||
import { Route as ManagementClawpacksRouteImport } from './routes/management/clawpacks'
|
||||
import { Route as CliAuthRouteImport } from './routes/cli/auth'
|
||||
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
|
||||
import { Route as ManagementPluginsNameRouteImport } from './routes/management/plugins/$name'
|
||||
import { Route as ManagementMigrationsBundledPluginIdRouteImport } from './routes/management/migrations/$bundledPluginId'
|
||||
import { Route as OwnerSlugSettingsRouteImport } from './routes/$owner/$slug/settings'
|
||||
import { Route as PluginsNameSecurityScannerRouteImport } from './routes/plugins/$name/security/$scanner'
|
||||
import { Route as ManagementClawpacksReleasesReleaseIdRouteImport } from './routes/management/clawpacks/releases/$releaseId'
|
||||
import { Route as OwnerSlugSecurityScannerRouteImport } from './routes/$owner/$slug/security/$scanner'
|
||||
|
||||
const UploadRoute = UploadRouteImport.update({
|
||||
@@ -159,6 +167,31 @@ const OrgsHandleRoute = OrgsHandleRouteImport.update({
|
||||
path: '/orgs/$handle',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ManagementUsersRoute = ManagementUsersRouteImport.update({
|
||||
id: '/users',
|
||||
path: '/users',
|
||||
getParentRoute: () => ManagementRoute,
|
||||
} as any)
|
||||
const ManagementPluginsRoute = ManagementPluginsRouteImport.update({
|
||||
id: '/plugins',
|
||||
path: '/plugins',
|
||||
getParentRoute: () => ManagementRoute,
|
||||
} as any)
|
||||
const ManagementModerationRoute = ManagementModerationRouteImport.update({
|
||||
id: '/moderation',
|
||||
path: '/moderation',
|
||||
getParentRoute: () => ManagementRoute,
|
||||
} as any)
|
||||
const ManagementMigrationsRoute = ManagementMigrationsRouteImport.update({
|
||||
id: '/migrations',
|
||||
path: '/migrations',
|
||||
getParentRoute: () => ManagementRoute,
|
||||
} as any)
|
||||
const ManagementClawpacksRoute = ManagementClawpacksRouteImport.update({
|
||||
id: '/clawpacks',
|
||||
path: '/clawpacks',
|
||||
getParentRoute: () => ManagementRoute,
|
||||
} as any)
|
||||
const CliAuthRoute = CliAuthRouteImport.update({
|
||||
id: '/cli/auth',
|
||||
path: '/cli/auth',
|
||||
@@ -169,6 +202,17 @@ const OwnerSlugRoute = OwnerSlugRouteImport.update({
|
||||
path: '/$owner/$slug',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ManagementPluginsNameRoute = ManagementPluginsNameRouteImport.update({
|
||||
id: '/$name',
|
||||
path: '/$name',
|
||||
getParentRoute: () => ManagementPluginsRoute,
|
||||
} as any)
|
||||
const ManagementMigrationsBundledPluginIdRoute =
|
||||
ManagementMigrationsBundledPluginIdRouteImport.update({
|
||||
id: '/$bundledPluginId',
|
||||
path: '/$bundledPluginId',
|
||||
getParentRoute: () => ManagementMigrationsRoute,
|
||||
} as any)
|
||||
const OwnerSlugSettingsRoute = OwnerSlugSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
@@ -180,6 +224,12 @@ const PluginsNameSecurityScannerRoute =
|
||||
path: '/security/$scanner',
|
||||
getParentRoute: () => PluginsNameRoute,
|
||||
} as any)
|
||||
const ManagementClawpacksReleasesReleaseIdRoute =
|
||||
ManagementClawpacksReleasesReleaseIdRouteImport.update({
|
||||
id: '/releases/$releaseId',
|
||||
path: '/releases/$releaseId',
|
||||
getParentRoute: () => ManagementClawpacksRoute,
|
||||
} as any)
|
||||
const OwnerSlugSecurityScannerRoute =
|
||||
OwnerSlugSecurityScannerRouteImport.update({
|
||||
id: '/security/$scanner',
|
||||
@@ -193,7 +243,7 @@ export interface FileRoutesByFullPath {
|
||||
'/admin': typeof AdminRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/import': typeof ImportRoute
|
||||
'/management': typeof ManagementRoute
|
||||
'/management': typeof ManagementRouteWithChildren
|
||||
'/publish-plugin': typeof PublishPluginRoute
|
||||
'/publish-skill': typeof PublishSkillRoute
|
||||
'/search': typeof SearchRoute
|
||||
@@ -202,6 +252,11 @@ export interface FileRoutesByFullPath {
|
||||
'/upload': typeof UploadRoute
|
||||
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
|
||||
'/cli/auth': typeof CliAuthRoute
|
||||
'/management/clawpacks': typeof ManagementClawpacksRouteWithChildren
|
||||
'/management/migrations': typeof ManagementMigrationsRouteWithChildren
|
||||
'/management/moderation': typeof ManagementModerationRoute
|
||||
'/management/plugins': typeof ManagementPluginsRouteWithChildren
|
||||
'/management/users': typeof ManagementUsersRoute
|
||||
'/orgs/$handle': typeof OrgsHandleRoute
|
||||
'/packages/$name': typeof PackagesNameRoute
|
||||
'/packages/new': typeof PackagesNewRoute
|
||||
@@ -215,7 +270,10 @@ export interface FileRoutesByFullPath {
|
||||
'/souls/': typeof SoulsIndexRoute
|
||||
'/users/': typeof UsersIndexRoute
|
||||
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
|
||||
'/management/migrations/$bundledPluginId': typeof ManagementMigrationsBundledPluginIdRoute
|
||||
'/management/plugins/$name': typeof ManagementPluginsNameRoute
|
||||
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
|
||||
'/management/clawpacks/releases/$releaseId': typeof ManagementClawpacksReleasesReleaseIdRoute
|
||||
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -224,7 +282,7 @@ export interface FileRoutesByTo {
|
||||
'/admin': typeof AdminRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/import': typeof ImportRoute
|
||||
'/management': typeof ManagementRoute
|
||||
'/management': typeof ManagementRouteWithChildren
|
||||
'/publish-plugin': typeof PublishPluginRoute
|
||||
'/publish-skill': typeof PublishSkillRoute
|
||||
'/search': typeof SearchRoute
|
||||
@@ -233,6 +291,11 @@ export interface FileRoutesByTo {
|
||||
'/upload': typeof UploadRoute
|
||||
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
|
||||
'/cli/auth': typeof CliAuthRoute
|
||||
'/management/clawpacks': typeof ManagementClawpacksRouteWithChildren
|
||||
'/management/migrations': typeof ManagementMigrationsRouteWithChildren
|
||||
'/management/moderation': typeof ManagementModerationRoute
|
||||
'/management/plugins': typeof ManagementPluginsRouteWithChildren
|
||||
'/management/users': typeof ManagementUsersRoute
|
||||
'/orgs/$handle': typeof OrgsHandleRoute
|
||||
'/packages/$name': typeof PackagesNameRoute
|
||||
'/packages/new': typeof PackagesNewRoute
|
||||
@@ -246,7 +309,10 @@ export interface FileRoutesByTo {
|
||||
'/souls': typeof SoulsIndexRoute
|
||||
'/users': typeof UsersIndexRoute
|
||||
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
|
||||
'/management/migrations/$bundledPluginId': typeof ManagementMigrationsBundledPluginIdRoute
|
||||
'/management/plugins/$name': typeof ManagementPluginsNameRoute
|
||||
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
|
||||
'/management/clawpacks/releases/$releaseId': typeof ManagementClawpacksReleasesReleaseIdRoute
|
||||
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
@@ -256,7 +322,7 @@ export interface FileRoutesById {
|
||||
'/admin': typeof AdminRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/import': typeof ImportRoute
|
||||
'/management': typeof ManagementRoute
|
||||
'/management': typeof ManagementRouteWithChildren
|
||||
'/publish-plugin': typeof PublishPluginRoute
|
||||
'/publish-skill': typeof PublishSkillRoute
|
||||
'/search': typeof SearchRoute
|
||||
@@ -265,6 +331,11 @@ export interface FileRoutesById {
|
||||
'/upload': typeof UploadRoute
|
||||
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
|
||||
'/cli/auth': typeof CliAuthRoute
|
||||
'/management/clawpacks': typeof ManagementClawpacksRouteWithChildren
|
||||
'/management/migrations': typeof ManagementMigrationsRouteWithChildren
|
||||
'/management/moderation': typeof ManagementModerationRoute
|
||||
'/management/plugins': typeof ManagementPluginsRouteWithChildren
|
||||
'/management/users': typeof ManagementUsersRoute
|
||||
'/orgs/$handle': typeof OrgsHandleRoute
|
||||
'/packages/$name': typeof PackagesNameRoute
|
||||
'/packages/new': typeof PackagesNewRoute
|
||||
@@ -278,7 +349,10 @@ export interface FileRoutesById {
|
||||
'/souls/': typeof SoulsIndexRoute
|
||||
'/users/': typeof UsersIndexRoute
|
||||
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
|
||||
'/management/migrations/$bundledPluginId': typeof ManagementMigrationsBundledPluginIdRoute
|
||||
'/management/plugins/$name': typeof ManagementPluginsNameRoute
|
||||
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
|
||||
'/management/clawpacks/releases/$releaseId': typeof ManagementClawpacksReleasesReleaseIdRoute
|
||||
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
@@ -298,6 +372,11 @@ export interface FileRouteTypes {
|
||||
| '/upload'
|
||||
| '/$owner/$slug'
|
||||
| '/cli/auth'
|
||||
| '/management/clawpacks'
|
||||
| '/management/migrations'
|
||||
| '/management/moderation'
|
||||
| '/management/plugins'
|
||||
| '/management/users'
|
||||
| '/orgs/$handle'
|
||||
| '/packages/$name'
|
||||
| '/packages/new'
|
||||
@@ -311,7 +390,10 @@ export interface FileRouteTypes {
|
||||
| '/souls/'
|
||||
| '/users/'
|
||||
| '/$owner/$slug/settings'
|
||||
| '/management/migrations/$bundledPluginId'
|
||||
| '/management/plugins/$name'
|
||||
| '/$owner/$slug/security/$scanner'
|
||||
| '/management/clawpacks/releases/$releaseId'
|
||||
| '/plugins/$name/security/$scanner'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -329,6 +411,11 @@ export interface FileRouteTypes {
|
||||
| '/upload'
|
||||
| '/$owner/$slug'
|
||||
| '/cli/auth'
|
||||
| '/management/clawpacks'
|
||||
| '/management/migrations'
|
||||
| '/management/moderation'
|
||||
| '/management/plugins'
|
||||
| '/management/users'
|
||||
| '/orgs/$handle'
|
||||
| '/packages/$name'
|
||||
| '/packages/new'
|
||||
@@ -342,7 +429,10 @@ export interface FileRouteTypes {
|
||||
| '/souls'
|
||||
| '/users'
|
||||
| '/$owner/$slug/settings'
|
||||
| '/management/migrations/$bundledPluginId'
|
||||
| '/management/plugins/$name'
|
||||
| '/$owner/$slug/security/$scanner'
|
||||
| '/management/clawpacks/releases/$releaseId'
|
||||
| '/plugins/$name/security/$scanner'
|
||||
id:
|
||||
| '__root__'
|
||||
@@ -360,6 +450,11 @@ export interface FileRouteTypes {
|
||||
| '/upload'
|
||||
| '/$owner/$slug'
|
||||
| '/cli/auth'
|
||||
| '/management/clawpacks'
|
||||
| '/management/migrations'
|
||||
| '/management/moderation'
|
||||
| '/management/plugins'
|
||||
| '/management/users'
|
||||
| '/orgs/$handle'
|
||||
| '/packages/$name'
|
||||
| '/packages/new'
|
||||
@@ -373,7 +468,10 @@ export interface FileRouteTypes {
|
||||
| '/souls/'
|
||||
| '/users/'
|
||||
| '/$owner/$slug/settings'
|
||||
| '/management/migrations/$bundledPluginId'
|
||||
| '/management/plugins/$name'
|
||||
| '/$owner/$slug/security/$scanner'
|
||||
| '/management/clawpacks/releases/$releaseId'
|
||||
| '/plugins/$name/security/$scanner'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
@@ -383,7 +481,7 @@ export interface RootRouteChildren {
|
||||
AdminRoute: typeof AdminRoute
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
ImportRoute: typeof ImportRoute
|
||||
ManagementRoute: typeof ManagementRoute
|
||||
ManagementRoute: typeof ManagementRouteWithChildren
|
||||
PublishPluginRoute: typeof PublishPluginRoute
|
||||
PublishSkillRoute: typeof PublishSkillRoute
|
||||
SearchRoute: typeof SearchRoute
|
||||
@@ -576,6 +674,41 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof OrgsHandleRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/management/users': {
|
||||
id: '/management/users'
|
||||
path: '/users'
|
||||
fullPath: '/management/users'
|
||||
preLoaderRoute: typeof ManagementUsersRouteImport
|
||||
parentRoute: typeof ManagementRoute
|
||||
}
|
||||
'/management/plugins': {
|
||||
id: '/management/plugins'
|
||||
path: '/plugins'
|
||||
fullPath: '/management/plugins'
|
||||
preLoaderRoute: typeof ManagementPluginsRouteImport
|
||||
parentRoute: typeof ManagementRoute
|
||||
}
|
||||
'/management/moderation': {
|
||||
id: '/management/moderation'
|
||||
path: '/moderation'
|
||||
fullPath: '/management/moderation'
|
||||
preLoaderRoute: typeof ManagementModerationRouteImport
|
||||
parentRoute: typeof ManagementRoute
|
||||
}
|
||||
'/management/migrations': {
|
||||
id: '/management/migrations'
|
||||
path: '/migrations'
|
||||
fullPath: '/management/migrations'
|
||||
preLoaderRoute: typeof ManagementMigrationsRouteImport
|
||||
parentRoute: typeof ManagementRoute
|
||||
}
|
||||
'/management/clawpacks': {
|
||||
id: '/management/clawpacks'
|
||||
path: '/clawpacks'
|
||||
fullPath: '/management/clawpacks'
|
||||
preLoaderRoute: typeof ManagementClawpacksRouteImport
|
||||
parentRoute: typeof ManagementRoute
|
||||
}
|
||||
'/cli/auth': {
|
||||
id: '/cli/auth'
|
||||
path: '/cli/auth'
|
||||
@@ -590,6 +723,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof OwnerSlugRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/management/plugins/$name': {
|
||||
id: '/management/plugins/$name'
|
||||
path: '/$name'
|
||||
fullPath: '/management/plugins/$name'
|
||||
preLoaderRoute: typeof ManagementPluginsNameRouteImport
|
||||
parentRoute: typeof ManagementPluginsRoute
|
||||
}
|
||||
'/management/migrations/$bundledPluginId': {
|
||||
id: '/management/migrations/$bundledPluginId'
|
||||
path: '/$bundledPluginId'
|
||||
fullPath: '/management/migrations/$bundledPluginId'
|
||||
preLoaderRoute: typeof ManagementMigrationsBundledPluginIdRouteImport
|
||||
parentRoute: typeof ManagementMigrationsRoute
|
||||
}
|
||||
'/$owner/$slug/settings': {
|
||||
id: '/$owner/$slug/settings'
|
||||
path: '/settings'
|
||||
@@ -604,6 +751,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PluginsNameSecurityScannerRouteImport
|
||||
parentRoute: typeof PluginsNameRoute
|
||||
}
|
||||
'/management/clawpacks/releases/$releaseId': {
|
||||
id: '/management/clawpacks/releases/$releaseId'
|
||||
path: '/releases/$releaseId'
|
||||
fullPath: '/management/clawpacks/releases/$releaseId'
|
||||
preLoaderRoute: typeof ManagementClawpacksReleasesReleaseIdRouteImport
|
||||
parentRoute: typeof ManagementClawpacksRoute
|
||||
}
|
||||
'/$owner/$slug/security/$scanner': {
|
||||
id: '/$owner/$slug/security/$scanner'
|
||||
path: '/security/$scanner'
|
||||
@@ -614,6 +768,61 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
}
|
||||
|
||||
interface ManagementClawpacksRouteChildren {
|
||||
ManagementClawpacksReleasesReleaseIdRoute: typeof ManagementClawpacksReleasesReleaseIdRoute
|
||||
}
|
||||
|
||||
const ManagementClawpacksRouteChildren: ManagementClawpacksRouteChildren = {
|
||||
ManagementClawpacksReleasesReleaseIdRoute:
|
||||
ManagementClawpacksReleasesReleaseIdRoute,
|
||||
}
|
||||
|
||||
const ManagementClawpacksRouteWithChildren =
|
||||
ManagementClawpacksRoute._addFileChildren(ManagementClawpacksRouteChildren)
|
||||
|
||||
interface ManagementMigrationsRouteChildren {
|
||||
ManagementMigrationsBundledPluginIdRoute: typeof ManagementMigrationsBundledPluginIdRoute
|
||||
}
|
||||
|
||||
const ManagementMigrationsRouteChildren: ManagementMigrationsRouteChildren = {
|
||||
ManagementMigrationsBundledPluginIdRoute:
|
||||
ManagementMigrationsBundledPluginIdRoute,
|
||||
}
|
||||
|
||||
const ManagementMigrationsRouteWithChildren =
|
||||
ManagementMigrationsRoute._addFileChildren(ManagementMigrationsRouteChildren)
|
||||
|
||||
interface ManagementPluginsRouteChildren {
|
||||
ManagementPluginsNameRoute: typeof ManagementPluginsNameRoute
|
||||
}
|
||||
|
||||
const ManagementPluginsRouteChildren: ManagementPluginsRouteChildren = {
|
||||
ManagementPluginsNameRoute: ManagementPluginsNameRoute,
|
||||
}
|
||||
|
||||
const ManagementPluginsRouteWithChildren =
|
||||
ManagementPluginsRoute._addFileChildren(ManagementPluginsRouteChildren)
|
||||
|
||||
interface ManagementRouteChildren {
|
||||
ManagementClawpacksRoute: typeof ManagementClawpacksRouteWithChildren
|
||||
ManagementMigrationsRoute: typeof ManagementMigrationsRouteWithChildren
|
||||
ManagementModerationRoute: typeof ManagementModerationRoute
|
||||
ManagementPluginsRoute: typeof ManagementPluginsRouteWithChildren
|
||||
ManagementUsersRoute: typeof ManagementUsersRoute
|
||||
}
|
||||
|
||||
const ManagementRouteChildren: ManagementRouteChildren = {
|
||||
ManagementClawpacksRoute: ManagementClawpacksRouteWithChildren,
|
||||
ManagementMigrationsRoute: ManagementMigrationsRouteWithChildren,
|
||||
ManagementModerationRoute: ManagementModerationRoute,
|
||||
ManagementPluginsRoute: ManagementPluginsRouteWithChildren,
|
||||
ManagementUsersRoute: ManagementUsersRoute,
|
||||
}
|
||||
|
||||
const ManagementRouteWithChildren = ManagementRoute._addFileChildren(
|
||||
ManagementRouteChildren,
|
||||
)
|
||||
|
||||
interface OwnerSlugRouteChildren {
|
||||
OwnerSlugSettingsRoute: typeof OwnerSlugSettingsRoute
|
||||
OwnerSlugSecurityScannerRoute: typeof OwnerSlugSecurityScannerRoute
|
||||
@@ -646,7 +855,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
AdminRoute: AdminRoute,
|
||||
DashboardRoute: DashboardRoute,
|
||||
ImportRoute: ImportRoute,
|
||||
ManagementRoute: ManagementRoute,
|
||||
ManagementRoute: ManagementRouteWithChildren,
|
||||
PublishPluginRoute: PublishPluginRoute,
|
||||
PublishSkillRoute: PublishSkillRoute,
|
||||
SearchRoute: SearchRoute,
|
||||
|
||||
@@ -9,11 +9,16 @@ import { SkillDetailPage } from "../../components/SkillDetailPage";
|
||||
import { buildSkillMeta } from "../../lib/og";
|
||||
import { fetchSkillPageData } from "../../lib/skillPage";
|
||||
|
||||
const RESERVED_OWNER_ROUTE_SEGMENTS = new Set(["api", "assets", "og"]);
|
||||
|
||||
export const Route = createFileRoute("/$owner/$slug")({
|
||||
beforeLoad: ({ params }) => {
|
||||
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
|
||||
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
|
||||
if (!isHandle && !isOwnerId) {
|
||||
const isReservedInfrastructurePath = RESERVED_OWNER_ROUTE_SEGMENTS.has(
|
||||
params.owner.toLowerCase(),
|
||||
);
|
||||
if ((!isHandle && !isOwnerId) || isReservedInfrastructurePath) {
|
||||
throw notFound();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -92,13 +92,17 @@ type TestPackage = {
|
||||
versions: number;
|
||||
};
|
||||
verification: null;
|
||||
scanStatus: "suspicious" | "malicious";
|
||||
scanStatus: "clean" | "suspicious" | "malicious";
|
||||
latestRelease: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
vtStatus: string | null;
|
||||
llmStatus: string | null;
|
||||
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
|
||||
clawpackAvailable: boolean;
|
||||
clawpackSha256: string | null;
|
||||
hostTargets: null;
|
||||
environment: null;
|
||||
};
|
||||
rescanState: TestSkill["rescanState"];
|
||||
};
|
||||
@@ -179,6 +183,10 @@ function createPackage(overrides?: Partial<TestPackage>): TestPackage {
|
||||
vtStatus: "malicious",
|
||||
llmStatus: "malicious",
|
||||
staticScanStatus: "malicious",
|
||||
clawpackAvailable: true,
|
||||
clawpackSha256: "a".repeat(64),
|
||||
hostTargets: null,
|
||||
environment: null,
|
||||
},
|
||||
rescanState: {
|
||||
maxRequests: 3,
|
||||
@@ -260,6 +268,14 @@ describe("Dashboard minimal rows", () => {
|
||||
expect(document.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("links new publishers to plugin publishing from the empty state", () => {
|
||||
arrangeDashboard({ skills: [], packages: [] });
|
||||
|
||||
renderDashboard();
|
||||
|
||||
expect(screen.getByRole("link", { name: "Publish a Plugin" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render row-level actions", () => {
|
||||
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
|
||||
|
||||
@@ -271,6 +287,32 @@ describe("Dashboard minimal rows", () => {
|
||||
expect(screen.queryByRole("link", { name: /^view$/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces missing Claw Pack artifacts in plugin dashboard status", () => {
|
||||
arrangeDashboard({
|
||||
packages: [
|
||||
createPackage({
|
||||
scanStatus: "clean",
|
||||
latestRelease: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
vtStatus: "clean",
|
||||
llmStatus: "clean",
|
||||
staticScanStatus: "clean",
|
||||
clawpackAvailable: false,
|
||||
clawpackSha256: null,
|
||||
hostTargets: null,
|
||||
environment: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
renderDashboard();
|
||||
|
||||
expect(screen.getByText("Claw Pack missing")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Claw Pack missing status reason" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render column titles, scanner details, or plugin metadata chips", () => {
|
||||
arrangeDashboard({ skills: [createSkill()], packages: [createPackage()] });
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
/>
|
||||
<ClientOnly>
|
||||
<Analytics />
|
||||
<ProductionAnalytics />
|
||||
</ClientOnly>
|
||||
</AppProviders>
|
||||
<Scripts />
|
||||
@@ -157,6 +157,13 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProductionAnalytics() {
|
||||
if (typeof window === "undefined") return null;
|
||||
const hostname = window.location.hostname.toLowerCase();
|
||||
if (hostname !== "clawhub.ai" && hostname !== "www.clawhub.ai") return null;
|
||||
return <Analytics />;
|
||||
}
|
||||
|
||||
/** Resets the error boundary whenever the route pathname changes. */
|
||||
function RouteErrorBoundary({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -17,6 +17,16 @@ import {
|
||||
} from "../components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../components/ui/tooltip";
|
||||
import { getUserFacingConvexError } from "../lib/convexError";
|
||||
import { deriveClawPackLifecycle } from "../lib/packageLifecycle";
|
||||
|
||||
type PluginPublishSearch = {
|
||||
ownerHandle: string | undefined;
|
||||
name: string | undefined;
|
||||
displayName: string | undefined;
|
||||
family: "code-plugin" | "bundle-plugin" | undefined;
|
||||
nextVersion: string | undefined;
|
||||
sourceRepo: string | undefined;
|
||||
};
|
||||
|
||||
const emptyPluginPublishSearch = {
|
||||
ownerHandle: undefined,
|
||||
@@ -25,7 +35,7 @@ const emptyPluginPublishSearch = {
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
} as const;
|
||||
} satisfies PluginPublishSearch;
|
||||
|
||||
type DashboardSkill = Pick<
|
||||
Doc<"skills">,
|
||||
@@ -91,6 +101,19 @@ type DashboardPackage = {
|
||||
vtStatus: string | null;
|
||||
llmStatus: string | null;
|
||||
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
|
||||
clawpackAvailable: boolean;
|
||||
clawpackSha256: string | null;
|
||||
hostTargets: Array<{
|
||||
os: "darwin" | "linux" | "win32";
|
||||
arch: "arm64" | "x64";
|
||||
libc?: "glibc" | "musl";
|
||||
}> | null;
|
||||
environment: {
|
||||
requiresLocalDesktop?: boolean;
|
||||
requiresBrowser?: boolean;
|
||||
requiresAudioDevice?: boolean;
|
||||
requiresNetwork?: boolean;
|
||||
} | null;
|
||||
} | null;
|
||||
rescanState?: DashboardRescanState | null;
|
||||
};
|
||||
@@ -201,6 +224,11 @@ export function Dashboard() {
|
||||
Publish a Skill
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/publish-plugin" search={{ ...emptyPluginPublishSearch, ownerHandle }}>
|
||||
Publish a Plugin
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link
|
||||
to="/skills"
|
||||
@@ -320,6 +348,7 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
|
||||
settingsHref={settingsHref}
|
||||
statusLabel={status.label}
|
||||
rescanState={skill.rescanState ?? null}
|
||||
releaseLink={null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -366,6 +395,34 @@ function packageDashboardStatus(pkg: DashboardPackage): {
|
||||
description: string;
|
||||
variant: "default" | "pending" | "warning" | "destructive" | "success";
|
||||
} {
|
||||
const lifecycle = deriveClawPackLifecycle({
|
||||
available: pkg.latestRelease?.clawpackAvailable ?? false,
|
||||
verificationScanStatus: pkg.scanStatus,
|
||||
vtStatus: pkg.latestRelease?.vtStatus,
|
||||
llmStatus: pkg.latestRelease?.llmStatus,
|
||||
staticScanStatus: pkg.latestRelease?.staticScanStatus,
|
||||
});
|
||||
if (lifecycle.state === "clawpack-missing") {
|
||||
return {
|
||||
label: lifecycle.label,
|
||||
description: lifecycle.description,
|
||||
variant: "warning",
|
||||
};
|
||||
}
|
||||
if (lifecycle.state === "scan-pending") {
|
||||
return {
|
||||
label: lifecycle.label,
|
||||
description: lifecycle.description,
|
||||
variant: "pending",
|
||||
};
|
||||
}
|
||||
if (lifecycle.state === "ready") {
|
||||
return {
|
||||
label: lifecycle.label,
|
||||
description: lifecycle.description,
|
||||
variant: "success",
|
||||
};
|
||||
}
|
||||
const releaseStatuses = new Set([
|
||||
pkg.latestRelease?.vtStatus,
|
||||
pkg.latestRelease?.llmStatus,
|
||||
@@ -406,7 +463,7 @@ function packageDashboardStatus(pkg: DashboardPackage): {
|
||||
};
|
||||
}
|
||||
|
||||
function PackageRow({ pkg }: { pkg: DashboardPackage; ownerHandle: string }) {
|
||||
function PackageRow({ pkg, ownerHandle }: { pkg: DashboardPackage; ownerHandle: string }) {
|
||||
const status = packageDashboardStatus(pkg);
|
||||
|
||||
return (
|
||||
@@ -429,13 +486,25 @@ function PackageRow({ pkg }: { pkg: DashboardPackage; ownerHandle: string }) {
|
||||
settingsHref={`/plugins/${encodeURIComponent(pkg.name)}`}
|
||||
statusLabel={status.label}
|
||||
rescanState={pkg.rescanState ?? null}
|
||||
releaseLink={{
|
||||
to: "/publish-plugin",
|
||||
search: {
|
||||
...emptyPluginPublishSearch,
|
||||
ownerHandle,
|
||||
name: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
family: pkg.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
|
||||
nextVersion: undefined,
|
||||
sourceRepo: pkg.sourceRepo ?? undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function canShowDashboardRescan(statusLabel: string, state: DashboardRescanState | null) {
|
||||
if (statusLabel === "Visible") return false;
|
||||
if (statusLabel === "Visible" || statusLabel === "Ready") return false;
|
||||
if (!state) return true;
|
||||
return state.canRequest && !state.inProgressRequest && state.remainingRequests > 0;
|
||||
}
|
||||
@@ -447,6 +516,7 @@ function RowMenu({
|
||||
settingsHref,
|
||||
statusLabel,
|
||||
rescanState,
|
||||
releaseLink,
|
||||
}: {
|
||||
kind: "skill" | "plugin";
|
||||
targetId: string;
|
||||
@@ -454,6 +524,16 @@ function RowMenu({
|
||||
settingsHref: string;
|
||||
statusLabel: string;
|
||||
rescanState: DashboardRescanState | null;
|
||||
releaseLink: {
|
||||
to: "/publish-plugin";
|
||||
search: PluginPublishSearch & {
|
||||
ownerHandle: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: "code-plugin" | "bundle-plugin";
|
||||
sourceRepo?: string;
|
||||
};
|
||||
} | null;
|
||||
}) {
|
||||
const requestSkillRescan = useMutation(api.skills.requestRescan);
|
||||
const requestPluginRescan = useMutation(api.packages.requestRescan);
|
||||
@@ -504,6 +584,14 @@ function RowMenu({
|
||||
Settings
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
{releaseLink ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={releaseLink.to} search={releaseLink.search}>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
New release
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{showRescanItem ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isRequesting || isScanInProgress}
|
||||
|
||||
+403
-12
@@ -1,8 +1,10 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { createFileRoute, Link, Outlet, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { ManagementAccessNotice } from "../components/ManagementAccessNotice";
|
||||
import { PluginOperationsNav } from "../components/PluginOperationsNav";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
@@ -19,6 +21,16 @@ import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
|
||||
const SKILL_AUDIT_LOG_LIMIT = 10;
|
||||
|
||||
const packageApiRefs = api as unknown as {
|
||||
packages: {
|
||||
setModerationVerdict: unknown;
|
||||
getClawPackMigrationStatus: unknown;
|
||||
backfillClawPackArtifacts: unknown;
|
||||
backfillClawPackSearchIndex: unknown;
|
||||
revokeClawPackArtifact: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type ManagementUserSummary = {
|
||||
_id: Id<"users">;
|
||||
handle?: string | null;
|
||||
@@ -84,6 +96,33 @@ type PluginByNameResult = {
|
||||
highlighted: { byUserId: Id<"users">; at: number } | null;
|
||||
} | null;
|
||||
|
||||
type ClawPackMigrationStatus = {
|
||||
missingSample: Array<{
|
||||
releaseId: Id<"packageReleases">;
|
||||
packageId: Id<"packages">;
|
||||
name: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
createdAt: number;
|
||||
fileCount: number;
|
||||
}>;
|
||||
missingSampleSize: number;
|
||||
generatedClawPackSampleSize: number;
|
||||
generatedClawPackBytes: number;
|
||||
sampleLimit: number;
|
||||
};
|
||||
|
||||
type ClawPackBackfillResult = {
|
||||
processed?: number;
|
||||
succeeded?: number;
|
||||
failed?: number;
|
||||
skipped?: number;
|
||||
isDone?: boolean;
|
||||
continueCursor?: string | null;
|
||||
};
|
||||
|
||||
type PackageScanStatus = "clean" | "suspicious" | "malicious" | "pending" | "not-run";
|
||||
|
||||
function resolveOwnerParam(
|
||||
handle: string | null | undefined,
|
||||
ownerId?: Id<"users"> | Id<"publishers">,
|
||||
@@ -105,15 +144,32 @@ function promptUnbanReason(label: string) {
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function promptClawPackRevocationReason(label: string) {
|
||||
const result = window.prompt(`Revoke Claw Pack for ${label}. Reason required.`);
|
||||
if (result === null) return null;
|
||||
const trimmed = result.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function promptClawPackBackfill(label: string) {
|
||||
return window.confirm(`${label}\n\nThis writes Claw Pack metadata in Convex. Continue?`);
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/management")({
|
||||
validateSearch: (search) => ({
|
||||
skill: typeof search.skill === "string" && search.skill.trim() ? search.skill : undefined,
|
||||
plugin: typeof search.plugin === "string" && search.plugin.trim() ? search.plugin : undefined,
|
||||
}),
|
||||
component: Management,
|
||||
component: ManagementRouteComponent,
|
||||
});
|
||||
|
||||
function Management() {
|
||||
function ManagementRouteComponent() {
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
if (pathname !== "/management") return <Outlet />;
|
||||
return <ManagementConsole />;
|
||||
}
|
||||
|
||||
function ManagementConsole() {
|
||||
const { me } = useAuthStatus();
|
||||
const search = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
@@ -147,6 +203,22 @@ function Management() {
|
||||
const unbanUser = useMutation(api.users.unbanUser);
|
||||
const setBatch = useMutation(api.skills.setBatch);
|
||||
const setPackageBatch = useMutation(api.packages.setBatch);
|
||||
const setPackageModerationVerdict = useMutation(
|
||||
packageApiRefs.packages.setModerationVerdict as never,
|
||||
) as unknown as (args: {
|
||||
packageId: Id<"packages">;
|
||||
verdict: PackageScanStatus;
|
||||
note?: string;
|
||||
}) => Promise<unknown>;
|
||||
const revokeClawPackArtifact = useMutation(
|
||||
packageApiRefs.packages.revokeClawPackArtifact as never,
|
||||
) as unknown as (args: { releaseId: Id<"packageReleases">; reason?: string }) => Promise<unknown>;
|
||||
const backfillClawPackArtifacts = useAction(
|
||||
packageApiRefs.packages.backfillClawPackArtifacts as never,
|
||||
) as unknown as (args: { limit?: number }) => Promise<unknown>;
|
||||
const backfillClawPackSearchIndex = useAction(
|
||||
packageApiRefs.packages.backfillClawPackSearchIndex as never,
|
||||
) as unknown as (args: { limit?: number; cursor?: string }) => Promise<unknown>;
|
||||
const setSoftDeleted = useMutation(api.skills.setSoftDeleted);
|
||||
const hardDelete = useMutation(api.skills.hardDelete);
|
||||
const changeOwner = useMutation(api.skills.changeOwner);
|
||||
@@ -163,6 +235,15 @@ function Management() {
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const [userSearchDebounced, setUserSearchDebounced] = useState("");
|
||||
const [pluginSearch, setPluginSearch] = useState(selectedPluginName ?? "");
|
||||
const [pluginModerationVerdict, setPluginModerationVerdict] =
|
||||
useState<PackageScanStatus>("clean");
|
||||
const [pluginModerationNote, setPluginModerationNote] = useState("");
|
||||
const [clawPackBackfillLimit, setClawPackBackfillLimit] = useState(10);
|
||||
const [clawPackIndexCursor, setClawPackIndexCursor] = useState("");
|
||||
const [clawPackLastResult, setClawPackLastResult] = useState<{
|
||||
kind: "artifact-backfill" | "index-backfill";
|
||||
result: ClawPackBackfillResult;
|
||||
} | null>(null);
|
||||
const [skillOverrideNote, setSkillOverrideNote] = useState("");
|
||||
|
||||
const userQuery = userSearchDebounced.trim();
|
||||
@@ -170,6 +251,10 @@ function Management() {
|
||||
api.users.list,
|
||||
admin ? { limit: 200, search: userQuery || undefined } : "skip",
|
||||
) as { items: Doc<"users">[]; total: number } | undefined;
|
||||
const clawPackMigration = useQuery(
|
||||
packageApiRefs.packages.getClawPackMigrationStatus as never,
|
||||
staff ? {} : "skip",
|
||||
) as ClawPackMigrationStatus | undefined;
|
||||
|
||||
const selectedOwnerUserId = selectedSkill?.skill?.ownerUserId ?? null;
|
||||
const selectedCanonicalSlug = selectedSkill?.canonical?.skill?.slug ?? "";
|
||||
@@ -188,6 +273,13 @@ function Management() {
|
||||
setPluginSearch(selectedPluginName ?? "");
|
||||
}, [selectedPluginName]);
|
||||
|
||||
useEffect(() => {
|
||||
setPluginModerationVerdict(
|
||||
(selectedPlugin?.package?.scanStatus ?? "clean") as PackageScanStatus,
|
||||
);
|
||||
setPluginModerationNote("");
|
||||
}, [selectedPlugin?.package?._id, selectedPlugin?.package?.scanStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setReportSearchDebounced(reportSearch), 250);
|
||||
return () => clearTimeout(handle);
|
||||
@@ -199,11 +291,7 @@ function Management() {
|
||||
}, [userSearch]);
|
||||
|
||||
if (!staff) {
|
||||
return (
|
||||
<main className="section">
|
||||
<Card>Management only.</Card>
|
||||
</main>
|
||||
);
|
||||
return <ManagementAccessNotice me={me} />;
|
||||
}
|
||||
|
||||
if (!recentVersions || !reportedSkills || !duplicateCandidates) {
|
||||
@@ -254,7 +342,6 @@ function Management() {
|
||||
: "No users yet."
|
||||
: ""
|
||||
: "Loading users…";
|
||||
|
||||
const applySkillOverride = () => {
|
||||
if (!selectedSkill?.skill) return;
|
||||
void setSkillManualOverride({
|
||||
@@ -288,10 +375,186 @@ function Management() {
|
||||
});
|
||||
};
|
||||
|
||||
const clawPackSampleTotal = clawPackMigration
|
||||
? clawPackMigration.missingSampleSize + clawPackMigration.generatedClawPackSampleSize
|
||||
: 0;
|
||||
const clawPackSampleCoverage =
|
||||
clawPackSampleTotal > 0 && clawPackMigration
|
||||
? Math.round((clawPackMigration.generatedClawPackSampleSize / clawPackSampleTotal) * 100)
|
||||
: null;
|
||||
|
||||
const runClawPackArtifactBackfill = () => {
|
||||
const limit = Math.max(1, Math.min(clawPackBackfillLimit, 100));
|
||||
if (!promptClawPackBackfill(`Build Claw Pack artifacts for up to ${limit} legacy releases?`)) {
|
||||
return;
|
||||
}
|
||||
void backfillClawPackArtifacts({ limit })
|
||||
.then((result) => {
|
||||
setClawPackLastResult({
|
||||
kind: "artifact-backfill",
|
||||
result: result as ClawPackBackfillResult,
|
||||
});
|
||||
})
|
||||
.catch((error) => window.alert(formatMutationError(error)));
|
||||
};
|
||||
|
||||
const runClawPackIndexBackfill = () => {
|
||||
const limit = Math.max(1, Math.min(clawPackBackfillLimit, 100));
|
||||
if (
|
||||
!promptClawPackBackfill(`Rebuild Claw Pack search index rows for up to ${limit} releases?`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void backfillClawPackSearchIndex({
|
||||
limit,
|
||||
...(clawPackIndexCursor.trim() ? { cursor: clawPackIndexCursor.trim() } : {}),
|
||||
})
|
||||
.then((result) => {
|
||||
const typedResult = result as ClawPackBackfillResult;
|
||||
setClawPackLastResult({
|
||||
kind: "index-backfill",
|
||||
result: typedResult,
|
||||
});
|
||||
if (typedResult.continueCursor) setClawPackIndexCursor(typedResult.continueCursor);
|
||||
})
|
||||
.catch((error) => window.alert(formatMutationError(error)));
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<h1 className="section-title">Management console</h1>
|
||||
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
|
||||
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="section-title">Management console</h1>
|
||||
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management/moderation" search={{ skill: undefined, plugin: undefined }}>
|
||||
Open moderation queue
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management/migrations" search={{ skill: undefined, plugin: undefined }}>
|
||||
Open migration readiness
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PluginOperationsNav />
|
||||
|
||||
<Card className="mb-5">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Claw Pack migration</h2>
|
||||
<p className="section-subtitle m-0">
|
||||
Quick status snapshot. Use the dedicated Claw Pack page for dry-run and cursor
|
||||
controls.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management/clawpacks" search={{ skill: undefined, plugin: undefined }}>
|
||||
Open Claw Pack ops
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="management-sublist">
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Sample coverage</span>
|
||||
<span>
|
||||
{clawPackMigration
|
||||
? clawPackSampleCoverage === null
|
||||
? "No sampled releases yet"
|
||||
: `${clawPackSampleCoverage}% generated across ${clawPackSampleTotal} sampled releases`
|
||||
: "Loading..."}
|
||||
</span>
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Missing sample</span>
|
||||
<span>
|
||||
{clawPackMigration
|
||||
? `${clawPackMigration.missingSampleSize} releases in the current sample`
|
||||
: "Loading..."}
|
||||
</span>
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Generated sample</span>
|
||||
<span>
|
||||
{clawPackMigration
|
||||
? `${clawPackMigration.generatedClawPackSampleSize} artifacts / ${formatBytesCompact(
|
||||
clawPackMigration.generatedClawPackBytes,
|
||||
)}`
|
||||
: "Loading..."}
|
||||
</span>
|
||||
</div>
|
||||
{clawPackMigration?.missingSample?.length ? (
|
||||
<div className="management-sublist">
|
||||
{clawPackMigration.missingSample.slice(0, 5).map((entry) => (
|
||||
<div key={entry.releaseId} className="management-report-item">
|
||||
<span className="management-report-meta">
|
||||
{entry.name}@{entry.version}
|
||||
</span>
|
||||
<span>
|
||||
{entry.fileCount} files · published {formatTimestamp(entry.createdAt)} ·{" "}
|
||||
<Link to="/management" search={{ skill: undefined, plugin: entry.name }}>
|
||||
manage
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{admin ? (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<div className="management-tool-grid">
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">batch limit</span>
|
||||
<input
|
||||
className="management-field"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={clawPackBackfillLimit}
|
||||
onChange={(event) =>
|
||||
setClawPackBackfillLimit(Number.parseInt(event.target.value, 10) || 1)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">index cursor</span>
|
||||
<input
|
||||
className="management-field"
|
||||
value={clawPackIndexCursor}
|
||||
onChange={(event) => setClawPackIndexCursor(event.target.value)}
|
||||
placeholder="optional continue cursor"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="management-actions management-actions-start">
|
||||
<Button type="button" onClick={runClawPackArtifactBackfill}>
|
||||
Build missing artifacts
|
||||
</Button>
|
||||
<Button type="button" onClick={runClawPackIndexBackfill}>
|
||||
Rebuild lookup index
|
||||
</Button>
|
||||
</div>
|
||||
{clawPackLastResult ? (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">
|
||||
Last {clawPackLastResult.kind.replace("-", " ")}
|
||||
</span>
|
||||
<span>
|
||||
processed {clawPackLastResult.result.processed ?? "?"} · succeeded{" "}
|
||||
{clawPackLastResult.result.succeeded ?? "?"} · failed{" "}
|
||||
{clawPackLastResult.result.failed ?? "?"}
|
||||
{clawPackLastResult.result.isDone === false ? " · more available" : ""}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Reported skills</h2>
|
||||
@@ -786,6 +1049,82 @@ function Management() {
|
||||
: "Not highlighted"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Claw Pack</span>
|
||||
<span>
|
||||
{latestRelease?.clawpackRevokedAt
|
||||
? `Revoked ${formatTimestamp(latestRelease.clawpackRevokedAt)}`
|
||||
: latestRelease?.clawpackStorageId
|
||||
? `${latestRelease.clawpackFileCount ?? 0} files · ${latestRelease.clawpackSha256?.slice(0, 12) ?? "no digest"}`
|
||||
: "Missing Claw Pack artifact"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Host targets</span>
|
||||
<span>
|
||||
{latestRelease?.hostTargetsSummary?.length
|
||||
? latestRelease.hostTargetsSummary
|
||||
.map((target) =>
|
||||
[target.os, target.arch, target.libc].filter(Boolean).join("-"),
|
||||
)
|
||||
.join(", ")
|
||||
: "No target summary yet"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Environment</span>
|
||||
<span>{formatEnvironmentSummary(latestRelease?.environmentSummary)}</span>
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Moderation</span>
|
||||
<span>{plugin.scanStatus}</span>
|
||||
</div>
|
||||
<section className="management-override-panel">
|
||||
<div className="management-tool-grid">
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">verdict</span>
|
||||
<select
|
||||
className="management-field"
|
||||
value={pluginModerationVerdict}
|
||||
onChange={(event) =>
|
||||
setPluginModerationVerdict(event.target.value as PackageScanStatus)
|
||||
}
|
||||
>
|
||||
<option value="clean">clean</option>
|
||||
<option value="suspicious">suspicious</option>
|
||||
<option value="malicious">malicious</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="not-run">not-run</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">note</span>
|
||||
<input
|
||||
className="management-field"
|
||||
value={pluginModerationNote}
|
||||
onChange={(event) => setPluginModerationNote(event.target.value)}
|
||||
placeholder="Audit note"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="management-actions management-actions-start">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!pluginModerationNote.trim()}
|
||||
onClick={() =>
|
||||
void setPackageModerationVerdict({
|
||||
packageId: plugin._id,
|
||||
verdict: pluginModerationVerdict,
|
||||
note: pluginModerationNote.trim(),
|
||||
})
|
||||
.then(() => setPluginModerationNote(""))
|
||||
.catch((error) => window.alert(formatMutationError(error)))
|
||||
}
|
||||
>
|
||||
Save verdict
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div className="management-actions management-action-grid">
|
||||
@@ -794,6 +1133,17 @@ function Management() {
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
{latestRelease?.version ? (
|
||||
<Button asChild className="management-action-btn">
|
||||
<Link
|
||||
to="/management/clawpacks/releases/$releaseId"
|
||||
params={{ releaseId: latestRelease._id }}
|
||||
search={{ skill: undefined, plugin: undefined }}
|
||||
>
|
||||
Inspect Claw Pack
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
@@ -806,6 +1156,27 @@ function Management() {
|
||||
>
|
||||
{isHighlighted ? "Unhighlight" : "Highlight"}
|
||||
</Button>
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
disabled={
|
||||
!latestRelease?.clawpackStorageId ||
|
||||
Boolean(latestRelease.clawpackRevokedAt)
|
||||
}
|
||||
onClick={() => {
|
||||
if (!latestRelease?._id) return;
|
||||
const reason = promptClawPackRevocationReason(
|
||||
`${plugin.name}@${latestRelease.version}`,
|
||||
);
|
||||
if (!reason) return;
|
||||
void revokeClawPackArtifact({
|
||||
releaseId: latestRelease._id,
|
||||
reason,
|
||||
}).catch((error) => window.alert(formatMutationError(error)));
|
||||
}}
|
||||
>
|
||||
Revoke Claw Pack
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1047,6 +1418,26 @@ function Management() {
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytesCompact(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0B";
|
||||
if (value < 1024) return `${value}B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function formatEnvironmentSummary(environment: Doc<"packageReleases">["environmentSummary"]) {
|
||||
if (!environment) return "No environment summary yet";
|
||||
const labels = [
|
||||
environment.requiresLocalDesktop ? "desktop" : null,
|
||||
environment.requiresBrowser ? "browser" : null,
|
||||
environment.requiresAudioDevice ? "audio" : null,
|
||||
environment.requiresNetwork ? "network" : null,
|
||||
...(environment.requiresExternalServices ?? []).map((service) => `service:${service}`),
|
||||
...(environment.requiresOsPermissions ?? []).map((permission) => `permission:${permission}`),
|
||||
].filter(Boolean);
|
||||
return labels.length > 0 ? labels.join(", ") : "No special environment requirements";
|
||||
}
|
||||
|
||||
function formatTimestamp(value: number) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import type { Id } from "../../../convex/_generated/dataModel";
|
||||
import { ManagementAccessNotice } from "../../components/ManagementAccessNotice";
|
||||
import { PluginOperationsNav } from "../../components/PluginOperationsNav";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { deriveClawPackLifecycle } from "../../lib/packageLifecycle";
|
||||
import { isAdmin, isModerator } from "../../lib/roles";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
|
||||
const packageApiRefs = api as unknown as {
|
||||
packages: {
|
||||
getClawPackMigrationStatus: unknown;
|
||||
dryRunClawPackMigrationRunForStaff: unknown;
|
||||
listClawPackMigrationRunsForStaff: unknown;
|
||||
startClawPackMigrationRun: unknown;
|
||||
continueClawPackMigrationRun: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type ClawPackMigrationOperation = "artifact-backfill" | "failure-retry" | "search-index-backfill";
|
||||
|
||||
type ClawPackMigrationRunStatus = "pending" | "running" | "completed" | "failed";
|
||||
|
||||
type ClawPackMigrationStatus = {
|
||||
missingSample: Array<{
|
||||
releaseId: Id<"packageReleases">;
|
||||
packageId: Id<"packages">;
|
||||
name: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
createdAt: number;
|
||||
fileCount: number;
|
||||
clawpackAvailable?: boolean;
|
||||
clawpackBuiltAt?: number | null;
|
||||
clawpackSha256?: string | null;
|
||||
clawpackRevokedAt?: number | null;
|
||||
}>;
|
||||
failureSample: Array<{
|
||||
failureId: Id<"packageClawPackBackfillFailures">;
|
||||
releaseId: Id<"packageReleases">;
|
||||
packageId: Id<"packages">;
|
||||
name: string;
|
||||
version: string;
|
||||
error: string;
|
||||
attemptCount: number;
|
||||
firstFailedAt: number;
|
||||
lastAttemptAt: number;
|
||||
lastFailedAt: number;
|
||||
}>;
|
||||
missingSampleSize: number;
|
||||
failureSampleSize: number;
|
||||
generatedClawPackSampleSize: number;
|
||||
generatedClawPackBytes: number;
|
||||
sampleLimit: number;
|
||||
};
|
||||
|
||||
type ClawPackBackfillResult = {
|
||||
processed?: number;
|
||||
succeeded?: number;
|
||||
failed?: number;
|
||||
skipped?: number;
|
||||
isDone?: boolean;
|
||||
continueCursor?: string | null;
|
||||
results?: Array<{
|
||||
ok?: boolean;
|
||||
name?: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
sha256?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type ClawPackMigrationDryRunCandidate = {
|
||||
failureId?: Id<"packageClawPackBackfillFailures">;
|
||||
releaseId?: Id<"packageReleases">;
|
||||
packageId?: Id<"packages">;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
attemptCount?: number;
|
||||
lastFailedAt?: number;
|
||||
clawpackSha256?: string | null;
|
||||
clawpackBuiltAt?: number | null;
|
||||
};
|
||||
|
||||
type ClawPackMigrationDryRun = {
|
||||
operation: ClawPackMigrationOperation;
|
||||
limit: number;
|
||||
cursor: string | null;
|
||||
continueCursor: string | null;
|
||||
isDone: boolean;
|
||||
candidates: ClawPackMigrationDryRunCandidate[];
|
||||
candidateCount: number;
|
||||
failureCount: number;
|
||||
};
|
||||
|
||||
type ClawPackMigrationRun = {
|
||||
_id: Id<"clawPackMigrationRuns">;
|
||||
actorUserId: Id<"users">;
|
||||
operation: ClawPackMigrationOperation;
|
||||
status: ClawPackMigrationRunStatus;
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
continueCursor?: string;
|
||||
isDone?: boolean;
|
||||
processed: number;
|
||||
generated: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
bytesGenerated: number;
|
||||
failureCounts: Record<string, number>;
|
||||
lastError?: string;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
actor?: {
|
||||
userId: Id<"users">;
|
||||
handle?: string | null;
|
||||
name?: string | null;
|
||||
role?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type ClawPackMigrationRunList = {
|
||||
items: ClawPackMigrationRun[];
|
||||
limit: number;
|
||||
status: ClawPackMigrationRunStatus | null;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
type ClawPackMigrationRunResult = {
|
||||
run: ClawPackMigrationRun | null;
|
||||
result: ClawPackBackfillResult | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/management/clawpacks")({
|
||||
component: ClawPackManagementRoute,
|
||||
});
|
||||
|
||||
export function ClawPackManagementRoute() {
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
if (pathname !== "/management/clawpacks") return <Outlet />;
|
||||
return <ClawPackManagementConsole />;
|
||||
}
|
||||
|
||||
function ClawPackManagementConsole() {
|
||||
const { me } = useAuthStatus();
|
||||
const staff = isModerator(me);
|
||||
const admin = isAdmin(me);
|
||||
const migration = useQuery(
|
||||
packageApiRefs.packages.getClawPackMigrationStatus as never,
|
||||
staff ? {} : "skip",
|
||||
) as ClawPackMigrationStatus | undefined;
|
||||
const migrationRuns = useQuery(
|
||||
packageApiRefs.packages.listClawPackMigrationRunsForStaff as never,
|
||||
staff ? ({ limit: 12 } as never) : "skip",
|
||||
) as ClawPackMigrationRunList | undefined;
|
||||
|
||||
const [operation, setOperation] = useState<ClawPackMigrationOperation>("artifact-backfill");
|
||||
const [batchLimit, setBatchLimit] = useState(10);
|
||||
const [indexCursor, setIndexCursor] = useState("");
|
||||
const [dryRunArgs, setDryRunArgs] = useState<{
|
||||
operation: ClawPackMigrationOperation;
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
} | null>(null);
|
||||
const dryRun = useQuery(
|
||||
packageApiRefs.packages.dryRunClawPackMigrationRunForStaff as never,
|
||||
staff && dryRunArgs ? (dryRunArgs as never) : "skip",
|
||||
) as ClawPackMigrationDryRun | undefined;
|
||||
const startMigrationRun = useMutation(
|
||||
packageApiRefs.packages.startClawPackMigrationRun as never,
|
||||
) as unknown as (args: {
|
||||
operation: ClawPackMigrationOperation;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}) => Promise<ClawPackMigrationRun | null>;
|
||||
const continueMigrationRun = useAction(
|
||||
packageApiRefs.packages.continueClawPackMigrationRun as never,
|
||||
) as unknown as (args: {
|
||||
runId: Id<"clawPackMigrationRuns">;
|
||||
}) => Promise<ClawPackMigrationRunResult>;
|
||||
const [lastResult, setLastResult] = useState<{
|
||||
kind: ClawPackMigrationOperation;
|
||||
result: ClawPackBackfillResult | null;
|
||||
run: ClawPackMigrationRun | null;
|
||||
} | null>(null);
|
||||
const [activeRunId, setActiveRunId] = useState<Id<"clawPackMigrationRuns"> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!staff) {
|
||||
return <ManagementAccessNotice me={me} />;
|
||||
}
|
||||
|
||||
const limit = Math.max(1, Math.min(batchLimit, 100));
|
||||
const sampleTotal = migration
|
||||
? migration.missingSampleSize + migration.generatedClawPackSampleSize
|
||||
: 0;
|
||||
const coverage =
|
||||
migration && sampleTotal > 0
|
||||
? Math.round((migration.generatedClawPackSampleSize / sampleTotal) * 100)
|
||||
: null;
|
||||
|
||||
const runDryRun = () => {
|
||||
setError(null);
|
||||
setDryRunArgs({
|
||||
operation,
|
||||
limit,
|
||||
...(operation === "search-index-backfill" && indexCursor.trim()
|
||||
? { cursor: indexCursor.trim() }
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
const createMigrationRun = () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Create a ${formatOperation(operation)} migration run for up to ${limit} releases?\n\nThis writes an operator run record in Convex but does not execute the batch yet.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
void startMigrationRun({
|
||||
operation,
|
||||
limit,
|
||||
...(operation === "search-index-backfill" && indexCursor.trim()
|
||||
? { cursor: indexCursor.trim() }
|
||||
: {}),
|
||||
})
|
||||
.then((run) => {
|
||||
setLastResult({ kind: operation, result: null, run });
|
||||
})
|
||||
.catch((requestError) => setError(formatMutationError(requestError)));
|
||||
};
|
||||
|
||||
const runMigrationBatch = (run: ClawPackMigrationRun) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Run next ${formatOperation(run.operation)} batch?\n\nRun: ${run._id}\nLimit: ${run.limit}\nCursor: ${run.continueCursor ?? run.cursor ?? "start"}\n\nThis writes Claw Pack migration data in Convex.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setActiveRunId(run._id);
|
||||
void continueMigrationRun({ runId: run._id })
|
||||
.then((result) => {
|
||||
setLastResult({
|
||||
kind: result.run?.operation ?? run.operation,
|
||||
result: result.result,
|
||||
run: result.run,
|
||||
});
|
||||
if (result.run?.continueCursor) setIndexCursor(result.run.continueCursor);
|
||||
if (result.error) setError(result.error);
|
||||
})
|
||||
.catch((requestError) => setError(formatMutationError(requestError)))
|
||||
.finally(() => setActiveRunId(null));
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="section-title">Claw Pack operations</h1>
|
||||
<p className="section-subtitle">
|
||||
Migration status, dry-run sampling, cursor resume, and rebuild controls for plugin Claw
|
||||
Pack artifacts.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
|
||||
Back to management
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<PluginOperationsNav current="clawpacks" />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<MetricCard
|
||||
label="Sample coverage"
|
||||
value={coverage === null ? "unknown" : `${coverage}%`}
|
||||
detail={sampleTotal > 0 ? `${sampleTotal} sampled releases` : "No sample rows yet"}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Missing sample"
|
||||
value={migration ? String(migration.missingSampleSize) : "..."}
|
||||
detail="eligible releases without artifacts"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Open failures"
|
||||
value={migration ? String(migration.failureSampleSize) : "..."}
|
||||
detail="recent failed artifact builds"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Generated sample"
|
||||
value={migration ? String(migration.generatedClawPackSampleSize) : "..."}
|
||||
detail="sampled releases with artifacts"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Generated bytes"
|
||||
value={migration ? formatBytesCompact(migration.generatedClawPackBytes) : "..."}
|
||||
detail="stored Claw Pack sample size"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="mt-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Migration controls
|
||||
</h2>
|
||||
<p className="section-subtitle m-0">
|
||||
Dry-run reads from the current sample. Build and index actions require confirmation.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="compact">{admin ? "admin write access" : "read only"}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="management-tool-grid">
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">operation</span>
|
||||
<select
|
||||
className="management-field"
|
||||
value={operation}
|
||||
onChange={(event) => setOperation(event.target.value as ClawPackMigrationOperation)}
|
||||
>
|
||||
<option value="artifact-backfill">artifact backfill</option>
|
||||
<option value="failure-retry">failure retry</option>
|
||||
<option value="search-index-backfill">search index backfill</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">batch limit</span>
|
||||
<input
|
||||
className="management-field"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={batchLimit}
|
||||
onChange={(event) => setBatchLimit(Number.parseInt(event.target.value, 10) || 1)}
|
||||
/>
|
||||
</label>
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">index cursor</span>
|
||||
<input
|
||||
className="management-field"
|
||||
value={indexCursor}
|
||||
onChange={(event) => setIndexCursor(event.target.value)}
|
||||
placeholder="optional continue cursor"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="management-actions management-actions-start">
|
||||
<Button type="button" variant="outline" onClick={runDryRun}>
|
||||
Dry-run operation
|
||||
</Button>
|
||||
{admin ? (
|
||||
<Button type="button" onClick={createMigrationRun}>
|
||||
Create migration run
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <Badge variant="accent">{error}</Badge> : null}
|
||||
{lastResult ? (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">
|
||||
Last {formatOperation(lastResult.kind)}
|
||||
</span>
|
||||
{lastResult.result ? (
|
||||
<span>
|
||||
processed {lastResult.result.processed ?? "?"} - succeeded{" "}
|
||||
{lastResult.result.succeeded ?? "?"} - failed {lastResult.result.failed ?? "?"}
|
||||
{lastResult.result.continueCursor
|
||||
? ` - next cursor ${lastResult.result.continueCursor}`
|
||||
: ""}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
created {lastResult.run?._id ?? "migration run"} with status{" "}
|
||||
{lastResult.run?.status ?? "pending"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{dryRunArgs ? (
|
||||
<Card className="mt-5">
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Dry-run result
|
||||
</h2>
|
||||
{dryRun === undefined ? (
|
||||
<div className="stat mt-3">Loading dry-run candidates...</div>
|
||||
) : (
|
||||
<DryRunResult result={dryRun} />
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Migration runs
|
||||
</h2>
|
||||
{migrationRuns === undefined ? (
|
||||
<div className="stat mt-3">Loading migration run ledger...</div>
|
||||
) : migrationRuns.items.length === 0 ? (
|
||||
<div className="stat mt-3">No Claw Pack migration runs have been created yet.</div>
|
||||
) : (
|
||||
<MigrationRunRows
|
||||
rows={migrationRuns.items}
|
||||
admin={admin}
|
||||
activeRunId={activeRunId}
|
||||
onContinue={runMigrationBatch}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Missing artifacts
|
||||
</h2>
|
||||
{migration === undefined ? (
|
||||
<div className="stat mt-3">Loading Claw Pack migration status...</div>
|
||||
) : migration.missingSample.length === 0 ? (
|
||||
<div className="stat mt-3">No missing Claw Pack artifacts in the current sample.</div>
|
||||
) : (
|
||||
<MigrationRows rows={migration.missingSample} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Failed artifact builds
|
||||
</h2>
|
||||
{migration === undefined ? (
|
||||
<div className="stat mt-3">Loading failure ledger...</div>
|
||||
) : migration.failureSample.length === 0 ? (
|
||||
<div className="stat mt-3">No open Claw Pack backfill failures in the sample.</div>
|
||||
) : (
|
||||
<FailureRows rows={migration.failureSample} />
|
||||
)}
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard(props: { label: string; value: string; detail: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">{props.label}</span>
|
||||
<strong className="text-[color:var(--ink)]">{props.value}</strong>
|
||||
</div>
|
||||
<p className="section-subtitle m-0 mt-2">{props.detail}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationRows(props: { rows: ClawPackMigrationStatus["missingSample"] }) {
|
||||
return (
|
||||
<div className="management-list mt-3">
|
||||
{props.rows.map((entry) => (
|
||||
<MigrationRow key={entry.releaseId} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DryRunResult(props: { result: ClawPackMigrationDryRun }) {
|
||||
const result = props.result;
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">operation</span>
|
||||
<span>
|
||||
{formatOperation(result.operation)} - {result.candidateCount} candidates
|
||||
{result.continueCursor ? ` - next cursor ${result.continueCursor}` : ""}
|
||||
{result.isDone ? " - done" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{result.candidates.length === 0 ? (
|
||||
<div className="stat">No candidates found for this dry-run.</div>
|
||||
) : (
|
||||
<div className="management-list">
|
||||
{result.candidates.map((candidate, index) => (
|
||||
<DryRunCandidateRow
|
||||
key={candidate.failureId ?? candidate.releaseId ?? `${result.operation}-${index}`}
|
||||
candidate={candidate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DryRunCandidateRow(props: { candidate: ClawPackMigrationDryRunCandidate }) {
|
||||
const candidate = props.candidate;
|
||||
const title = candidate.displayName ?? candidate.name ?? "Claw Pack candidate";
|
||||
return (
|
||||
<div className="management-item">
|
||||
<div className="management-item-main">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{candidate.name ? (
|
||||
<Link to="/plugins/$name" params={{ name: candidate.name }}>
|
||||
{title}
|
||||
</Link>
|
||||
) : (
|
||||
<strong>{title}</strong>
|
||||
)}
|
||||
{candidate.error ? <Badge variant="destructive">failed</Badge> : null}
|
||||
</div>
|
||||
<div className="section-subtitle m-0">
|
||||
{candidate.name ?? candidate.packageId ?? "package"}@{candidate.version ?? "unknown"}
|
||||
{candidate.attemptCount ? ` - ${candidate.attemptCount} attempts` : ""}
|
||||
</div>
|
||||
{candidate.error ? (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">last error</span>
|
||||
<span>{candidate.error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{candidate.releaseId ? (
|
||||
<div className="management-actions">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link
|
||||
to="/management/clawpacks/releases/$releaseId"
|
||||
params={{ releaseId: candidate.releaseId }}
|
||||
search={{ skill: undefined, plugin: undefined }}
|
||||
>
|
||||
Details
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationRunRows(props: {
|
||||
rows: ClawPackMigrationRun[];
|
||||
admin: boolean;
|
||||
activeRunId: Id<"clawPackMigrationRuns"> | null;
|
||||
onContinue: (run: ClawPackMigrationRun) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="management-list mt-3">
|
||||
{props.rows.map((run) => (
|
||||
<MigrationRunRow
|
||||
key={run._id}
|
||||
run={run}
|
||||
admin={props.admin}
|
||||
active={props.activeRunId === run._id}
|
||||
onContinue={props.onContinue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationRunRow(props: {
|
||||
run: ClawPackMigrationRun;
|
||||
admin: boolean;
|
||||
active: boolean;
|
||||
onContinue: (run: ClawPackMigrationRun) => void;
|
||||
}) {
|
||||
const run = props.run;
|
||||
const canContinue = props.admin && run.status !== "completed" && run.status !== "running";
|
||||
return (
|
||||
<div className="management-item">
|
||||
<div className="management-item-main">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<strong>{formatOperation(run.operation)}</strong>
|
||||
<Badge variant={run.status === "failed" ? "destructive" : "compact"}>{run.status}</Badge>
|
||||
</div>
|
||||
<div className="section-subtitle m-0">
|
||||
limit {run.limit} - processed {run.processed} - generated {run.generated} - skipped{" "}
|
||||
{run.skipped} - failed {run.failed}
|
||||
</div>
|
||||
<div className="section-subtitle m-0">
|
||||
created {formatTimestamp(run.createdAt)}
|
||||
{run.actor?.handle ? ` by @${run.actor.handle}` : ""}
|
||||
{run.continueCursor ? ` - next cursor ${run.continueCursor}` : ""}
|
||||
</div>
|
||||
{run.lastError ? (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">last error</span>
|
||||
<span>{run.lastError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{canContinue ? (
|
||||
<div className="management-actions">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => props.onContinue(run)}
|
||||
disabled={props.active}
|
||||
>
|
||||
{props.active ? "Running..." : "Run next batch"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationRow(props: { entry: ClawPackMigrationStatus["missingSample"][number] }) {
|
||||
const entry = props.entry;
|
||||
const lifecycle = deriveClawPackLifecycle({
|
||||
available: entry.clawpackAvailable ?? false,
|
||||
revokedAt: entry.clawpackRevokedAt,
|
||||
});
|
||||
return (
|
||||
<div className="management-item">
|
||||
<div className="management-item-main">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link to="/plugins/$name" params={{ name: entry.name }}>
|
||||
{entry.displayName}
|
||||
</Link>
|
||||
<Badge variant={lifecycle.severity === "danger" ? "destructive" : "accent"}>
|
||||
{lifecycle.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="section-subtitle m-0">
|
||||
{entry.name}@{entry.version} - {entry.fileCount} files - published{" "}
|
||||
{formatTimestamp(entry.createdAt)}
|
||||
</div>
|
||||
<div className="section-subtitle m-0">{lifecycle.action ?? lifecycle.description}</div>
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link
|
||||
to="/management/clawpacks/releases/$releaseId"
|
||||
params={{ releaseId: entry.releaseId }}
|
||||
search={{ skill: undefined, plugin: undefined }}
|
||||
>
|
||||
Details
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to="/management" search={{ skill: undefined, plugin: entry.name }}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailureRows(props: { rows: ClawPackMigrationStatus["failureSample"] }) {
|
||||
return (
|
||||
<div className="management-list mt-3">
|
||||
{props.rows.map((entry) => (
|
||||
<FailureRow key={entry.failureId} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailureRow(props: { entry: ClawPackMigrationStatus["failureSample"][number] }) {
|
||||
const entry = props.entry;
|
||||
const lifecycle = deriveClawPackLifecycle({
|
||||
available: false,
|
||||
buildError: entry.error,
|
||||
});
|
||||
return (
|
||||
<div className="management-item">
|
||||
<div className="management-item-main">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link to="/plugins/$name" params={{ name: entry.name }}>
|
||||
{entry.name}@{entry.version}
|
||||
</Link>
|
||||
<Badge variant="destructive">{lifecycle.label}</Badge>
|
||||
</div>
|
||||
<div className="section-subtitle m-0">
|
||||
{entry.attemptCount} attempts - last failed {formatTimestamp(entry.lastFailedAt)}
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">next action</span>
|
||||
<span>{lifecycle.action}</span>
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">last error</span>
|
||||
<span>{entry.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link
|
||||
to="/management/clawpacks/releases/$releaseId"
|
||||
params={{ releaseId: entry.releaseId }}
|
||||
search={{ skill: undefined, plugin: undefined }}
|
||||
>
|
||||
Details
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to="/management" search={{ skill: undefined, plugin: entry.name }}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytesCompact(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0B";
|
||||
if (value < 1024) return `${value}B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: number) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatOperation(value: ClawPackMigrationOperation) {
|
||||
return value.replaceAll("-", " ");
|
||||
}
|
||||
|
||||
function formatMutationError(error: unknown) {
|
||||
if (error instanceof Error && error.message.trim()) return error.message.trim();
|
||||
return "Request failed.";
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../../../../convex/_generated/api";
|
||||
import type { Id } from "../../../../../convex/_generated/dataModel";
|
||||
import { ManagementAccessNotice } from "../../../../components/ManagementAccessNotice";
|
||||
import { PluginOperationsNav } from "../../../../components/PluginOperationsNav";
|
||||
import { Badge } from "../../../../components/ui/badge";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { Card } from "../../../../components/ui/card";
|
||||
import { deriveClawPackLifecycle } from "../../../../lib/packageLifecycle";
|
||||
import { isModerator } from "../../../../lib/roles";
|
||||
import { useAuthStatus } from "../../../../lib/useAuthStatus";
|
||||
|
||||
const packageApiRefs = api as unknown as {
|
||||
packages: {
|
||||
getClawPackReleaseForStaff: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type ReleaseSourceSummary = {
|
||||
kind: string | null;
|
||||
repo: string | null;
|
||||
url: string | null;
|
||||
ref: string | null;
|
||||
commit: string | null;
|
||||
path: string | null;
|
||||
} | null;
|
||||
|
||||
type ClawPackReleaseDetail = {
|
||||
package: {
|
||||
packageId: Id<"packages">;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: string;
|
||||
channel: string;
|
||||
isOfficial: boolean;
|
||||
scanStatus: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
release: {
|
||||
releaseId: Id<"packageReleases">;
|
||||
version: string;
|
||||
createdAt: number;
|
||||
fileCount: number;
|
||||
fileSample: Array<{ path: string; size: number; sha256: string }>;
|
||||
clawpackStorageId: Id<"_storage"> | null;
|
||||
clawpackSha256: string | null;
|
||||
clawpackSize: number | null;
|
||||
clawpackSpecVersion: number | null;
|
||||
clawpackFormat: string | null;
|
||||
clawpackFileCount: number | null;
|
||||
clawpackManifestSha256: string | null;
|
||||
clawpackBuiltAt: number | null;
|
||||
clawpackBuildVersion: string | null;
|
||||
clawpackRevokedAt: number | null;
|
||||
clawpackRevocationReason: string | null;
|
||||
hostTargetsSummary: Array<{ os?: string; arch?: string; libc?: string }>;
|
||||
environmentSummary: {
|
||||
requiresLocalDesktop?: boolean;
|
||||
requiresBrowser?: boolean;
|
||||
requiresAudioDevice?: boolean;
|
||||
requiresNetwork?: boolean;
|
||||
requiresExternalServices?: string[];
|
||||
requiresOsPermissions?: string[];
|
||||
} | null;
|
||||
source: ReleaseSourceSummary;
|
||||
verificationScanStatus: string | null;
|
||||
vtStatus: string | null;
|
||||
vtVerdict: string | null;
|
||||
llmStatus: string | null;
|
||||
llmVerdict: string | null;
|
||||
staticScanStatus: string | null;
|
||||
staticScanSummary: string | null;
|
||||
staticScanReasonCodes: string[];
|
||||
};
|
||||
artifacts: Array<{
|
||||
artifactId: Id<"packageReleaseArtifacts">;
|
||||
kind: string;
|
||||
targetKey: string | null;
|
||||
storageId: Id<"_storage">;
|
||||
sha256: string;
|
||||
size: number;
|
||||
format: string;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
revokedAt: number | null;
|
||||
revocationReason: string | null;
|
||||
}>;
|
||||
failures: Array<{
|
||||
failureId: Id<"packageClawPackBackfillFailures">;
|
||||
error: string;
|
||||
attemptCount: number;
|
||||
firstFailedAt: number;
|
||||
lastAttemptAt: number;
|
||||
lastFailedAt: number;
|
||||
resolvedAt: number | null;
|
||||
}>;
|
||||
searchIndexRows: Array<{
|
||||
rowId: Id<"packageClawPackSearchIndex">;
|
||||
kind: string;
|
||||
key: string;
|
||||
updatedAt: number;
|
||||
createdAt: number;
|
||||
}>;
|
||||
} | null;
|
||||
|
||||
export const Route = createFileRoute("/management/clawpacks/releases/$releaseId")({
|
||||
component: ClawPackReleaseDetailRoute,
|
||||
});
|
||||
|
||||
function ClawPackReleaseDetailRoute() {
|
||||
const { releaseId } = Route.useParams();
|
||||
return <ClawPackReleaseDetailPage releaseId={releaseId as Id<"packageReleases">} />;
|
||||
}
|
||||
|
||||
export function ClawPackReleaseDetailPage(props: { releaseId: Id<"packageReleases"> }) {
|
||||
const { me } = useAuthStatus();
|
||||
const staff = isModerator(me);
|
||||
const detail = useQuery(
|
||||
packageApiRefs.packages.getClawPackReleaseForStaff as never,
|
||||
staff ? ({ releaseId: props.releaseId } as never) : "skip",
|
||||
) as ClawPackReleaseDetail | undefined;
|
||||
|
||||
if (!staff) {
|
||||
return <ManagementAccessNotice me={me} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="section-title">Claw Pack release detail</h1>
|
||||
<p className="section-subtitle">
|
||||
Release-level artifact state, failure history, lookup rows, and provenance evidence.
|
||||
</p>
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management/clawpacks" search={{ skill: undefined, plugin: undefined }}>
|
||||
Claw Pack ops
|
||||
</Link>
|
||||
</Button>
|
||||
{detail?.package ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link
|
||||
to="/management/plugins/$name"
|
||||
params={{ name: detail.package.name }}
|
||||
search={{ skill: undefined, plugin: undefined }}
|
||||
>
|
||||
Plugin detail
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PluginOperationsNav current="clawpacks" />
|
||||
|
||||
{detail === undefined ? (
|
||||
<Card>Loading Claw Pack release...</Card>
|
||||
) : detail === null ? (
|
||||
<Card>No plugin release found for this Claw Pack record.</Card>
|
||||
) : (
|
||||
<ClawPackReleaseDetailBody detail={detail} />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ClawPackReleaseDetailBody(props: { detail: Exclude<ClawPackReleaseDetail, null> }) {
|
||||
const { detail } = props;
|
||||
const lifecycle = deriveClawPackLifecycle({
|
||||
available: Boolean(detail.release.clawpackStorageId),
|
||||
revokedAt: detail.release.clawpackRevokedAt ?? undefined,
|
||||
buildError: detail.failures[0]?.error,
|
||||
});
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<Card>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="m-0 font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
{detail.package.displayName}
|
||||
</h2>
|
||||
<Badge variant={lifecycleBadgeVariant(lifecycle.severity)}>{lifecycle.label}</Badge>
|
||||
{detail.package.isOfficial ? <Badge variant="compact">official</Badge> : null}
|
||||
<Badge variant="compact">{detail.package.family}</Badge>
|
||||
</div>
|
||||
<p className="section-subtitle m-0">
|
||||
<span className="mono">{detail.package.name}</span>@{detail.release.version}
|
||||
</p>
|
||||
</div>
|
||||
<div className="management-actions management-action-grid">
|
||||
<Button asChild className="management-action-btn" size="sm">
|
||||
<Link to="/plugins/$name" params={{ name: detail.package.name }}>
|
||||
Public page
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="management-action-btn" size="sm" variant="outline">
|
||||
<Link
|
||||
to="/management/plugins/$name"
|
||||
params={{ name: detail.package.name }}
|
||||
search={{ skill: undefined, plugin: undefined }}
|
||||
>
|
||||
Plugin staff page
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Claw Pack state
|
||||
</h3>
|
||||
<div className="management-sublist">
|
||||
<ReportField label="lifecycle" value={lifecycle.description} />
|
||||
<ReportField
|
||||
label="built"
|
||||
value={
|
||||
detail.release.clawpackBuiltAt
|
||||
? formatTimestamp(detail.release.clawpackBuiltAt)
|
||||
: "missing"
|
||||
}
|
||||
/>
|
||||
<ReportField label="format" value={detail.release.clawpackFormat ?? "missing"} />
|
||||
<ReportField
|
||||
label="zip digest"
|
||||
value={detail.release.clawpackSha256 ?? "missing"}
|
||||
mono={Boolean(detail.release.clawpackSha256)}
|
||||
/>
|
||||
<ReportField
|
||||
label="manifest digest"
|
||||
value={detail.release.clawpackManifestSha256 ?? "missing"}
|
||||
mono={Boolean(detail.release.clawpackManifestSha256)}
|
||||
/>
|
||||
<ReportField
|
||||
label="size"
|
||||
value={
|
||||
detail.release.clawpackSize
|
||||
? formatBytesCompact(detail.release.clawpackSize)
|
||||
: "missing"
|
||||
}
|
||||
/>
|
||||
<ReportField
|
||||
label="files"
|
||||
value={detail.release.clawpackFileCount?.toString() ?? "missing"}
|
||||
/>
|
||||
<ReportField
|
||||
label="revocation"
|
||||
value={
|
||||
detail.release.clawpackRevokedAt
|
||||
? `${formatTimestamp(detail.release.clawpackRevokedAt)} - ${
|
||||
detail.release.clawpackRevocationReason ?? "no reason"
|
||||
}`
|
||||
: "none"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Provenance and scans
|
||||
</h3>
|
||||
<div className="management-sublist">
|
||||
<ReportField label="source" value={formatSource(detail.release.source)} />
|
||||
<ReportField
|
||||
label="verification"
|
||||
value={detail.release.verificationScanStatus ?? "missing"}
|
||||
/>
|
||||
<ReportField label="static scan" value={detail.release.staticScanStatus ?? "missing"} />
|
||||
<ReportField label="VirusTotal" value={detail.release.vtStatus ?? "missing"} />
|
||||
<ReportField label="LLM review" value={detail.release.llmStatus ?? "missing"} />
|
||||
<ReportField
|
||||
label="static reasons"
|
||||
value={
|
||||
detail.release.staticScanReasonCodes.length
|
||||
? detail.release.staticScanReasonCodes.join(", ")
|
||||
: "none"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<EvidenceList
|
||||
title="Host targets"
|
||||
values={detail.release.hostTargetsSummary.map(formatHostTarget)}
|
||||
empty="No host target summary yet"
|
||||
/>
|
||||
<EvidenceList
|
||||
title="Environment"
|
||||
values={formatEnvironmentSummary(detail.release.environmentSummary)}
|
||||
empty="No environment summary yet"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Artifact rows
|
||||
</h3>
|
||||
<div className="management-list mt-3">
|
||||
{detail.artifacts.length ? (
|
||||
detail.artifacts.map((artifact) => (
|
||||
<div className="management-item" key={artifact.artifactId}>
|
||||
<div className="management-item-main">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={artifact.status === "active" ? "success" : "compact"}>
|
||||
{artifact.status}
|
||||
</Badge>
|
||||
<span className="mono">{artifact.kind}</span>
|
||||
{artifact.targetKey ? (
|
||||
<Badge variant="compact">{artifact.targetKey}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="section-subtitle m-0">
|
||||
{formatBytesCompact(artifact.size)} - {artifact.format} - created{" "}
|
||||
{formatTimestamp(artifact.createdAt)}
|
||||
</div>
|
||||
<div className="mono break-all">{artifact.sha256}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="stat">No artifact rows recorded for this release.</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Failure ledger
|
||||
</h3>
|
||||
<div className="management-list mt-3">
|
||||
{detail.failures.length ? (
|
||||
detail.failures.map((failure) => (
|
||||
<div className="management-item" key={failure.failureId}>
|
||||
<div className="management-item-main">
|
||||
<div className="section-subtitle m-0">
|
||||
{failure.attemptCount} attempts - last failed{" "}
|
||||
{formatTimestamp(failure.lastFailedAt)}
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">error</span>
|
||||
<span>{failure.error}</span>
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">resolved</span>
|
||||
<span>{failure.resolvedAt ? formatTimestamp(failure.resolvedAt) : "open"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="stat">No Claw Pack build failures recorded for this release.</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Lookup index
|
||||
</h3>
|
||||
<div className="management-list mt-3">
|
||||
{detail.searchIndexRows.length ? (
|
||||
detail.searchIndexRows.map((row) => (
|
||||
<div className="management-report-item" key={row.rowId}>
|
||||
<span className="management-report-meta">{row.kind}</span>
|
||||
<span className="mono">{row.key}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="stat">No lookup rows for this release.</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Release files
|
||||
</h3>
|
||||
<p className="section-subtitle m-0 mt-2">
|
||||
Showing {detail.release.fileSample.length} of {detail.release.fileCount} files.
|
||||
</p>
|
||||
<div className="management-list mt-3">
|
||||
{detail.release.fileSample.map((file) => (
|
||||
<div className="management-report-item" key={file.path}>
|
||||
<span className="mono break-all">{file.path}</span>
|
||||
<span>{formatBytesCompact(file.size)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportField(props: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">{props.label}</span>
|
||||
<span className={props.mono ? "mono break-all" : undefined}>{props.value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceList(props: { title: string; values: string[]; empty: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">{props.title}</h3>
|
||||
<div className="management-sublist">
|
||||
{props.values.length ? (
|
||||
props.values.map((value) => (
|
||||
<div className="management-report-item" key={value}>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="stat">{props.empty}</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function lifecycleBadgeVariant(severity: string) {
|
||||
if (severity === "success") return "success";
|
||||
if (severity === "danger") return "destructive";
|
||||
if (severity === "warning") return "warning";
|
||||
return "compact";
|
||||
}
|
||||
|
||||
function formatSource(source: ReleaseSourceSummary) {
|
||||
if (!source) return "missing";
|
||||
return (
|
||||
[source.repo ?? source.url, source.ref, source.path, source.commit?.slice(0, 12)]
|
||||
.filter(Boolean)
|
||||
.join(" / ") || "missing"
|
||||
);
|
||||
}
|
||||
|
||||
function formatHostTarget(target: { os?: string; arch?: string; libc?: string }) {
|
||||
return [target.os, target.arch, target.libc].filter(Boolean).join("-") || "unknown";
|
||||
}
|
||||
|
||||
function formatEnvironmentSummary(
|
||||
environment: Exclude<ClawPackReleaseDetail, null>["release"]["environmentSummary"],
|
||||
) {
|
||||
if (!environment) return [];
|
||||
return [
|
||||
environment.requiresLocalDesktop ? "desktop" : null,
|
||||
environment.requiresBrowser ? "browser" : null,
|
||||
environment.requiresAudioDevice ? "audio" : null,
|
||||
environment.requiresNetwork ? "network" : null,
|
||||
...(environment.requiresExternalServices ?? []).map((service) => `service:${service}`),
|
||||
...(environment.requiresOsPermissions ?? []).map((permission) => `permission:${permission}`),
|
||||
].filter((value): value is string => Boolean(value));
|
||||
}
|
||||
|
||||
function formatBytesCompact(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0B";
|
||||
if (value < 1024) return `${value}B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: number) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { ManagementAccessNotice } from "../../components/ManagementAccessNotice";
|
||||
import { PluginOperationsNav } from "../../components/PluginOperationsNav";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import {
|
||||
formatReadinessSource,
|
||||
formatReadinessClawPack,
|
||||
readinessBlockerLabel,
|
||||
type MigrationReadinessItem,
|
||||
type MigrationReadinessResult,
|
||||
readinessStateLabel,
|
||||
} from "../../lib/officialMigrationReadiness";
|
||||
import { isModerator } from "../../lib/roles";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
|
||||
const packageApiRefs = api as unknown as {
|
||||
packages: {
|
||||
listOfficialMigrationReadinessForStaff: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/management/migrations")({
|
||||
component: OfficialMigrationRoute,
|
||||
});
|
||||
|
||||
export function OfficialMigrationRoute() {
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
if (pathname !== "/management/migrations") return <Outlet />;
|
||||
return <OfficialMigrationConsole />;
|
||||
}
|
||||
|
||||
function OfficialMigrationConsole() {
|
||||
const { me } = useAuthStatus();
|
||||
const staff = isModerator(me);
|
||||
const readiness = useQuery(
|
||||
packageApiRefs.packages.listOfficialMigrationReadinessForStaff as never,
|
||||
staff ? {} : "skip",
|
||||
) as MigrationReadinessResult | undefined;
|
||||
|
||||
if (!staff) {
|
||||
return <ManagementAccessNotice me={me} />;
|
||||
}
|
||||
|
||||
const items = readiness?.items ?? [];
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="section-title">OpenClaw migration readiness</h1>
|
||||
<p className="section-subtitle">
|
||||
ClawHub-side package, artifact, metadata, scan, and source gates for future bundled
|
||||
plugin externalization.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
|
||||
Back to management
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management/clawpacks" search={{ skill: undefined, plugin: undefined }}>
|
||||
Claw Pack ops
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PluginOperationsNav current="migrations" />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<MetricCard
|
||||
label="Ready"
|
||||
value={readiness ? String(readiness.readyCount) : "..."}
|
||||
detail="all ClawHub gates green"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Blocked"
|
||||
value={readiness ? String(readiness.blockedCount) : "..."}
|
||||
detail="missing package, artifact, metadata, source, or scan"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Tracked"
|
||||
value={readiness ? String(readiness.items.length) : "..."}
|
||||
detail={readiness ? `generated ${formatTimestamp(readiness.generatedAt)}` : "loading"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4">
|
||||
{readiness === undefined ? (
|
||||
<Card>Loading migration readiness…</Card>
|
||||
) : items.length === 0 ? (
|
||||
<Card>No migration candidates are configured.</Card>
|
||||
) : (
|
||||
items.map((item) => <MigrationCard key={item.bundledPluginId} item={item} />)
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationCard(props: { item: MigrationReadinessItem }) {
|
||||
const { item } = props;
|
||||
const ready = item.readinessState === "ready-for-openclaw";
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
{item.displayName}
|
||||
</h2>
|
||||
<Badge variant={ready ? "compact" : "accent"}>
|
||||
{readinessStateLabel(item.readinessState)}
|
||||
</Badge>
|
||||
{item.package?.isOfficial ? <Badge variant="compact">official</Badge> : null}
|
||||
</div>
|
||||
<p className="section-subtitle m-0">
|
||||
<span className="mono">{item.bundledPluginId}</span> →{" "}
|
||||
<span className="mono">{item.desiredPackageName}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="management-actions management-actions-start">
|
||||
{item.package ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/plugins/$name" params={{ name: item.package.name }}>
|
||||
Plugin page
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{item.package ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/management" search={{ skill: undefined, plugin: item.package.name }}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link
|
||||
to="/management/migrations/$bundledPluginId"
|
||||
params={{ bundledPluginId: item.bundledPluginId }}
|
||||
search={{ skill: undefined, plugin: undefined }}
|
||||
>
|
||||
Details
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="management-tool-grid">
|
||||
<ReportField label="publisher" value={`@${item.publisherHandle}`} />
|
||||
<ReportField
|
||||
label="source"
|
||||
value={formatReadinessSource(item)}
|
||||
tone={item.gates.sourceLinked ? undefined : "warn"}
|
||||
/>
|
||||
<ReportField
|
||||
label="release"
|
||||
value={item.latestRelease ? `v${item.latestRelease.version}` : "missing"}
|
||||
tone={item.latestRelease ? undefined : "warn"}
|
||||
/>
|
||||
<ReportField
|
||||
label="Claw Pack"
|
||||
value={formatReadinessClawPack(item)}
|
||||
tone={item.gates.clawpackAvailable ? undefined : "warn"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Gate label="Package" ok={item.gates.packageExists} />
|
||||
<Gate label="Release" ok={item.gates.releaseExists} />
|
||||
<Gate label="Claw Pack" ok={item.gates.clawpackAvailable} />
|
||||
<Gate label="Host matrix" ok={item.gates.hostMatrixComplete} />
|
||||
<Gate label="Environment" ok={item.gates.environmentComplete} />
|
||||
<Gate label="Source" ok={item.gates.sourceLinked} />
|
||||
<Gate label="Scan" ok={item.gates.scanClear} />
|
||||
<Gate label="Runtime bundle" ok={item.gates.runtimeBundleStatus === "not-required"} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{item.latestRelease?.hostTargetKeys.length ? (
|
||||
item.latestRelease.hostTargetKeys.map((target) => (
|
||||
<Badge key={target} variant="compact">
|
||||
{target}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<Badge variant="compact">no host targets</Badge>
|
||||
)}
|
||||
{item.latestRelease?.environmentFlags.map((flag) => (
|
||||
<Badge key={flag} variant="compact">
|
||||
{flag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{item.blockers.length > 0 ? (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">blockers</span>
|
||||
<span>{item.blockers.map(readinessBlockerLabel).join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard(props: { label: string; value: string; detail: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">{props.label}</span>
|
||||
<strong className="text-[color:var(--ink)]">{props.value}</strong>
|
||||
</div>
|
||||
<p className="section-subtitle m-0 mt-2">{props.detail}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportField(props: { label: string; value: string; tone?: "warn" }) {
|
||||
return (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">{props.label}</span>
|
||||
<span className={props.tone === "warn" ? "text-[color:var(--danger)]" : undefined}>
|
||||
{props.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Gate(props: { label: string; ok: boolean }) {
|
||||
return (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">{props.label}</span>
|
||||
<span
|
||||
className={
|
||||
props.ok ? "text-emerald-700 dark:text-emerald-300" : "text-[color:var(--danger)]"
|
||||
}
|
||||
>
|
||||
{props.ok ? "ready" : "blocked"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimestamp(value: number) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import { api } from "../../../../convex/_generated/api";
|
||||
import { ManagementAccessNotice } from "../../../components/ManagementAccessNotice";
|
||||
import { PluginOperationsNav } from "../../../components/PluginOperationsNav";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { Card } from "../../../components/ui/card";
|
||||
import {
|
||||
formatReadinessSource,
|
||||
formatReadinessClawPack,
|
||||
readinessBlockerLabel,
|
||||
type MigrationReadinessItem,
|
||||
type MigrationReadinessResult,
|
||||
readinessStateLabel,
|
||||
} from "../../../lib/officialMigrationReadiness";
|
||||
import { isModerator } from "../../../lib/roles";
|
||||
import { useAuthStatus } from "../../../lib/useAuthStatus";
|
||||
|
||||
const packageApiRefs = api as unknown as {
|
||||
packages: {
|
||||
listOfficialMigrationReadinessForStaff: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/management/migrations/$bundledPluginId")({
|
||||
component: OfficialMigrationDetailRoute,
|
||||
});
|
||||
|
||||
export function OfficialMigrationDetailRoute() {
|
||||
const params = Route.useParams();
|
||||
return <OfficialMigrationDetailPage bundledPluginId={params.bundledPluginId} />;
|
||||
}
|
||||
|
||||
export function OfficialMigrationDetailPage(props: { bundledPluginId: string }) {
|
||||
const { me } = useAuthStatus();
|
||||
const staff = isModerator(me);
|
||||
const readiness = useQuery(
|
||||
packageApiRefs.packages.listOfficialMigrationReadinessForStaff as never,
|
||||
staff ? {} : "skip",
|
||||
) as MigrationReadinessResult | undefined;
|
||||
|
||||
if (!staff) {
|
||||
return <ManagementAccessNotice me={me} />;
|
||||
}
|
||||
|
||||
const item = readiness?.items.find(
|
||||
(candidate) => candidate.bundledPluginId === props.bundledPluginId,
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="section-title">Migration candidate</h1>
|
||||
<p className="section-subtitle">
|
||||
ClawHub readiness gates for one future OpenClaw bundled-plugin externalization target.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management/migrations" search={{ skill: undefined, plugin: undefined }}>
|
||||
Back to readiness
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management/clawpacks" search={{ skill: undefined, plugin: undefined }}>
|
||||
Claw Pack ops
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PluginOperationsNav current="migrations" />
|
||||
|
||||
{readiness === undefined ? (
|
||||
<Card>Loading migration candidate…</Card>
|
||||
) : item ? (
|
||||
<MigrationCandidateDetail item={item} />
|
||||
) : (
|
||||
<Card>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">not found</span>
|
||||
<span className="mono">{props.bundledPluginId}</span>
|
||||
</div>
|
||||
<p className="section-subtitle m-0 mt-2">
|
||||
No official migration candidate is configured for this bundled plugin id.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationCandidateDetail(props: { item: MigrationReadinessItem }) {
|
||||
const { item } = props;
|
||||
const ready = item.readinessState === "ready-for-openclaw";
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<Card>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="m-0 font-display text-2xl font-bold text-[color:var(--ink)]">
|
||||
{item.displayName}
|
||||
</h2>
|
||||
<Badge variant={ready ? "compact" : "accent"}>
|
||||
{readinessStateLabel(item.readinessState)}
|
||||
</Badge>
|
||||
{item.package?.isOfficial ? <Badge variant="compact">official</Badge> : null}
|
||||
</div>
|
||||
<p className="section-subtitle m-0">
|
||||
<span className="mono">{item.bundledPluginId}</span> to{" "}
|
||||
<span className="mono">{item.desiredPackageName}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="management-actions management-actions-start">
|
||||
{item.package ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/plugins/$name" params={{ name: item.package.name }}>
|
||||
Plugin page
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{item.package ? (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/management" search={{ skill: undefined, plugin: item.package.name }}>
|
||||
Manage package
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="management-tool-grid">
|
||||
<ReportField label="publisher" value={`@${item.publisherHandle}`} />
|
||||
<ReportField label="source" value={formatReadinessSource(item)} />
|
||||
<ReportField label="source path" value={item.sourcePath || "."} />
|
||||
<ReportField label="Claw Pack" value={formatReadinessClawPack(item)} />
|
||||
<ReportField
|
||||
label="latest release"
|
||||
value={item.latestRelease ? `v${item.latestRelease.version}` : "missing"}
|
||||
/>
|
||||
<ReportField
|
||||
label="scan"
|
||||
value={item.latestRelease?.scanStatus ?? item.package?.scanStatus ?? "missing"}
|
||||
/>
|
||||
<ReportField
|
||||
label="runtime bundle"
|
||||
value={item.gates.runtimeBundleStatus.replaceAll("-", " ")}
|
||||
/>
|
||||
<ReportField
|
||||
label="generated package"
|
||||
value={item.package ? item.package.displayName : "missing"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
Readiness gates
|
||||
</h3>
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Gate label="Package exists" ok={item.gates.packageExists} />
|
||||
<Gate label="Release exists" ok={item.gates.releaseExists} />
|
||||
<Gate label="Claw Pack active" ok={item.gates.clawpackAvailable} />
|
||||
<Gate label="Host matrix complete" ok={item.gates.hostMatrixComplete} />
|
||||
<Gate label="Environment complete" ok={item.gates.environmentComplete} />
|
||||
<Gate label="Source linked" ok={item.gates.sourceLinked} />
|
||||
<Gate label="Scan clear" ok={item.gates.scanClear} />
|
||||
<Gate
|
||||
label="Runtime bundle decided"
|
||||
ok={item.gates.runtimeBundleStatus === "not-required"}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="m-0 font-display text-lg font-bold text-[color:var(--ink)]">Evidence</h3>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<EvidenceList
|
||||
title="Required hosts"
|
||||
values={item.requiredHostTargets}
|
||||
empty="no required hosts configured"
|
||||
/>
|
||||
<EvidenceList
|
||||
title="Release hosts"
|
||||
values={item.latestRelease?.hostTargetKeys ?? []}
|
||||
empty="no release host targets"
|
||||
/>
|
||||
<EvidenceList
|
||||
title="Environment flags"
|
||||
values={item.latestRelease?.environmentFlags ?? []}
|
||||
empty="no environment flags"
|
||||
/>
|
||||
<EvidenceList
|
||||
title="Blockers"
|
||||
values={item.blockers.map(readinessBlockerLabel)}
|
||||
empty="no blockers"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportField(props: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">{props.label}</span>
|
||||
<span>{props.value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Gate(props: { label: string; ok: boolean }) {
|
||||
return (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">{props.label}</span>
|
||||
<span
|
||||
className={
|
||||
props.ok ? "text-emerald-700 dark:text-emerald-300" : "text-[color:var(--danger)]"
|
||||
}
|
||||
>
|
||||
{props.ok ? "ready" : "blocked"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceList(props: { title: string; values: string[]; empty: string }) {
|
||||
return (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">{props.title}</span>
|
||||
<span>{props.values.length ? props.values.join(", ") : props.empty}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import type { Id } from "../../../convex/_generated/dataModel";
|
||||
import { ManagementAccessNotice } from "../../components/ManagementAccessNotice";
|
||||
import { PluginOperationsNav } from "../../components/PluginOperationsNav";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { familyLabel } from "../../lib/packageLabels";
|
||||
import { isModerator } from "../../lib/roles";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
|
||||
const packageApiRefs = api as unknown as {
|
||||
packages: {
|
||||
listModerationQueueForStaff: unknown;
|
||||
setModerationVerdict: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type PackageScanStatus = "clean" | "suspicious" | "malicious" | "pending" | "not-run";
|
||||
type QueueStatus = PackageScanStatus | "needs-review";
|
||||
|
||||
type ModerationQueueItem = {
|
||||
packageId: Id<"packages">;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel: "official" | "community" | "private";
|
||||
isOfficial: boolean;
|
||||
ownerHandle?: string;
|
||||
ownerKind?: "user" | "org";
|
||||
summary?: string;
|
||||
latestVersion?: string;
|
||||
runtimeId?: string;
|
||||
executesCode?: boolean;
|
||||
verificationTier?: string;
|
||||
clawpackAvailable?: boolean;
|
||||
hostTargetKeys: string[];
|
||||
environmentFlags: string[];
|
||||
scanStatus: PackageScanStatus;
|
||||
updatedAt: number;
|
||||
latestRelease: {
|
||||
releaseId: Id<"packageReleases">;
|
||||
version: string;
|
||||
createdAt: number;
|
||||
clawpackAvailable: boolean;
|
||||
clawpackRevokedAt?: number;
|
||||
clawpackSha256?: string;
|
||||
clawpackFileCount?: number;
|
||||
clawpackSize?: number;
|
||||
clawpackManifestSha256?: string;
|
||||
source: {
|
||||
kind: string | null;
|
||||
repo: string | null;
|
||||
url: string | null;
|
||||
ref: string | null;
|
||||
commit: string | null;
|
||||
path: string | null;
|
||||
} | null;
|
||||
verificationScanStatus: string | null;
|
||||
vtStatus: string | null;
|
||||
vtVerdict: string | null;
|
||||
llmStatus: string | null;
|
||||
llmVerdict: string | null;
|
||||
llmSummary: string | null;
|
||||
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
|
||||
staticScanSummary: string | null;
|
||||
staticScanReasonCodes: string[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
type ModerationQueueResult = {
|
||||
items: ModerationQueueItem[];
|
||||
status: QueueStatus;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
counts?: Record<QueueStatus, { value: number; capped: boolean }>;
|
||||
};
|
||||
|
||||
const QUEUE_STATUSES: Array<{ value: QueueStatus; label: string }> = [
|
||||
{ value: "needs-review", label: "Needs review" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "suspicious", label: "Suspicious" },
|
||||
{ value: "malicious", label: "Malicious" },
|
||||
{ value: "not-run", label: "Not run" },
|
||||
{ value: "clean", label: "Clean" },
|
||||
];
|
||||
|
||||
const VERDICTS: Array<{ value: PackageScanStatus; label: string }> = [
|
||||
{ value: "clean", label: "Approve clean" },
|
||||
{ value: "suspicious", label: "Mark suspicious" },
|
||||
{ value: "malicious", label: "Mark malicious" },
|
||||
{ value: "pending", label: "Hold pending" },
|
||||
];
|
||||
|
||||
export const Route = createFileRoute("/management/moderation")({
|
||||
component: PluginModerationRoute,
|
||||
});
|
||||
|
||||
export function PluginModerationRoute() {
|
||||
const { me } = useAuthStatus();
|
||||
const staff = isModerator(me);
|
||||
const [status, setStatus] = useState<QueueStatus>("needs-review");
|
||||
const [limit, setLimit] = useState(30);
|
||||
const [activeWrite, setActiveWrite] = useState<string | null>(null);
|
||||
const queue = useQuery(
|
||||
packageApiRefs.packages.listModerationQueueForStaff as never,
|
||||
staff ? ({ status, limit } as never) : "skip",
|
||||
) as ModerationQueueResult | undefined;
|
||||
const setModerationVerdict = useMutation(
|
||||
packageApiRefs.packages.setModerationVerdict as never,
|
||||
) as unknown as (args: {
|
||||
packageId: Id<"packages">;
|
||||
verdict: PackageScanStatus;
|
||||
note?: string;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
if (!staff) {
|
||||
return <ManagementAccessNotice me={me} />;
|
||||
}
|
||||
|
||||
const normalizedLimit = Math.max(1, Math.min(limit, 100));
|
||||
|
||||
const runVerdict = async (item: ModerationQueueItem, verdict: PackageScanStatus) => {
|
||||
const note = window.prompt(`Audit note for ${item.name} -> ${verdict}`);
|
||||
if (note === null) return;
|
||||
const trimmed = note.trim();
|
||||
if (!trimmed) {
|
||||
window.alert("Audit note is required.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!window.confirm(
|
||||
`Set ${item.name} moderation verdict to ${verdict}?\n\nThis writes a package moderation verdict and audit log in Convex.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setActiveWrite(`${item.packageId}:${verdict}`);
|
||||
try {
|
||||
await setModerationVerdict({ packageId: item.packageId, verdict, note: trimmed });
|
||||
} catch (error) {
|
||||
window.alert(formatMutationError(error));
|
||||
} finally {
|
||||
setActiveWrite(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="section-title">Plugin moderation</h1>
|
||||
<p className="section-subtitle">
|
||||
Review code and bundle plugins by scan state, Claw Pack status, channel, and release
|
||||
risk.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
|
||||
Back to management
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<PluginOperationsNav current="moderation" />
|
||||
|
||||
<Card className="mb-5">
|
||||
<div className="management-tool-grid">
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">queue</span>
|
||||
<select
|
||||
className="management-field"
|
||||
value={status}
|
||||
onChange={(event) => setStatus(event.target.value as QueueStatus)}
|
||||
>
|
||||
{QUEUE_STATUSES.map((entry) => (
|
||||
<option key={entry.value} value={entry.value}>
|
||||
{entry.label}
|
||||
{queue?.counts?.[entry.value]
|
||||
? ` (${formatQueueCount(queue.counts[entry.value])})`
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">limit</span>
|
||||
<input
|
||||
className="management-field"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={limit}
|
||||
onChange={(event) => setLimit(Number.parseInt(event.target.value, 10) || 1)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{queue?.counts ? (
|
||||
<div className="mb-5 grid gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
{QUEUE_STATUSES.map((entry) => (
|
||||
<button
|
||||
key={entry.value}
|
||||
className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3 text-left transition hover:border-[color:var(--accent)]"
|
||||
type="button"
|
||||
onClick={() => setStatus(entry.value)}
|
||||
>
|
||||
<span className="management-report-meta">{entry.label}</span>
|
||||
<strong className="mt-1 block text-lg text-[color:var(--ink)]">
|
||||
{formatQueueCount(queue.counts?.[entry.value])}
|
||||
</strong>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4">
|
||||
{(queue?.items ?? []).map((item) => (
|
||||
<Card key={item.packageId}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
{item.displayName}
|
||||
</h2>
|
||||
<Badge variant={item.scanStatus === "malicious" ? "accent" : "compact"}>
|
||||
{item.scanStatus}
|
||||
</Badge>
|
||||
<Badge variant="compact">{familyLabel(item.family)}</Badge>
|
||||
<Badge variant="compact">{item.channel}</Badge>
|
||||
</div>
|
||||
<p className="section-subtitle m-0">
|
||||
<span className="mono">{item.name}</span>
|
||||
{item.runtimeId ? ` · runtime ${item.runtimeId}` : ""}
|
||||
{item.latestVersion ? ` · latest ${item.latestVersion}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="management-actions management-actions-start">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management" search={{ skill: undefined, plugin: item.name }}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/plugins/$name" params={{ name: item.name }}>
|
||||
Public page
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="management-tool-grid">
|
||||
<ReportField label="owner" value={formatOwner(item)} />
|
||||
<ReportField
|
||||
label="source"
|
||||
value={formatSourceState(item)}
|
||||
tone={item.latestRelease?.source?.repo ? undefined : "warn"}
|
||||
/>
|
||||
<ReportField
|
||||
label="Claw Pack"
|
||||
value={formatClawPackState(item)}
|
||||
tone={item.clawpackAvailable ? undefined : "warn"}
|
||||
/>
|
||||
<ReportField label="verification" value={item.verificationTier ?? "unverified"} />
|
||||
<ReportField
|
||||
label="updated"
|
||||
value={new Date(item.updatedAt).toLocaleString(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="management-tool-grid">
|
||||
<ReportField
|
||||
label="scan rollup"
|
||||
value={formatScanRollup(item)}
|
||||
tone={item.scanStatus === "malicious" ? "warn" : undefined}
|
||||
/>
|
||||
<ReportField
|
||||
label="static scan"
|
||||
value={formatStaticScan(item)}
|
||||
tone={item.latestRelease?.staticScanStatus === "malicious" ? "warn" : undefined}
|
||||
/>
|
||||
<ReportField label="LLM review" value={formatLlmReview(item)} />
|
||||
<ReportField label="digest" value={formatArtifactDigest(item)} />
|
||||
</div>
|
||||
|
||||
{item.summary ? (
|
||||
<p className="m-0 text-sm text-[color:var(--ink-soft)]">{item.summary}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.hostTargetKeys.length > 0 ? (
|
||||
item.hostTargetKeys.map((target) => (
|
||||
<Badge key={target} variant="compact">
|
||||
{target}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<Badge variant="compact">no host targets</Badge>
|
||||
)}
|
||||
{item.environmentFlags.map((flag) => (
|
||||
<Badge key={flag} variant="compact">
|
||||
{flag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="management-actions management-actions-start">
|
||||
{VERDICTS.map((verdict) => (
|
||||
<Button
|
||||
key={verdict.value}
|
||||
type="button"
|
||||
variant={verdict.value === "clean" ? "default" : "outline"}
|
||||
size="sm"
|
||||
disabled={activeWrite !== null}
|
||||
onClick={() => void runVerdict(item, verdict.value)}
|
||||
>
|
||||
{activeWrite === `${item.packageId}:${verdict.value}`
|
||||
? "Saving..."
|
||||
: verdict.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{queue && queue.items.length === 0 ? <Card>No plugins in the selected queue.</Card> : null}
|
||||
{queue?.hasMore ? (
|
||||
<Card className="mt-4">
|
||||
Showing the newest {normalizedLimit} rows. Narrow the queue or raise the limit for the
|
||||
next review batch.
|
||||
</Card>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportField(props: { label: string; value: string; tone?: "warn" }) {
|
||||
return (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">{props.label}</span>
|
||||
<span className={props.tone === "warn" ? "text-[color:var(--danger)]" : undefined}>
|
||||
{props.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatOwner(item: ModerationQueueItem) {
|
||||
const handle = item.ownerHandle?.trim();
|
||||
if (!handle) return "unknown owner";
|
||||
return `${handle}${item.ownerKind ? ` (${item.ownerKind})` : ""}`;
|
||||
}
|
||||
|
||||
function formatClawPackState(item: ModerationQueueItem) {
|
||||
if (item.latestRelease?.clawpackRevokedAt) {
|
||||
return `revoked ${new Date(item.latestRelease.clawpackRevokedAt).toLocaleDateString()}`;
|
||||
}
|
||||
if (item.latestRelease?.clawpackAvailable || item.clawpackAvailable) {
|
||||
const digest = item.latestRelease?.clawpackSha256?.slice(0, 12);
|
||||
const count = item.latestRelease?.clawpackFileCount;
|
||||
return (
|
||||
[count ? `${count} files` : null, digest ?? null].filter(Boolean).join(" / ") || "stored"
|
||||
);
|
||||
}
|
||||
return "missing artifact";
|
||||
}
|
||||
|
||||
function formatSourceState(item: ModerationQueueItem) {
|
||||
const source = item.latestRelease?.source;
|
||||
if (!source?.repo) return item.ownerHandle ? `owned by ${item.ownerHandle}` : "missing source";
|
||||
return [source.repo, source.ref ?? source.commit?.slice(0, 12), source.path]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
function formatScanRollup(item: ModerationQueueItem) {
|
||||
const releaseStatus = item.latestRelease?.verificationScanStatus;
|
||||
return [
|
||||
item.scanStatus,
|
||||
releaseStatus && releaseStatus !== item.scanStatus ? releaseStatus : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
function formatStaticScan(item: ModerationQueueItem) {
|
||||
const release = item.latestRelease;
|
||||
if (!release?.staticScanStatus) return "not run";
|
||||
const reasonCodes = release.staticScanReasonCodes.slice(0, 2).join(", ");
|
||||
return [release.staticScanStatus, reasonCodes || release.staticScanSummary]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
function formatLlmReview(item: ModerationQueueItem) {
|
||||
const release = item.latestRelease;
|
||||
if (!release?.llmStatus) return "not run";
|
||||
return [release.llmStatus, release.llmVerdict, release.llmSummary].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
function formatArtifactDigest(item: ModerationQueueItem) {
|
||||
const digest = item.latestRelease?.clawpackSha256?.slice(0, 12);
|
||||
const manifestDigest = item.latestRelease?.clawpackManifestSha256?.slice(0, 12);
|
||||
if (!digest && !manifestDigest) return "missing";
|
||||
return [digest ? `zip ${digest}` : null, manifestDigest ? `manifest ${manifestDigest}` : null]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
}
|
||||
|
||||
function formatMutationError(error: unknown) {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function formatQueueCount(count: { value: number; capped: boolean } | undefined) {
|
||||
if (!count) return "0";
|
||||
return `${count.value}${count.capped ? "+" : ""}`;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router";
|
||||
import { useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import type { Id } from "../../../convex/_generated/dataModel";
|
||||
import { ManagementAccessNotice } from "../../components/ManagementAccessNotice";
|
||||
import { PluginOperationsNav } from "../../components/PluginOperationsNav";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { familyLabel } from "../../lib/packageLabels";
|
||||
import { isModerator } from "../../lib/roles";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
|
||||
const packageApiRefs = api as unknown as {
|
||||
packages: {
|
||||
listModerationQueueForStaff: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type PackageScanStatus = "clean" | "suspicious" | "malicious" | "pending" | "not-run";
|
||||
type QueueStatus = PackageScanStatus | "needs-review";
|
||||
|
||||
type PluginQueueItem = {
|
||||
packageId: Id<"packages">;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel: "official" | "community" | "private";
|
||||
isOfficial: boolean;
|
||||
ownerHandle?: string;
|
||||
summary?: string;
|
||||
latestVersion?: string;
|
||||
runtimeId?: string;
|
||||
executesCode?: boolean;
|
||||
verificationTier?: string;
|
||||
clawpackAvailable?: boolean;
|
||||
hostTargetKeys: string[];
|
||||
environmentFlags: string[];
|
||||
scanStatus: PackageScanStatus;
|
||||
updatedAt: number;
|
||||
latestRelease: {
|
||||
releaseId: Id<"packageReleases">;
|
||||
version: string;
|
||||
createdAt: number;
|
||||
clawpackAvailable: boolean;
|
||||
clawpackRevokedAt?: number;
|
||||
clawpackSha256?: string;
|
||||
clawpackFileCount?: number;
|
||||
source: {
|
||||
repo: string | null;
|
||||
ref: string | null;
|
||||
path: string | null;
|
||||
} | null;
|
||||
verificationScanStatus: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PluginQueueResult = {
|
||||
items: PluginQueueItem[];
|
||||
status: QueueStatus;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
const QUEUE_STATUSES: Array<{ value: QueueStatus; label: string }> = [
|
||||
{ value: "needs-review", label: "Needs review" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "suspicious", label: "Suspicious" },
|
||||
{ value: "malicious", label: "Malicious" },
|
||||
{ value: "not-run", label: "Not run" },
|
||||
{ value: "clean", label: "Clean" },
|
||||
];
|
||||
|
||||
export const Route = createFileRoute("/management/plugins")({
|
||||
component: PluginManagementRoute,
|
||||
});
|
||||
|
||||
export function PluginManagementRoute() {
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
||||
if (pathname !== "/management/plugins") return <Outlet />;
|
||||
return <PluginManagementConsole />;
|
||||
}
|
||||
|
||||
function PluginManagementConsole() {
|
||||
const { me } = useAuthStatus();
|
||||
const staff = isModerator(me);
|
||||
const [status, setStatus] = useState<QueueStatus>("needs-review");
|
||||
const [limit, setLimit] = useState(30);
|
||||
const queue = useQuery(
|
||||
packageApiRefs.packages.listModerationQueueForStaff as never,
|
||||
staff ? ({ status, limit } as never) : "skip",
|
||||
) as PluginQueueResult | undefined;
|
||||
|
||||
if (!staff) {
|
||||
return <ManagementAccessNotice me={me} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="section-title">Plugin management</h1>
|
||||
<p className="section-subtitle">
|
||||
Staff package index for moderation queues, Claw Pack status, release provenance, and
|
||||
direct package operations.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
|
||||
Back to management
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<PluginOperationsNav current="plugins" />
|
||||
|
||||
<Card>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="management-tool-grid">
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">queue</span>
|
||||
<select
|
||||
className="management-field"
|
||||
value={status}
|
||||
onChange={(event) => setStatus(event.target.value as QueueStatus)}
|
||||
>
|
||||
{QUEUE_STATUSES.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">limit</span>
|
||||
<input
|
||||
className="management-field"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={limit}
|
||||
onChange={(event) => setLimit(Number.parseInt(event.target.value, 10) || 1)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<Badge variant="compact">
|
||||
{queue ? `${queue.items.length}${queue.hasMore ? "+" : ""} packages` : "loading"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mt-5 grid gap-4">
|
||||
{queue === undefined ? (
|
||||
<Card>Loading plugin queue...</Card>
|
||||
) : queue.items.length === 0 ? (
|
||||
<Card>No plugins found for this queue.</Card>
|
||||
) : (
|
||||
queue.items.map((item) => <PluginQueueCard key={item.packageId} item={item} />)
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginQueueCard({ item }: { item: PluginQueueItem }) {
|
||||
const release = item.latestRelease;
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
className="font-display text-lg font-bold text-[color:var(--ink)]"
|
||||
to="/management/plugins/$name"
|
||||
params={{ name: item.name }}
|
||||
search={{ skill: undefined, plugin: undefined }}
|
||||
>
|
||||
{item.displayName}
|
||||
</Link>
|
||||
<Badge variant={scanBadgeVariant(item.scanStatus)}>{item.scanStatus}</Badge>
|
||||
{item.isOfficial ? <Badge variant="compact">official</Badge> : null}
|
||||
{item.executesCode ? <Badge variant="compact">executes code</Badge> : null}
|
||||
</div>
|
||||
<p className="section-subtitle m-0">{item.summary ?? "No summary provided."}</p>
|
||||
<div className="management-sublist">
|
||||
<ReportField label="package" value={item.name} mono />
|
||||
<ReportField
|
||||
label="owner"
|
||||
value={item.ownerHandle ? `@${item.ownerHandle}` : "unknown"}
|
||||
/>
|
||||
<ReportField label="family" value={`${familyLabel(item.family)} / ${item.channel}`} />
|
||||
<ReportField
|
||||
label="latest release"
|
||||
value={
|
||||
release ? `${release.version} / ${formatTimestamp(release.createdAt)}` : "none"
|
||||
}
|
||||
/>
|
||||
<ReportField label="Claw Pack" value={formatClawPack(release, item)} />
|
||||
<ReportField label="source" value={formatSource(release?.source ?? null)} />
|
||||
<ReportField
|
||||
label="targets"
|
||||
value={item.hostTargetKeys.length ? item.hostTargetKeys.join(", ") : "none"}
|
||||
/>
|
||||
<ReportField
|
||||
label="environment"
|
||||
value={item.environmentFlags.length ? item.environmentFlags.join(", ") : "none"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="management-actions management-action-grid">
|
||||
<Button asChild className="management-action-btn" size="sm">
|
||||
<Link
|
||||
to="/management/plugins/$name"
|
||||
params={{ name: item.name }}
|
||||
search={{ skill: undefined, plugin: undefined }}
|
||||
>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="management-action-btn" size="sm" variant="outline">
|
||||
<Link to="/plugins/$name" params={{ name: item.name }}>
|
||||
Public page
|
||||
</Link>
|
||||
</Button>
|
||||
{release?.version ? (
|
||||
<Button asChild className="management-action-btn" size="sm" variant="ghost">
|
||||
<Link
|
||||
to="/management/clawpacks/releases/$releaseId"
|
||||
params={{ releaseId: release.releaseId }}
|
||||
search={{ skill: undefined, plugin: undefined }}
|
||||
>
|
||||
Release detail
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportField({ label, mono, value }: { label: string; mono?: boolean; value: string }) {
|
||||
return (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">{label}</span>
|
||||
<span className={mono ? "mono" : undefined}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function scanBadgeVariant(status: PackageScanStatus) {
|
||||
if (status === "clean") return "success";
|
||||
if (status === "malicious") return "destructive";
|
||||
if (status === "suspicious") return "warning";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function formatClawPack(release: PluginQueueItem["latestRelease"], item: PluginQueueItem) {
|
||||
if (release?.clawpackRevokedAt) return `revoked ${formatTimestamp(release.clawpackRevokedAt)}`;
|
||||
if (release?.clawpackAvailable || item.clawpackAvailable) {
|
||||
return [
|
||||
release?.clawpackFileCount ? `${release.clawpackFileCount} files` : "available",
|
||||
release?.clawpackSha256 ? release.clawpackSha256.slice(0, 12) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
}
|
||||
return "missing";
|
||||
}
|
||||
|
||||
function formatSource(source: NonNullable<PluginQueueItem["latestRelease"]>["source"]) {
|
||||
if (!source) return "missing";
|
||||
return [source.repo, source.ref, source.path].filter(Boolean).join(" / ") || "missing";
|
||||
}
|
||||
|
||||
function formatTimestamp(value: number) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../../../convex/_generated/dataModel";
|
||||
import { ManagementAccessNotice } from "../../../components/ManagementAccessNotice";
|
||||
import { PluginOperationsNav } from "../../../components/PluginOperationsNav";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { Card } from "../../../components/ui/card";
|
||||
import { familyLabel } from "../../../lib/packageLabels";
|
||||
import type { PublicPublisher } from "../../../lib/publicUser";
|
||||
import { isModerator } from "../../../lib/roles";
|
||||
import { useAuthStatus } from "../../../lib/useAuthStatus";
|
||||
|
||||
const packageApiRefs = api as unknown as {
|
||||
packages: {
|
||||
getByNameForStaff: unknown;
|
||||
setModerationVerdict: unknown;
|
||||
revokeClawPackArtifact: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type PackageScanStatus = "clean" | "suspicious" | "malicious" | "pending" | "not-run";
|
||||
|
||||
type PluginByNameResult = {
|
||||
package: Doc<"packages">;
|
||||
latestRelease: Doc<"packageReleases"> | null;
|
||||
owner: PublicPublisher | null;
|
||||
highlighted: { byUserId: Id<"users">; at: number } | null;
|
||||
} | null;
|
||||
|
||||
export const Route = createFileRoute("/management/plugins/$name")({
|
||||
component: PluginManagementDetailRoute,
|
||||
});
|
||||
|
||||
function PluginManagementDetailRoute() {
|
||||
const { name } = Route.useParams();
|
||||
return <PluginManagementDetailPage name={name} />;
|
||||
}
|
||||
|
||||
export function PluginManagementDetailPage({ name }: { name: string }) {
|
||||
const { me } = useAuthStatus();
|
||||
const staff = isModerator(me);
|
||||
const detail = useQuery(
|
||||
packageApiRefs.packages.getByNameForStaff as never,
|
||||
staff ? ({ name } as never) : "skip",
|
||||
) as PluginByNameResult | undefined;
|
||||
const setPackageBatch = useMutation(api.packages.setBatch);
|
||||
const setModerationVerdict = useMutation(
|
||||
packageApiRefs.packages.setModerationVerdict as never,
|
||||
) as unknown as (args: {
|
||||
packageId: Id<"packages">;
|
||||
verdict: PackageScanStatus;
|
||||
note?: string;
|
||||
}) => Promise<unknown>;
|
||||
const revokeClawPackArtifact = useMutation(
|
||||
packageApiRefs.packages.revokeClawPackArtifact as never,
|
||||
) as unknown as (args: { releaseId: Id<"packageReleases">; reason?: string }) => Promise<unknown>;
|
||||
|
||||
const [verdict, setVerdict] = useState<PackageScanStatus>("clean");
|
||||
const [note, setNote] = useState("");
|
||||
const [activeWrite, setActiveWrite] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const plugin = detail?.package ?? null;
|
||||
const release = detail?.latestRelease ?? null;
|
||||
const owner = detail?.owner ?? null;
|
||||
const highlighted = Boolean(detail?.highlighted);
|
||||
|
||||
useEffect(() => {
|
||||
if (plugin?.scanStatus) setVerdict(plugin.scanStatus as PackageScanStatus);
|
||||
}, [plugin?.scanStatus]);
|
||||
|
||||
if (!staff) {
|
||||
return <ManagementAccessNotice me={me} />;
|
||||
}
|
||||
|
||||
const saveVerdict = () => {
|
||||
if (!plugin) return;
|
||||
const trimmed = note.trim();
|
||||
if (!trimmed) {
|
||||
setError("Audit note required.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!window.confirm(
|
||||
`Set ${plugin.name} moderation verdict to ${verdict}?\n\nThis writes a package moderation verdict and audit log in Convex.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setActiveWrite("verdict");
|
||||
void setModerationVerdict({ packageId: plugin._id, verdict, note: trimmed })
|
||||
.then(() => setNote(""))
|
||||
.catch((requestError) => setError(formatMutationError(requestError)))
|
||||
.finally(() => setActiveWrite(null));
|
||||
};
|
||||
|
||||
const toggleHighlight = () => {
|
||||
if (!plugin) return;
|
||||
const nextState = highlighted ? "remove highlighted badge from" : "mark highlighted for";
|
||||
if (
|
||||
!window.confirm(
|
||||
`This will ${nextState} ${plugin.name}.\n\nThis writes package badge state in Convex.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setActiveWrite("highlight");
|
||||
void setPackageBatch({
|
||||
packageId: plugin._id,
|
||||
batch: highlighted ? undefined : "highlighted",
|
||||
})
|
||||
.catch((requestError) => setError(formatMutationError(requestError)))
|
||||
.finally(() => setActiveWrite(null));
|
||||
};
|
||||
|
||||
const revokeClawPack = () => {
|
||||
if (!plugin || !release?._id) return;
|
||||
const reason = window.prompt(
|
||||
`Revoke Claw Pack for ${plugin.name}@${release.version}. Reason required.`,
|
||||
);
|
||||
const trimmed = reason?.trim();
|
||||
if (!trimmed) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`Revoke Claw Pack artifact for ${plugin.name}@${release.version}?\n\nThis writes revocation metadata and an audit log in Convex.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setActiveWrite("clawpack");
|
||||
void revokeClawPackArtifact({ releaseId: release._id, reason: trimmed })
|
||||
.catch((requestError) => setError(formatMutationError(requestError)))
|
||||
.finally(() => setActiveWrite(null));
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="section-title">Plugin package detail</h1>
|
||||
<p className="section-subtitle">
|
||||
Staff drilldown for release provenance, Claw Pack artifact state, moderation verdicts,
|
||||
and package promotion controls.
|
||||
</p>
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management/plugins" search={{ skill: undefined, plugin: undefined }}>
|
||||
Plugin index
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/management" search={{ skill: undefined, plugin: name }}>
|
||||
Legacy panel
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PluginOperationsNav current="plugins" />
|
||||
|
||||
{detail === undefined ? (
|
||||
<Card>Loading plugin package...</Card>
|
||||
) : !plugin ? (
|
||||
<Card>No plugin package found for "{name}".</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
{plugin.displayName}
|
||||
</h2>
|
||||
<Badge variant={scanBadgeVariant(plugin.scanStatus as PackageScanStatus)}>
|
||||
{plugin.scanStatus}
|
||||
</Badge>
|
||||
{plugin.isOfficial ? <Badge variant="compact">official</Badge> : null}
|
||||
{plugin.executesCode ? <Badge variant="compact">executes code</Badge> : null}
|
||||
{highlighted ? <Badge variant="success">highlighted</Badge> : null}
|
||||
</div>
|
||||
<p className="section-subtitle m-0">{plugin.summary ?? "No summary provided."}</p>
|
||||
<div className="management-sublist">
|
||||
<ReportField label="package" value={plugin.name} mono />
|
||||
<ReportField
|
||||
label="owner"
|
||||
value={owner?.handle ? `@${owner.handle}` : "unknown"}
|
||||
/>
|
||||
<ReportField
|
||||
label="family"
|
||||
value={`${familyLabel(plugin.family)} / ${plugin.channel}`}
|
||||
/>
|
||||
<ReportField label="runtime id" value={plugin.runtimeId ?? "none"} mono />
|
||||
<ReportField label="verification" value={plugin.verification?.tier ?? "none"} />
|
||||
<ReportField label="updated" value={formatTimestamp(plugin.updatedAt)} />
|
||||
<ReportField
|
||||
label="latest release"
|
||||
value={
|
||||
release
|
||||
? `${release.version} / ${formatTimestamp(release.createdAt)}`
|
||||
: "none"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="management-actions management-action-grid">
|
||||
<Button asChild className="management-action-btn" size="sm">
|
||||
<Link to="/plugins/$name" params={{ name: plugin.name }}>
|
||||
Public page
|
||||
</Link>
|
||||
</Button>
|
||||
{release?.version ? (
|
||||
<Button asChild className="management-action-btn" size="sm" variant="outline">
|
||||
<Link
|
||||
to="/management/clawpacks/releases/$releaseId"
|
||||
params={{ releaseId: release._id }}
|
||||
search={{ skill: undefined, plugin: undefined }}
|
||||
>
|
||||
Release detail
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
loading={activeWrite === "highlight"}
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={toggleHighlight}
|
||||
>
|
||||
{highlighted ? "Unhighlight" : "Highlight"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Claw Pack
|
||||
</h2>
|
||||
<div className="management-sublist">
|
||||
<ReportField label="state" value={formatClawPackState(release)} />
|
||||
<ReportField
|
||||
label="zip digest"
|
||||
value={release?.clawpackSha256 ?? "missing"}
|
||||
mono={Boolean(release?.clawpackSha256)}
|
||||
/>
|
||||
<ReportField
|
||||
label="manifest digest"
|
||||
value={release?.clawpackManifestSha256 ?? "missing"}
|
||||
mono={Boolean(release?.clawpackManifestSha256)}
|
||||
/>
|
||||
<ReportField
|
||||
label="files"
|
||||
value={release?.clawpackFileCount ? String(release.clawpackFileCount) : "none"}
|
||||
/>
|
||||
<ReportField
|
||||
label="size"
|
||||
value={release?.clawpackSize ? formatBytesCompact(release.clawpackSize) : "none"}
|
||||
/>
|
||||
<ReportField
|
||||
label="host targets"
|
||||
value={formatHostTargets(release?.hostTargetsSummary)}
|
||||
/>
|
||||
<ReportField
|
||||
label="environment"
|
||||
value={formatEnvironmentSummary(release?.environmentSummary)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="self-start"
|
||||
disabled={!release?.clawpackStorageId || Boolean(release.clawpackRevokedAt)}
|
||||
loading={activeWrite === "clawpack"}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={revokeClawPack}
|
||||
>
|
||||
Revoke Claw Pack
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Release provenance
|
||||
</h2>
|
||||
<div className="management-sublist">
|
||||
<ReportField label="source" value={formatReleaseSource(release?.source)} />
|
||||
<ReportField
|
||||
label="verification scan"
|
||||
value={release?.verification?.scanStatus ?? "missing"}
|
||||
/>
|
||||
<ReportField label="static scan" value={release?.staticScan?.status ?? "missing"} />
|
||||
<ReportField label="VirusTotal" value={release?.vtAnalysis?.status ?? "missing"} />
|
||||
<ReportField label="LLM review" value={release?.llmAnalysis?.status ?? "missing"} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Moderation verdict
|
||||
</h2>
|
||||
<div className="management-tool-grid">
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">verdict</span>
|
||||
<select
|
||||
className="management-field"
|
||||
value={verdict}
|
||||
onChange={(event) => setVerdict(event.target.value as PackageScanStatus)}
|
||||
>
|
||||
<option value="clean">clean</option>
|
||||
<option value="suspicious">suspicious</option>
|
||||
<option value="malicious">malicious</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="not-run">not-run</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="management-control management-control-stack">
|
||||
<span className="mono">audit note</span>
|
||||
<input
|
||||
className="management-field"
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
placeholder="Required"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="management-actions management-actions-start">
|
||||
<Button
|
||||
loading={activeWrite === "verdict"}
|
||||
type="button"
|
||||
disabled={!note.trim()}
|
||||
onClick={saveVerdict}
|
||||
>
|
||||
Save verdict
|
||||
</Button>
|
||||
{error ? <Badge variant="destructive">{error}</Badge> : null}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportField({ label, mono, value }: { label: string; mono?: boolean; value: string }) {
|
||||
return (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">{label}</span>
|
||||
<span className={mono ? "mono break-all" : undefined}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function scanBadgeVariant(status: PackageScanStatus) {
|
||||
if (status === "clean") return "success";
|
||||
if (status === "malicious") return "destructive";
|
||||
if (status === "suspicious") return "warning";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function formatClawPackState(release: Doc<"packageReleases"> | null | undefined) {
|
||||
if (!release) return "no release";
|
||||
if (release.clawpackRevokedAt) return `revoked ${formatTimestamp(release.clawpackRevokedAt)}`;
|
||||
if (release.clawpackStorageId)
|
||||
return `active ${formatTimestamp(release.clawpackBuiltAt ?? release.createdAt)}`;
|
||||
return "missing";
|
||||
}
|
||||
|
||||
function formatHostTargets(targets: Doc<"packageReleases">["hostTargetsSummary"]) {
|
||||
if (!targets?.length) return "No target summary yet";
|
||||
return targets
|
||||
.map((target) => [target.os, target.arch, target.libc].filter(Boolean).join("-"))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatEnvironmentSummary(environment: Doc<"packageReleases">["environmentSummary"]) {
|
||||
if (!environment) return "No environment summary yet";
|
||||
const labels = [
|
||||
environment.requiresLocalDesktop ? "desktop" : null,
|
||||
environment.requiresBrowser ? "browser" : null,
|
||||
environment.requiresAudioDevice ? "audio" : null,
|
||||
environment.requiresNetwork ? "network" : null,
|
||||
...(environment.requiresExternalServices ?? []).map((service) => `service:${service}`),
|
||||
...(environment.requiresOsPermissions ?? []).map((permission) => `permission:${permission}`),
|
||||
].filter(Boolean);
|
||||
return labels.length > 0 ? labels.join(", ") : "No special environment requirements";
|
||||
}
|
||||
|
||||
function formatReleaseSource(source: Doc<"packageReleases">["source"]) {
|
||||
if (!source || typeof source !== "object") return "missing";
|
||||
const typed = source as { repo?: string; ref?: string; path?: string; commit?: string };
|
||||
return (
|
||||
[typed.repo, typed.ref, typed.path, typed.commit?.slice(0, 12)].filter(Boolean).join(" / ") ||
|
||||
"missing"
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytesCompact(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0B";
|
||||
if (value < 1024) return `${value}B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: number) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatMutationError(error: unknown) {
|
||||
if (error instanceof Error && error.message.trim()) return error.message.trim();
|
||||
return "Request failed.";
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { useState } from "react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import type { Doc } from "../../../convex/_generated/dataModel";
|
||||
import { ManagementAccessNotice } from "../../components/ManagementAccessNotice";
|
||||
import { PluginOperationsNav } from "../../components/PluginOperationsNav";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Card } from "../../components/ui/card";
|
||||
import { isAdmin } from "../../lib/roles";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
|
||||
type UserRole = "admin" | "moderator" | "user";
|
||||
type UserListResult = {
|
||||
items: Doc<"users">[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/management/users")({
|
||||
component: UserManagementRoute,
|
||||
});
|
||||
|
||||
export function UserManagementRoute() {
|
||||
const { me } = useAuthStatus();
|
||||
const admin = isAdmin(me);
|
||||
const [search, setSearch] = useState("");
|
||||
const [limit, setLimit] = useState(100);
|
||||
const [activeWrite, setActiveWrite] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const users = useQuery(
|
||||
api.users.list,
|
||||
admin ? { limit, search: search.trim() || undefined } : "skip",
|
||||
) as UserListResult | undefined;
|
||||
const setRole = useMutation(api.users.setRole);
|
||||
const banUser = useMutation(api.users.banUser);
|
||||
const unbanUser = useMutation(api.users.unbanUser);
|
||||
|
||||
if (!admin) {
|
||||
return <ManagementAccessNotice me={me} />;
|
||||
}
|
||||
|
||||
const items = users?.items ?? [];
|
||||
const changeRole = (user: Doc<"users">, role: UserRole) => {
|
||||
const label = formatUserLabel(user);
|
||||
const currentRole = (user.role ?? "user") as UserRole;
|
||||
if (role === currentRole) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`Set ${label} role from ${currentRole} to ${role}?\n\nThis writes users.setRole in Convex.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setActiveWrite(`role:${user._id}`);
|
||||
void setRole({ userId: user._id, role })
|
||||
.catch((requestError) => setError(formatMutationError(requestError)))
|
||||
.finally(() => setActiveWrite(null));
|
||||
};
|
||||
const runBan = (user: Doc<"users">) => {
|
||||
if (user._id === me?._id) return;
|
||||
const label = formatUserLabel(user);
|
||||
if (
|
||||
!window.confirm(
|
||||
`Ban ${label} and delete their skills?\n\nThis writes users.banUser in Convex.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const reason = window.prompt(`Ban reason for ${label}. Required.`);
|
||||
const trimmed = reason?.trim();
|
||||
if (!trimmed) return;
|
||||
setError(null);
|
||||
setActiveWrite(`ban:${user._id}`);
|
||||
void banUser({ userId: user._id, reason: trimmed })
|
||||
.catch((requestError) => setError(formatMutationError(requestError)))
|
||||
.finally(() => setActiveWrite(null));
|
||||
};
|
||||
const runUnban = (user: Doc<"users">) => {
|
||||
const label = formatUserLabel(user);
|
||||
if (!window.confirm(`Unban ${label}?\n\nThis writes users.unbanUser in Convex.`)) return;
|
||||
const reason = window.prompt(`Unban reason for ${label}. Required.`);
|
||||
const trimmed = reason?.trim();
|
||||
if (!trimmed) return;
|
||||
setError(null);
|
||||
setActiveWrite(`unban:${user._id}`);
|
||||
void unbanUser({ userId: user._id, reason: trimmed })
|
||||
.catch((requestError) => setError(formatMutationError(requestError)))
|
||||
.finally(() => setActiveWrite(null));
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="section-title">User roles</h1>
|
||||
<p className="section-subtitle">
|
||||
Admin-only user search, moderator setup, role changes, and ban recovery.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
|
||||
Back to management
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<PluginOperationsNav current="users" />
|
||||
|
||||
<Card>
|
||||
<div className="management-controls">
|
||||
<label className="management-control management-search">
|
||||
<span className="mono">Search</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="handle, name, email, or user id"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="management-control">
|
||||
<span className="mono">Limit</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={limit}
|
||||
onChange={(event) => setLimit(Number.parseInt(event.target.value, 10) || 1)}
|
||||
/>
|
||||
</label>
|
||||
<div className="management-count">
|
||||
{users ? `${items.length} shown / ${users.total} matched` : "Loading users..."}
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="mt-3">
|
||||
<Badge variant="destructive">{error}</Badge>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Role assignments
|
||||
</h2>
|
||||
<div className="management-list mt-3">
|
||||
{users === undefined ? (
|
||||
<div className="stat">Loading users...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="stat">No matching users.</div>
|
||||
) : (
|
||||
items.map((user) => (
|
||||
<div className="management-item" key={user._id}>
|
||||
<div className="management-item-main">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="mono">{formatUserLabel(user)}</span>
|
||||
<Badge variant={roleBadgeVariant(user.role)}>{user.role ?? "user"}</Badge>
|
||||
{user.deletedAt ? <Badge variant="destructive">banned</Badge> : null}
|
||||
{user.deactivatedAt ? <Badge variant="compact">deactivated</Badge> : null}
|
||||
</div>
|
||||
<div className="section-subtitle m-0">
|
||||
{user.email ?? user.name ?? user._id} - joined{" "}
|
||||
{formatTimestamp(user.createdAt ?? user._creationTime)}
|
||||
</div>
|
||||
{user.banReason ? (
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">ban reason</span>
|
||||
<span>{user.banReason}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<select
|
||||
value={user.role ?? "user"}
|
||||
disabled={activeWrite === `role:${user._id}`}
|
||||
onChange={(event) => changeRole(user, event.target.value as UserRole)}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={user._id === me?._id || activeWrite === `ban:${user._id}`}
|
||||
onClick={() => runBan(user)}
|
||||
>
|
||||
Ban
|
||||
</Button>
|
||||
{user.deletedAt && !user.deactivatedAt ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={activeWrite === `unban:${user._id}`}
|
||||
onClick={() => runUnban(user)}
|
||||
>
|
||||
Unban
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function roleBadgeVariant(role: Doc<"users">["role"]) {
|
||||
if (role === "admin") return "destructive";
|
||||
if (role === "moderator") return "warning";
|
||||
return "compact";
|
||||
}
|
||||
|
||||
function formatUserLabel(user: Pick<Doc<"users">, "_id" | "handle" | "name">) {
|
||||
return `@${user.handle ?? user.name ?? user._id}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: number) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatMutationError(error: unknown) {
|
||||
if (error instanceof Error && error.message.trim()) return error.message.trim();
|
||||
return "Request failed.";
|
||||
}
|
||||
+641
-29
@@ -6,6 +6,7 @@ import semver from "semver";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { MAX_PUBLISH_FILE_BYTES, MAX_PUBLISH_TOTAL_BYTES } from "../../convex/lib/publishLimits";
|
||||
import { InstallCopyButton } from "../components/InstallCopyButton";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { PackageSourceChooser } from "../components/PackageSourceChooser";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
@@ -13,6 +14,11 @@ import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { normalizeClawPackImport, type ClawPackImportSummary } from "../lib/clawpackImport";
|
||||
import {
|
||||
fetchGitHubPackageSource,
|
||||
type GitHubPackageSourceProgress,
|
||||
} from "../lib/githubPackageSource";
|
||||
import {
|
||||
buildPackageUploadEntries,
|
||||
filterIgnoredPackageFiles,
|
||||
@@ -44,6 +50,314 @@ const apiRefs = api as unknown as {
|
||||
};
|
||||
};
|
||||
|
||||
type ClawPackPreviewFile = {
|
||||
path: string;
|
||||
size: number;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
type ClawPackPreview = {
|
||||
manifest: Record<string, unknown>;
|
||||
manifestJson: string;
|
||||
publishFiles: ClawPackPreviewFile[];
|
||||
finalFileCount: number;
|
||||
selectedBytes: number;
|
||||
hostTargets: string[];
|
||||
environment: string[];
|
||||
blockers: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type ClawPackIntakeGate = {
|
||||
label: string;
|
||||
status: "ready" | "review" | "blocked" | "pending";
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type PublishSuccess = {
|
||||
name: string;
|
||||
version: string;
|
||||
releaseId?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CLAWPACK_TARGETS = ["darwin-arm64", "linux-x64-glibc", "win32-x64"];
|
||||
|
||||
function splitHostTargets(value: string) {
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function inferPreviewEnvironment(paths: string[]) {
|
||||
const lowerPaths = paths.map((path) => path.toLowerCase());
|
||||
const environment = [
|
||||
"network",
|
||||
lowerPaths.some((path) => path.includes("playwright") || path.includes("browser"))
|
||||
? "browser"
|
||||
: null,
|
||||
lowerPaths.some((path) => path.includes("desktop") || path.includes("imessage"))
|
||||
? "local desktop"
|
||||
: null,
|
||||
lowerPaths.some((path) => path.includes("audio") || path.includes("microphone"))
|
||||
? "audio device"
|
||||
: null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
return [...new Set(environment)];
|
||||
}
|
||||
|
||||
function hostTargetPreviewObjects(targets: string[], compatibility: PackageCompatibility | null) {
|
||||
return targets.map((target) => {
|
||||
const parts = target.toLowerCase().split(/[-_/]/).filter(Boolean);
|
||||
const os = parts.find((part) => part === "darwin" || part === "linux" || part === "win32");
|
||||
const arch = parts.find((part) => part === "arm64" || part === "x64");
|
||||
const libc = parts.find((part) => part === "glibc" || part === "musl");
|
||||
return {
|
||||
target,
|
||||
...(os ? { os } : {}),
|
||||
...(arch ? { arch } : {}),
|
||||
...(libc ? { libc } : {}),
|
||||
supportState: os && arch ? "supported" : "setup-required",
|
||||
...(compatibility?.minGatewayVersion
|
||||
? { openclawRange: compatibility.minGatewayVersion }
|
||||
: {}),
|
||||
...(compatibility?.pluginApiRange ? { pluginApiRange: compatibility.pluginApiRange } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function formatPreviewBytes(value: number) {
|
||||
if (value < 1024) return `${value}B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function formatGitHubSourceProgress(progress: GitHubPackageSourceProgress) {
|
||||
if (progress.phase === "resolving") return "Resolving GitHub repo and commit...";
|
||||
if (progress.phase === "listing") return "Reading GitHub package file list...";
|
||||
if (progress.phase === "downloading") {
|
||||
const count =
|
||||
typeof progress.current === "number" && typeof progress.total === "number"
|
||||
? `${progress.current}/${progress.total}`
|
||||
: "";
|
||||
return `Downloading GitHub files${count ? ` ${count}` : ""}${
|
||||
progress.path ? `: ${progress.path}` : ""
|
||||
}`;
|
||||
}
|
||||
return "Fetching GitHub package...";
|
||||
}
|
||||
|
||||
function buildClawPackPreview(input: {
|
||||
files: File[];
|
||||
normalizedPaths: string[];
|
||||
family: "code-plugin" | "bundle-plugin";
|
||||
name: string;
|
||||
displayName: string;
|
||||
ownerHandle: string;
|
||||
version: string;
|
||||
changelog: string;
|
||||
sourceRepo: string;
|
||||
sourceCommit: string;
|
||||
sourceRef: string;
|
||||
sourcePath: string;
|
||||
bundleFormat: string;
|
||||
hostTargets: string;
|
||||
compatibility: PackageCompatibility | null;
|
||||
codePluginFieldIssues: string[];
|
||||
validationError: string | null;
|
||||
}): ClawPackPreview | null {
|
||||
if (input.files.length === 0) return null;
|
||||
|
||||
const normalized = normalizePackageUploadFiles(input.files);
|
||||
const publishFiles = normalized
|
||||
.filter((entry) => entry.path.toLowerCase() !== "clawpack.json")
|
||||
.map((entry) => ({
|
||||
path: entry.path,
|
||||
size: entry.file.size,
|
||||
...(entry.file.type ? { contentType: entry.file.type } : {}),
|
||||
}))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
const selectedBytes = publishFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
const suppliedClawPack = normalized.some((entry) => entry.path.toLowerCase() === "clawpack.json");
|
||||
const rawTargets = input.family === "bundle-plugin" ? splitHostTargets(input.hostTargets) : [];
|
||||
const hostTargets = rawTargets.length > 0 ? rawTargets : DEFAULT_CLAWPACK_TARGETS;
|
||||
const environment = inferPreviewEnvironment(input.normalizedPaths);
|
||||
const blockers = [
|
||||
input.validationError,
|
||||
input.name.trim() ? null : "Plugin name is required.",
|
||||
input.version.trim() ? null : "Version is required.",
|
||||
input.family === "code-plugin" && !input.sourceRepo.trim() ? "Source repo is required." : null,
|
||||
input.family === "code-plugin" && !input.sourceCommit.trim()
|
||||
? "Source commit is required."
|
||||
: null,
|
||||
...input.codePluginFieldIssues.map((field) => `Missing package metadata: ${field}.`),
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
const warnings = [
|
||||
input.changelog.trim() ? null : "Changelog is empty.",
|
||||
suppliedClawPack ? "Existing pack manifest will be replaced by ClawHub." : null,
|
||||
rawTargets.length === 0 && input.family === "bundle-plugin"
|
||||
? "No bundle host targets provided; ClawHub will fall back to the default host matrix."
|
||||
: null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
const source =
|
||||
input.sourceRepo.trim() && input.sourceCommit.trim()
|
||||
? {
|
||||
kind: "github",
|
||||
repo: input.sourceRepo.trim(),
|
||||
ref: input.sourceRef.trim() || input.sourceCommit.trim(),
|
||||
commit: input.sourceCommit.trim(),
|
||||
path: input.sourcePath.trim() || ".",
|
||||
}
|
||||
: null;
|
||||
const manifest: Record<string, unknown> = {
|
||||
specVersion: 1,
|
||||
kind: "openclaw.clawpack",
|
||||
package: {
|
||||
name: input.name.trim() || "unresolved",
|
||||
displayName: input.displayName.trim() || input.name.trim() || "unresolved",
|
||||
owner: input.ownerHandle.trim() || "resolved-on-publish",
|
||||
slug: input.name.trim() || "unresolved",
|
||||
version: input.version.trim() || "unresolved",
|
||||
family: input.family,
|
||||
channel: "community",
|
||||
},
|
||||
release: {
|
||||
packageId: "assigned-on-publish",
|
||||
releaseId: "assigned-on-publish",
|
||||
publishedAt: "assigned-on-publish",
|
||||
source,
|
||||
},
|
||||
artifact: {
|
||||
format: "zip",
|
||||
root: "package/",
|
||||
specVersion: 1,
|
||||
contentSha256: "computed-on-publish",
|
||||
fileCount: publishFiles.length,
|
||||
},
|
||||
files: publishFiles.map((file) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: "computed-on-publish",
|
||||
...(file.contentType ? { contentType: file.contentType } : {}),
|
||||
})),
|
||||
compatibility: input.compatibility ?? null,
|
||||
capabilities:
|
||||
input.family === "bundle-plugin"
|
||||
? {
|
||||
format: input.bundleFormat.trim() || null,
|
||||
hostTargets,
|
||||
}
|
||||
: null,
|
||||
verification: { scanStatus: "pending" },
|
||||
hostTargets: hostTargetPreviewObjects(hostTargets, input.compatibility),
|
||||
environment: {
|
||||
requiresNetwork: true,
|
||||
requiresBrowser: environment.includes("browser"),
|
||||
requiresLocalDesktop: environment.includes("local desktop"),
|
||||
requiresAudioDevice: environment.includes("audio device"),
|
||||
},
|
||||
runtimeBundles: [],
|
||||
};
|
||||
|
||||
return {
|
||||
manifest,
|
||||
manifestJson: JSON.stringify(manifest, null, 2),
|
||||
publishFiles,
|
||||
finalFileCount: publishFiles.length + 1,
|
||||
selectedBytes,
|
||||
hostTargets,
|
||||
environment,
|
||||
blockers,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClawPackIntakeGates(input: {
|
||||
preview: ClawPackPreview;
|
||||
family: "code-plugin" | "bundle-plugin";
|
||||
sourceRepo: string;
|
||||
sourceCommit: string;
|
||||
sourceRef: string;
|
||||
}) {
|
||||
const hasSource = Boolean(input.sourceRepo.trim() && input.sourceCommit.trim());
|
||||
const defaultMatrix =
|
||||
input.family === "bundle-plugin" &&
|
||||
input.preview.hostTargets.length === DEFAULT_CLAWPACK_TARGETS.length &&
|
||||
input.preview.hostTargets.every((target, index) => target === DEFAULT_CLAWPACK_TARGETS[index]);
|
||||
return [
|
||||
{
|
||||
label: "Archive contract",
|
||||
status: input.preview.blockers.length > 0 ? "blocked" : "ready",
|
||||
detail:
|
||||
input.preview.blockers.length > 0
|
||||
? "Resolve blocking metadata before ClawHub can build the canonical Claw Pack."
|
||||
: `${input.preview.finalFileCount} files will be packaged with a generated manifest and digests.`,
|
||||
},
|
||||
{
|
||||
label: "Source provenance",
|
||||
status: hasSource ? "ready" : "blocked",
|
||||
detail: hasSource
|
||||
? `${input.sourceRepo.trim()} @ ${input.sourceRef.trim() || input.sourceCommit.trim()}`
|
||||
: "Code plugins require a source repository and exact commit for review and future rebuild checks.",
|
||||
},
|
||||
{
|
||||
label: "Platform matrix",
|
||||
status: defaultMatrix ? "review" : "ready",
|
||||
detail: defaultMatrix
|
||||
? "Default macOS/Linux/Windows targets are selected. Confirm native dependencies before publish."
|
||||
: input.preview.hostTargets.join(", "),
|
||||
},
|
||||
{
|
||||
label: "Environment needs",
|
||||
status: input.preview.environment.length > 0 ? "ready" : "review",
|
||||
detail:
|
||||
input.preview.environment.length > 0
|
||||
? input.preview.environment.join(", ")
|
||||
: "No environment signals detected beyond package metadata.",
|
||||
},
|
||||
{
|
||||
label: "Security review",
|
||||
status: "pending",
|
||||
detail:
|
||||
"Static, malware, and policy checks run in the background after the Claw Pack is accepted.",
|
||||
},
|
||||
] satisfies ClawPackIntakeGate[];
|
||||
}
|
||||
|
||||
function ClawPackIntakeReview({ gates }: { gates: ClawPackIntakeGate[] }) {
|
||||
return (
|
||||
<Card className="mb-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Claw Pack checks
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
ClawHub checks the package contract, source provenance, platform coverage, environment
|
||||
needs, and background scanning before public install confidence.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{gates.map((gate) => (
|
||||
<div
|
||||
key={gate.label}
|
||||
className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<strong className="text-sm text-[color:var(--ink)]">{gate.label}</strong>
|
||||
<Badge variant={gate.status === "blocked" ? "accent" : "compact"}>
|
||||
{gate.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="m-0 text-sm text-[color:var(--ink-soft)]">{gate.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function PublishPluginRoute() {
|
||||
const search = useSearch({ from: "/publish-plugin" });
|
||||
const { isAuthenticated } = useAuthStatus();
|
||||
@@ -77,13 +391,22 @@ export function PublishPluginRoute() {
|
||||
const [bundleFormat, setBundleFormat] = useState("");
|
||||
const [hostTargets, setHostTargets] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [sourceUrl, setSourceUrl] = useState(
|
||||
search.sourceRepo ? `https://github.com/${search.sourceRepo}` : "",
|
||||
);
|
||||
const [sourceUrlError, setSourceUrlError] = useState<string | null>(null);
|
||||
const [sourceUrlStatus, setSourceUrlStatus] = useState<string | null>(null);
|
||||
const [sourceUrlBusy, setSourceUrlBusy] = useState(false);
|
||||
const [intakeStatus, setIntakeStatus] = useState<string | null>(null);
|
||||
const [ignoredPaths, setIgnoredPaths] = useState<string[]>([]);
|
||||
const [clawPackImport, setClawPackImport] = useState<ClawPackImportSummary | null>(null);
|
||||
const [detectedPrefillFields, setDetectedPrefillFields] = useState<string[]>([]);
|
||||
const [codePluginFieldIssues, setCodePluginFieldIssues] = useState<string[]>([]);
|
||||
const [codePluginCompatibility, setCodePluginCompatibility] =
|
||||
useState<PackageCompatibility | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [publishSuccess, setPublishSuccess] = useState<PublishSuccess | null>(null);
|
||||
|
||||
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
|
||||
const normalizedPaths = useMemo(
|
||||
@@ -111,30 +434,145 @@ export function PublishPluginRoute() {
|
||||
const isMetadataLocked = files.length === 0;
|
||||
const isSubmitting = status !== null;
|
||||
const metadataDisabled = isMetadataLocked || isSubmitting;
|
||||
const clawPackPreview = useMemo(
|
||||
() =>
|
||||
buildClawPackPreview({
|
||||
files,
|
||||
normalizedPaths,
|
||||
family,
|
||||
name,
|
||||
displayName,
|
||||
ownerHandle,
|
||||
version,
|
||||
changelog,
|
||||
sourceRepo,
|
||||
sourceCommit,
|
||||
sourceRef,
|
||||
sourcePath,
|
||||
bundleFormat,
|
||||
hostTargets,
|
||||
compatibility: codePluginCompatibility,
|
||||
codePluginFieldIssues,
|
||||
validationError,
|
||||
}),
|
||||
[
|
||||
files,
|
||||
normalizedPaths,
|
||||
family,
|
||||
name,
|
||||
displayName,
|
||||
ownerHandle,
|
||||
version,
|
||||
changelog,
|
||||
sourceRepo,
|
||||
sourceCommit,
|
||||
sourceRef,
|
||||
sourcePath,
|
||||
bundleFormat,
|
||||
hostTargets,
|
||||
codePluginCompatibility,
|
||||
codePluginFieldIssues,
|
||||
validationError,
|
||||
],
|
||||
);
|
||||
const publishLifecycle = useMemo(() => {
|
||||
if (status) return { label: status };
|
||||
if (!files.length) return { label: "Waiting for files" };
|
||||
if (clawPackPreview?.blockers.length) return { label: "Needs details" };
|
||||
return { label: "Ready to publish" };
|
||||
}, [files.length, clawPackPreview, status]);
|
||||
|
||||
const onPickFiles = async (selected: File[]) => {
|
||||
const expanded = await expandFilesWithReport(selected, {
|
||||
includeBinaryArchiveFiles: true,
|
||||
});
|
||||
const filtered = await filterIgnoredPackageFiles(expanded.files);
|
||||
const normalized = normalizePackageUploadFiles(filtered.files);
|
||||
const nextIgnoredPaths = [
|
||||
...new Set([...expanded.ignoredMacJunkPaths, ...filtered.ignoredPaths]),
|
||||
];
|
||||
setFiles(filtered.files);
|
||||
setIgnoredPaths(nextIgnoredPaths);
|
||||
setError(null);
|
||||
const prefill = await derivePluginPrefill(normalized);
|
||||
setDetectedPrefillFields(listPrefilledFields(prefill));
|
||||
setCodePluginFieldIssues(prefill.missingRequiredFields ?? []);
|
||||
setCodePluginCompatibility(prefill.compatibility ?? null);
|
||||
if (prefill.family) setFamily(prefill.family);
|
||||
if (prefill.name) setName(prefill.name);
|
||||
if (prefill.displayName) setDisplayName(prefill.displayName);
|
||||
if (prefill.version) setVersion(prefill.version);
|
||||
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
|
||||
if (prefill.bundleFormat) setBundleFormat(prefill.bundleFormat);
|
||||
if (prefill.hostTargets) setHostTargets(prefill.hostTargets);
|
||||
try {
|
||||
setIntakeStatus("Reading package files...");
|
||||
const expanded = await expandFilesWithReport(selected, {
|
||||
includeBinaryArchiveFiles: true,
|
||||
});
|
||||
setIntakeStatus("Filtering local-only files...");
|
||||
const filtered = await filterIgnoredPackageFiles(expanded.files);
|
||||
setIntakeStatus("Looking for Claw Pack metadata...");
|
||||
const imported = await normalizeClawPackImport(filtered.files);
|
||||
const selectedFiles = imported.summary ? imported.files : filtered.files;
|
||||
const normalized = normalizePackageUploadFiles(selectedFiles);
|
||||
const nextIgnoredPaths = [
|
||||
...new Set([...expanded.ignoredMacJunkPaths, ...filtered.ignoredPaths]),
|
||||
];
|
||||
setFiles(selectedFiles);
|
||||
setPublishSuccess(null);
|
||||
setIgnoredPaths(nextIgnoredPaths);
|
||||
setClawPackImport(imported.summary);
|
||||
setError(null);
|
||||
setIntakeStatus("Prefilling package details...");
|
||||
const prefill = await derivePluginPrefill(normalized);
|
||||
setDetectedPrefillFields(listPrefilledFields(prefill));
|
||||
setCodePluginFieldIssues(prefill.missingRequiredFields ?? []);
|
||||
setCodePluginCompatibility(prefill.compatibility ?? null);
|
||||
if (imported.summary?.family ?? prefill.family) {
|
||||
setFamily((imported.summary?.family ?? prefill.family) as "code-plugin" | "bundle-plugin");
|
||||
}
|
||||
if (imported.summary?.packageName ?? prefill.name) {
|
||||
setName(imported.summary?.packageName ?? prefill.name ?? "");
|
||||
}
|
||||
if (imported.summary?.displayName ?? prefill.displayName) {
|
||||
setDisplayName(imported.summary?.displayName ?? prefill.displayName ?? "");
|
||||
}
|
||||
if (imported.summary?.version ?? prefill.version) {
|
||||
setVersion(imported.summary?.version ?? prefill.version ?? "");
|
||||
}
|
||||
if (imported.summary?.sourceRepo ?? prefill.sourceRepo) {
|
||||
setSourceRepo(imported.summary?.sourceRepo ?? prefill.sourceRepo ?? "");
|
||||
}
|
||||
if (imported.summary?.sourceCommit) setSourceCommit(imported.summary.sourceCommit);
|
||||
if (imported.summary?.sourceRef) setSourceRef(imported.summary.sourceRef);
|
||||
if (imported.summary?.sourcePath) setSourcePath(imported.summary.sourcePath);
|
||||
if (prefill.bundleFormat) setBundleFormat(prefill.bundleFormat);
|
||||
if (imported.summary?.hostTargets.length) {
|
||||
setHostTargets(imported.summary.hostTargets.join(", "));
|
||||
} else if (prefill.hostTargets) setHostTargets(prefill.hostTargets);
|
||||
setIntakeStatus("Package ready for review.");
|
||||
} catch (pickError) {
|
||||
setFiles([]);
|
||||
setPublishSuccess(null);
|
||||
setIgnoredPaths([]);
|
||||
setClawPackImport(null);
|
||||
setDetectedPrefillFields([]);
|
||||
setCodePluginFieldIssues([]);
|
||||
setCodePluginCompatibility(null);
|
||||
setIntakeStatus(null);
|
||||
setError(formatPublishError(pickError));
|
||||
}
|
||||
};
|
||||
|
||||
const onApplySourceUrl = async () => {
|
||||
if (!sourceUrl.trim()) {
|
||||
setSourceUrlError("Paste a GitHub repo, tree, or blob URL.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSourceUrlBusy(true);
|
||||
setSourceUrlError(null);
|
||||
setSourceUrlStatus("Resolving GitHub URL...");
|
||||
const imported = await fetchGitHubPackageSource(sourceUrl, {
|
||||
maxFileBytes: MAX_PUBLISH_FILE_BYTES,
|
||||
maxTotalBytes: MAX_PUBLISH_TOTAL_BYTES,
|
||||
onProgress: (progress) => setSourceUrlStatus(formatGitHubSourceProgress(progress)),
|
||||
});
|
||||
setSourceUrlStatus(`Preparing ${imported.files.length} GitHub files for review...`);
|
||||
await onPickFiles(imported.files);
|
||||
setSourceRepo(imported.source.repo);
|
||||
setSourceCommit(imported.source.commit);
|
||||
setSourceRef(imported.source.ref);
|
||||
setSourcePath(imported.source.path);
|
||||
setSourceUrl(imported.source.url);
|
||||
setSourceUrlStatus(`Fetched ${imported.files.length} files from ${imported.source.repo}.`);
|
||||
setIntakeStatus("GitHub package ready for review.");
|
||||
toast.success("GitHub package fetched. Review the inferred details.");
|
||||
} catch (sourceError) {
|
||||
setSourceUrlStatus(null);
|
||||
setSourceUrlError(formatPublishError(sourceError));
|
||||
} finally {
|
||||
setSourceUrlBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -154,10 +592,8 @@ export function PublishPluginRoute() {
|
||||
{search.name ? "Publish Plugin Release" : "Publish Plugin"}
|
||||
</h1>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
Publish a native code plugin or bundle plugin release.
|
||||
</p>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
New releases stay private until automated security checks and verification finish.
|
||||
Plugin onboarding now builds a Claw Pack (npm pack compatible) from your files. Upload a
|
||||
package or paste a GitHub URL, review the inferred details, then publish.
|
||||
</p>
|
||||
{search.name ? (
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
@@ -169,20 +605,177 @@ export function PublishPluginRoute() {
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="mb-5 grid gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-4 text-sm md:grid-cols-3">
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.08em] text-[color:var(--ink-soft)]">
|
||||
1. Intake
|
||||
</span>
|
||||
<strong className="text-[color:var(--ink)]">
|
||||
{files.length ? `${files.length} files received` : "Choose files or source"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.08em] text-[color:var(--ink-soft)]">
|
||||
2. Details
|
||||
</span>
|
||||
<strong className="text-[color:var(--ink)]">
|
||||
{files.length && !clawPackPreview?.blockers.length ? "Ready" : "Needs review"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-semibold uppercase tracking-[0.08em] text-[color:var(--ink-soft)]">
|
||||
3. Publish
|
||||
</span>
|
||||
<strong className="text-[color:var(--ink)]">{publishLifecycle.label}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PackageSourceChooser
|
||||
files={files}
|
||||
totalBytes={totalBytes}
|
||||
normalizedPaths={normalizedPaths}
|
||||
normalizedPathSet={normalizedPathSet}
|
||||
ignoredPaths={ignoredPaths}
|
||||
sourceUrl={sourceUrl}
|
||||
sourceUrlError={sourceUrlError}
|
||||
sourceUrlStatus={sourceUrlStatus}
|
||||
sourceUrlBusy={sourceUrlBusy}
|
||||
intakeStatus={intakeStatus}
|
||||
detectedPrefillFields={detectedPrefillFields}
|
||||
family={family}
|
||||
validationError={validationError}
|
||||
codePluginFieldIssues={codePluginFieldIssues}
|
||||
codePluginCompatibility={codePluginCompatibility}
|
||||
clawPackImport={clawPackImport}
|
||||
hostTargets={hostTargets}
|
||||
onSourceUrlChange={setSourceUrl}
|
||||
onApplySourceUrl={onApplySourceUrl}
|
||||
onPickFiles={onPickFiles}
|
||||
/>
|
||||
|
||||
{clawPackPreview ? (
|
||||
<ClawPackIntakeReview
|
||||
gates={buildClawPackIntakeGates({
|
||||
preview: clawPackPreview,
|
||||
family,
|
||||
sourceRepo,
|
||||
sourceCommit,
|
||||
sourceRef,
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{clawPackPreview ? (
|
||||
<Card className="mb-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="m-0 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
Claw Pack preview
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[color:var(--ink-soft)]">
|
||||
This is the generated package contract ClawHub will build on publish.
|
||||
</p>
|
||||
</div>
|
||||
<InstallCopyButton
|
||||
text={clawPackPreview.manifestJson}
|
||||
ariaLabel="Copy Claw Pack preview manifest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-3">
|
||||
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] p-3">
|
||||
<dt className="mb-1 text-[color:var(--ink-soft)]">Final archive</dt>
|
||||
<dd className="font-semibold text-[color:var(--ink)]">
|
||||
{clawPackPreview.finalFileCount} files including CLAWPACK.json
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] p-3">
|
||||
<dt className="mb-1 text-[color:var(--ink-soft)]">Selected size</dt>
|
||||
<dd className="font-semibold text-[color:var(--ink)]">
|
||||
{formatPreviewBytes(clawPackPreview.selectedBytes)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="rounded-[var(--radius-sm)] border border-[color:var(--line)] p-3">
|
||||
<dt className="mb-1 text-[color:var(--ink-soft)]">Publish state</dt>
|
||||
<dd className="font-semibold text-[color:var(--ink)]">
|
||||
{publishLifecycle.label}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{clawPackPreview.hostTargets.map((target) => (
|
||||
<Badge key={target} variant="compact">
|
||||
{target}
|
||||
</Badge>
|
||||
))}
|
||||
{clawPackPreview.environment.map((signal) => (
|
||||
<Badge key={signal} variant="compact">
|
||||
{signal}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{clawPackPreview.blockers.length > 0 ? (
|
||||
<div className="rounded-[var(--radius-sm)] border border-red-300/50 bg-red-50 p-3 text-sm text-red-900 dark:border-red-500/30 dark:bg-red-950/30 dark:text-red-100">
|
||||
<strong>Blocking issues</strong>
|
||||
<ul className="mt-2 list-disc pl-5">
|
||||
{clawPackPreview.blockers.map((blocker) => (
|
||||
<li key={blocker}>{blocker}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{clawPackPreview.warnings.length > 0 ? (
|
||||
<div className="rounded-[var(--radius-sm)] border border-amber-300/50 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<strong>Warnings</strong>
|
||||
<ul className="mt-2 list-disc pl-5">
|
||||
{clawPackPreview.warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<strong className="text-sm text-[color:var(--ink)]">CLAWPACK.json</strong>
|
||||
<span className="text-xs text-[color:var(--ink-soft)]">
|
||||
digests computed after upload
|
||||
</span>
|
||||
</div>
|
||||
<pre className="max-h-[420px] overflow-auto rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-4 text-xs leading-5 text-[color:var(--ink)]">
|
||||
<code>{clawPackPreview.manifestJson}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{publishSuccess ? (
|
||||
<Card className="mb-5 border-emerald-300/50 bg-emerald-50 dark:border-emerald-500/30 dark:bg-emerald-950/30">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<Badge variant="compact">Published</Badge>
|
||||
<h2 className="mt-2 mb-1 font-display text-xl font-bold text-[color:var(--ink)]">
|
||||
{publishSuccess.name}@{publishSuccess.version}
|
||||
</h2>
|
||||
<p className="m-0 text-sm text-[color:var(--ink-soft)]">
|
||||
The Claw Pack was accepted and stored. Public plugin pages are unchanged while
|
||||
review, scanning, and rollout controls stay behind management surfaces.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="primary" size="sm">
|
||||
<a href="/dashboard">Open dashboard</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card
|
||||
className={isMetadataLocked ? "pointer-events-none opacity-60" : ""}
|
||||
aria-disabled={isMetadataLocked}
|
||||
@@ -309,21 +902,29 @@ export function PublishPluginRoute() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const publishName = name.trim();
|
||||
const publishVersion = version.trim();
|
||||
setStatus("Uploading files...");
|
||||
setError(null);
|
||||
setPublishSuccess(null);
|
||||
const uploaded = await buildPackageUploadEntries(files, {
|
||||
generateUploadUrl,
|
||||
hashFile,
|
||||
uploadFile,
|
||||
onProgress: (progress) => {
|
||||
setStatus(
|
||||
`Uploading file ${progress.current}/${progress.total}: ${progress.path}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
setStatus("Publishing release...");
|
||||
await publishRelease({
|
||||
const result = await publishRelease({
|
||||
payload: {
|
||||
name: name.trim(),
|
||||
name: publishName,
|
||||
displayName: displayName.trim() || undefined,
|
||||
ownerHandle: ownerHandle || undefined,
|
||||
family,
|
||||
version: version.trim(),
|
||||
version: publishVersion,
|
||||
changelog: changelog.trim(),
|
||||
...(sourceRepo.trim() && sourceCommit.trim()
|
||||
? {
|
||||
@@ -357,6 +958,17 @@ export function PublishPluginRoute() {
|
||||
setStatus(
|
||||
"Published. Pending security checks and verification before public listing.",
|
||||
);
|
||||
setPublishSuccess({
|
||||
name: publishName,
|
||||
version: publishVersion,
|
||||
releaseId:
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"releaseId" in result &&
|
||||
typeof result.releaseId === "string"
|
||||
? result.releaseId
|
||||
: undefined,
|
||||
});
|
||||
} catch (publishError) {
|
||||
toast.error(formatPublishError(publishError));
|
||||
setStatus(null);
|
||||
|
||||
Reference in New Issue
Block a user