mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: add plugin install ranking (#2633)
This commit is contained in:
@@ -232,6 +232,30 @@ describe("download metric helpers", () => {
|
||||
expect(delete_).toHaveBeenCalledWith("downloadMetricDedupes:two");
|
||||
});
|
||||
|
||||
it("prunes stale package install metric dedupe rows after download rows", async () => {
|
||||
vi.setSystemTime(30 * 86_400_000);
|
||||
const { db, delete_, indexCalls } = makeDb(
|
||||
{},
|
||||
{
|
||||
packageInstallMetricDedupes: [
|
||||
{ _id: "packageInstallMetricDedupes:one" },
|
||||
{ _id: "packageInstallMetricDedupes:two" },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await pruneDownloadMetricDedupesHandler({ db }, {});
|
||||
|
||||
expect(result).toEqual({ deleted: 2, hasMore: false });
|
||||
expect(indexCalls.map((call) => call.table)).toEqual([
|
||||
"downloadMetricDedupes",
|
||||
"packageInstallMetricDedupes",
|
||||
]);
|
||||
expect(indexCalls[1]?.indexName).toBe("by_day");
|
||||
expect(delete_).toHaveBeenCalledWith("packageInstallMetricDedupes:one");
|
||||
expect(delete_).toHaveBeenCalledWith("packageInstallMetricDedupes:two");
|
||||
});
|
||||
|
||||
it("reschedules stale dedupe pruning when one bounded batch fills", async () => {
|
||||
vi.setSystemTime(30 * 86_400_000);
|
||||
const rows = Array.from({ length: 200 }, (_, index) => ({
|
||||
|
||||
@@ -105,16 +105,28 @@ export const pruneDownloadMetricDedupesInternal = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const cutoffDayStart = getDayStart(Date.now() - DEDUPE_RETENTION_MS);
|
||||
const stale = await ctx.db
|
||||
const staleDownloads = await ctx.db
|
||||
.query("downloadMetricDedupes")
|
||||
.withIndex("by_day", (q) => q.lt("dayStart", cutoffDayStart))
|
||||
.take(PRUNE_BATCH_SIZE);
|
||||
const remainingBatchSize = PRUNE_BATCH_SIZE - staleDownloads.length;
|
||||
const staleInstalls =
|
||||
remainingBatchSize > 0
|
||||
? await ctx.db
|
||||
.query("packageInstallMetricDedupes")
|
||||
.withIndex("by_day", (q) => q.lt("dayStart", cutoffDayStart))
|
||||
.take(remainingBatchSize)
|
||||
: [];
|
||||
|
||||
for (const entry of stale) {
|
||||
for (const entry of staleDownloads) {
|
||||
await ctx.db.delete(entry._id);
|
||||
}
|
||||
for (const entry of staleInstalls) {
|
||||
await ctx.db.delete(entry._id);
|
||||
}
|
||||
|
||||
const hasMore = stale.length === PRUNE_BATCH_SIZE;
|
||||
const deleted = staleDownloads.length + staleInstalls.length;
|
||||
const hasMore = deleted === PRUNE_BATCH_SIZE;
|
||||
if (hasMore) {
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
@@ -123,7 +135,7 @@ export const pruneDownloadMetricDedupesInternal = internalMutation({
|
||||
);
|
||||
}
|
||||
|
||||
return { deleted: stale.length, hasMore };
|
||||
return { deleted, hasMore };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -10743,9 +10743,16 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.packages.recordPackageInstallInternal, {
|
||||
packageId: "packages:demo-plugin",
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
internal.packages.recordPackageInstallInternal,
|
||||
expect.objectContaining({
|
||||
packageId: "packages:demo-plugin",
|
||||
identityKind: "ip",
|
||||
identityHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
dayStart: expect.any(Number),
|
||||
occurredAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
internal.downloadMetrics.recordDownloadMetricInternal,
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -259,7 +259,7 @@ function normalizeCapabilityTagSegment(value: string) {
|
||||
const PACKAGE_FAMILY_VALUES = ["skill", "code-plugin", "bundle-plugin"] as const;
|
||||
const PLUGIN_EXPORT_FAMILY_VALUES = ["code-plugin", "bundle-plugin"] as const;
|
||||
const PACKAGE_CHANNEL_VALUES = ["official", "community", "private"] as const;
|
||||
const PACKAGE_LIST_SORT_VALUES = ["updated", "downloads", "recommended"] as const;
|
||||
const PACKAGE_LIST_SORT_VALUES = ["updated", "downloads", "recommended", "installs"] as const;
|
||||
const MAX_PLUGIN_EXPORT_FILE_COUNT = 10_000;
|
||||
const MAX_PLUGIN_EXPORT_PAGE_LIMIT = 250;
|
||||
const DEFAULT_PLUGIN_EXPORT_PAGE_LIMIT = 250;
|
||||
@@ -707,22 +707,34 @@ async function streamClawPackRelease(
|
||||
const blob = await ctx.storage.get(release.clawpackStorageId);
|
||||
if (!blob) return text("ClawPack artifact not found", 404, rateHeaders);
|
||||
try {
|
||||
const identity = getDownloadIdentity(request, viewerUserId ? String(viewerUserId) : null);
|
||||
const now = Date.now();
|
||||
const metricArgs = identity
|
||||
? await buildDownloadMetricArgs({
|
||||
target: { kind: "package", id: pkg._id },
|
||||
identity,
|
||||
now,
|
||||
})
|
||||
: null;
|
||||
if (statKind === "install") {
|
||||
await runMutationRef(ctx, internalRefs.packages.recordPackageInstallInternal, {
|
||||
packageId: pkg._id,
|
||||
...(metricArgs
|
||||
? {
|
||||
identityKind: metricArgs.identityKind,
|
||||
identityHash: metricArgs.identityHash,
|
||||
dayStart: metricArgs.dayStart,
|
||||
occurredAt: metricArgs.occurredAt,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
const identity = getDownloadIdentity(request, viewerUserId ? String(viewerUserId) : null);
|
||||
if (identity) {
|
||||
if (metricArgs) {
|
||||
await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.downloadMetrics.recordDownloadMetricInternal,
|
||||
await buildDownloadMetricArgs({
|
||||
target: { kind: "package", id: pkg._id },
|
||||
identity,
|
||||
now: Date.now(),
|
||||
}),
|
||||
metricArgs,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -1013,6 +1025,10 @@ function compareCatalogItemsForSort(
|
||||
const downloads = (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0);
|
||||
if (downloads !== 0) return downloads;
|
||||
}
|
||||
if (sort === "installs") {
|
||||
const installs = (b.stats?.installs ?? 0) - (a.stats?.installs ?? 0);
|
||||
if (installs !== 0) return installs;
|
||||
}
|
||||
return compareCatalogItems(a, b);
|
||||
}
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ const listPublicPageHandler = (
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
category?: string;
|
||||
sort?: "updated" | "downloads" | "recommended";
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs";
|
||||
paginationOpts: { cursor: string | null; numItems: number };
|
||||
},
|
||||
{ page: Array<{ name: string }>; isDone: boolean; continueCursor: string }
|
||||
@@ -152,7 +152,7 @@ const listPageForViewerInternalHandler = (
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
category?: string;
|
||||
sort?: "updated" | "downloads" | "recommended";
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs";
|
||||
viewerUserId?: string;
|
||||
paginationOpts: { cursor: string | null; numItems: number };
|
||||
},
|
||||
@@ -1129,6 +1129,8 @@ function makeDigestCtx(options: {
|
||||
if (
|
||||
indexName === "by_active_downloads" ||
|
||||
indexName === "by_active_family_downloads" ||
|
||||
indexName === "by_active_installs" ||
|
||||
indexName === "by_active_family_installs" ||
|
||||
indexName === "by_active_recommended_rank" ||
|
||||
indexName === "by_active_family_recommended_rank" ||
|
||||
indexName === "by_active_recommended_score" ||
|
||||
@@ -2305,6 +2307,57 @@ describe("packages public queries", () => {
|
||||
expect(paginate).toHaveBeenCalledWith({ cursor: null, numItems: 50 });
|
||||
});
|
||||
|
||||
it("uses a family-scoped installs index for install-sorted family pages", async () => {
|
||||
const { ctx, indexFilters, indexNames, paginate } = makeDigestCtx({
|
||||
packagePages: [
|
||||
{
|
||||
page: [
|
||||
makePackageDoc({
|
||||
_id: "packages:code-plugin-a",
|
||||
name: "code-plugin-a",
|
||||
normalizedName: "code-plugin-a",
|
||||
displayName: "Code Plugin A",
|
||||
family: "code-plugin",
|
||||
stats: { downloads: 100, installs: 200, stars: 0, versions: 1 },
|
||||
}),
|
||||
makePackageDoc({
|
||||
_id: "packages:code-plugin-b",
|
||||
name: "code-plugin-b",
|
||||
normalizedName: "code-plugin-b",
|
||||
displayName: "Code Plugin B",
|
||||
family: "code-plugin",
|
||||
stats: { downloads: 500, installs: 100, stars: 0, versions: 1 },
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listPublicPageHandler(ctx, {
|
||||
family: "code-plugin",
|
||||
sort: "installs",
|
||||
paginationOpts: { cursor: null, numItems: 1 },
|
||||
});
|
||||
|
||||
expect(result.page.map((entry) => entry.name)).toEqual(["code-plugin-a"]);
|
||||
expect(result.isDone).toBe(false);
|
||||
expect(result.continueCursor.startsWith("pkgpage:")).toBe(true);
|
||||
expect(indexNames).toEqual(["by_active_family_installs"]);
|
||||
expect(indexFilters).toEqual([
|
||||
{
|
||||
indexName: "by_active_family_installs",
|
||||
filters: [
|
||||
{ field: "softDeletedAt", value: undefined },
|
||||
{ field: "family", value: "code-plugin" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(paginate).toHaveBeenCalledTimes(1);
|
||||
expect(paginate).toHaveBeenCalledWith({ cursor: null, numItems: 50 });
|
||||
});
|
||||
|
||||
it("uses a family-scoped weighted recommended score index after backfill", async () => {
|
||||
const { ctx, indexFilters, indexNames, paginate } = makeDigestCtx({
|
||||
packagePages: [
|
||||
|
||||
@@ -20,7 +20,16 @@ const recordDownloadHandler = (
|
||||
)._handler;
|
||||
|
||||
const recordInstallHandler = (
|
||||
recordPackageInstallInternal as unknown as WrappedHandler<{ packageId: string }, void>
|
||||
recordPackageInstallInternal as unknown as WrappedHandler<
|
||||
{
|
||||
packageId: string;
|
||||
identityKind?: "user" | "ip";
|
||||
identityHash?: string;
|
||||
dayStart?: number;
|
||||
occurredAt?: number;
|
||||
},
|
||||
void
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const processStatsHandler = (
|
||||
@@ -99,6 +108,73 @@ describe("package stat events", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("dedupes identity-backed installs before appending stat events", async () => {
|
||||
const insert = vi.fn();
|
||||
const unique = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||
_id: "packageInstallMetricDedupes:existing",
|
||||
});
|
||||
const queryBuilder = {
|
||||
eq: vi.fn(() => queryBuilder),
|
||||
};
|
||||
const withIndex = vi.fn((_indexName: string, buildQuery: (q: unknown) => unknown) => {
|
||||
buildQuery(queryBuilder);
|
||||
return { unique };
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn(() => ({ withIndex })),
|
||||
get: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
insert,
|
||||
patch: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
system: {
|
||||
get: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
const args = {
|
||||
packageId: "packages:one",
|
||||
identityKind: "ip" as const,
|
||||
identityHash: "hash-ip",
|
||||
dayStart: 86_400_000,
|
||||
occurredAt: 86_500_000,
|
||||
};
|
||||
|
||||
await recordInstallHandler(ctx, args);
|
||||
await recordInstallHandler(ctx, args);
|
||||
|
||||
expect(withIndex).toHaveBeenCalledWith("by_target_metric_identity_day", expect.any(Function));
|
||||
expect(queryBuilder.eq).toHaveBeenCalledWith("targetKind", "package");
|
||||
expect(queryBuilder.eq).toHaveBeenCalledWith("targetId", "packages:one");
|
||||
expect(queryBuilder.eq).toHaveBeenCalledWith("metricKind", "install");
|
||||
expect(queryBuilder.eq).toHaveBeenCalledWith("identityKind", "ip");
|
||||
expect(queryBuilder.eq).toHaveBeenCalledWith("identityHash", "hash-ip");
|
||||
expect(queryBuilder.eq).toHaveBeenCalledWith("dayStart", 86_400_000);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"packageInstallMetricDedupes",
|
||||
expect.objectContaining({
|
||||
targetKind: "package",
|
||||
targetId: "packages:one",
|
||||
metricKind: "install",
|
||||
identityKind: "ip",
|
||||
identityHash: "hash-ip",
|
||||
dayStart: 86_400_000,
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"packageStatEvents",
|
||||
expect.objectContaining({
|
||||
packageId: "packages:one",
|
||||
kind: "install",
|
||||
occurredAt: 86_500_000,
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("aggregates queued downloads and installs before patching package stats", async () => {
|
||||
const events = [
|
||||
{ _id: "packageStatEvents:1", packageId: "packages:one", kind: "download" },
|
||||
|
||||
+67
-18
@@ -2684,7 +2684,12 @@ export const listPublicPage = query({
|
||||
capabilityTag: v.optional(v.string()),
|
||||
category: v.optional(v.string()),
|
||||
sort: v.optional(
|
||||
v.union(v.literal("updated"), v.literal("downloads"), v.literal("recommended")),
|
||||
v.union(
|
||||
v.literal("updated"),
|
||||
v.literal("downloads"),
|
||||
v.literal("recommended"),
|
||||
v.literal("installs"),
|
||||
),
|
||||
),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
},
|
||||
@@ -3154,7 +3159,12 @@ export const listPageForViewerInternal = internalQuery({
|
||||
capabilityTag: v.optional(v.string()),
|
||||
category: v.optional(v.string()),
|
||||
sort: v.optional(
|
||||
v.union(v.literal("updated"), v.literal("downloads"), v.literal("recommended")),
|
||||
v.union(
|
||||
v.literal("updated"),
|
||||
v.literal("downloads"),
|
||||
v.literal("recommended"),
|
||||
v.literal("installs"),
|
||||
),
|
||||
),
|
||||
viewerUserId: v.optional(v.id("users")),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
@@ -3206,7 +3216,7 @@ async function listPackagePageImpl(
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
category?: string;
|
||||
sort?: "updated" | "downloads" | "recommended";
|
||||
sort?: "updated" | "downloads" | "recommended" | "installs";
|
||||
viewerUserId?: Id<"users">;
|
||||
paginationOpts: { cursor: string | null; numItems: number };
|
||||
},
|
||||
@@ -3263,23 +3273,27 @@ async function listPackagePageImpl(
|
||||
: await getPackageRecommendedIndexName(ctx, family)
|
||||
: null;
|
||||
|
||||
if (args.sort === "downloads" || recommendedIndexName) {
|
||||
if (args.sort === "downloads" || args.sort === "installs" || recommendedIndexName) {
|
||||
let cursor = pageCursor;
|
||||
let pageOffset = offset;
|
||||
let pageSize: number | null = decodedCursor.pageSize ?? null;
|
||||
let done = decodedCursor.done;
|
||||
const buildSortedQuery = () =>
|
||||
family
|
||||
? ctx.db
|
||||
.query("packages")
|
||||
.withIndex(recommendedIndexName ?? "by_active_family_downloads", (q) =>
|
||||
q.eq("softDeletedAt", undefined).eq("family", family),
|
||||
)
|
||||
: ctx.db
|
||||
.query("packages")
|
||||
.withIndex(recommendedIndexName ?? "by_active_downloads", (q) =>
|
||||
q.eq("softDeletedAt", undefined),
|
||||
);
|
||||
const buildSortedQuery = () => {
|
||||
if (family) {
|
||||
const indexName =
|
||||
args.sort === "installs"
|
||||
? "by_active_family_installs"
|
||||
: (recommendedIndexName ?? "by_active_family_downloads");
|
||||
return ctx.db
|
||||
.query("packages")
|
||||
.withIndex(indexName, (q) => q.eq("softDeletedAt", undefined).eq("family", family));
|
||||
}
|
||||
const indexName =
|
||||
args.sort === "installs"
|
||||
? "by_active_installs"
|
||||
: (recommendedIndexName ?? "by_active_downloads");
|
||||
return ctx.db.query("packages").withIndex(indexName, (q) => q.eq("softDeletedAt", undefined));
|
||||
};
|
||||
|
||||
while ((pageOffset > 0 || !done) && collected.length < targetCount) {
|
||||
const scanPageSize = Math.min(
|
||||
@@ -3593,12 +3607,47 @@ export const recordPackageDownloadInternal = internalMutation({
|
||||
});
|
||||
|
||||
export const recordPackageInstallInternal = internalMutation({
|
||||
args: { packageId: v.id("packages") },
|
||||
args: {
|
||||
packageId: v.id("packages"),
|
||||
identityKind: v.optional(v.union(v.literal("user"), v.literal("ip"))),
|
||||
identityHash: v.optional(v.string()),
|
||||
dayStart: v.optional(v.number()),
|
||||
occurredAt: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const identityKind = args.identityKind;
|
||||
const identityHash = args.identityHash;
|
||||
const dayStart = args.dayStart;
|
||||
if (identityKind && identityHash && typeof dayStart === "number") {
|
||||
const existing = await ctx.db
|
||||
.query("packageInstallMetricDedupes")
|
||||
.withIndex("by_target_metric_identity_day", (q) =>
|
||||
q
|
||||
.eq("targetKind", "package")
|
||||
.eq("targetId", args.packageId)
|
||||
.eq("metricKind", "install")
|
||||
.eq("identityKind", identityKind)
|
||||
.eq("identityHash", identityHash)
|
||||
.eq("dayStart", dayStart),
|
||||
)
|
||||
.unique();
|
||||
if (existing) return;
|
||||
|
||||
await ctx.db.insert("packageInstallMetricDedupes", {
|
||||
targetKind: "package",
|
||||
targetId: args.packageId,
|
||||
metricKind: "install",
|
||||
identityKind,
|
||||
identityHash,
|
||||
dayStart,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.insert("packageStatEvents", {
|
||||
packageId: args.packageId,
|
||||
kind: "install",
|
||||
occurredAt: Date.now(),
|
||||
occurredAt: args.occurredAt ?? Date.now(),
|
||||
processedAt: undefined,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1168,6 +1168,8 @@ const packages = defineTable({
|
||||
.index("by_active_updated", ["softDeletedAt", "updatedAt"])
|
||||
.index("by_active_downloads", ["softDeletedAt", "stats.downloads", "updatedAt"])
|
||||
.index("by_active_family_downloads", ["softDeletedAt", "family", "stats.downloads", "updatedAt"])
|
||||
.index("by_active_installs", ["softDeletedAt", "stats.installs", "updatedAt"])
|
||||
.index("by_active_family_installs", ["softDeletedAt", "family", "stats.installs", "updatedAt"])
|
||||
.index("by_active_recommended_rank", [
|
||||
"softDeletedAt",
|
||||
"stats.stars",
|
||||
@@ -2377,6 +2379,25 @@ const downloadMetricDedupes = defineTable({
|
||||
])
|
||||
.index("by_day", ["dayStart"]);
|
||||
|
||||
const packageInstallMetricDedupes = defineTable({
|
||||
targetKind: v.literal("package"),
|
||||
targetId: v.id("packages"),
|
||||
metricKind: v.literal("install"),
|
||||
identityKind: downloadMetricIdentityKind,
|
||||
identityHash: v.string(),
|
||||
dayStart: v.number(),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_target_metric_identity_day", [
|
||||
"targetKind",
|
||||
"targetId",
|
||||
"metricKind",
|
||||
"identityKind",
|
||||
"identityHash",
|
||||
"dayStart",
|
||||
])
|
||||
.index("by_day", ["dayStart"]);
|
||||
|
||||
const reservedSlugs = defineTable({
|
||||
slug: v.string(),
|
||||
originalOwnerUserId: v.id("users"),
|
||||
@@ -2560,6 +2581,7 @@ export default defineSchema({
|
||||
rateLimitShards,
|
||||
downloadDedupes,
|
||||
downloadMetricDedupes,
|
||||
packageInstallMetricDedupes,
|
||||
reservedSlugs,
|
||||
reservedHandles,
|
||||
githubBackupSyncState,
|
||||
|
||||
@@ -98,6 +98,9 @@ Public read:
|
||||
- `GET /api/v1/skills/{slug}/file?path=&version=&tag=`
|
||||
- `GET /api/v1/resolve?slug=&hash=`
|
||||
- `GET /api/v1/download?slug=&version=&tag=`
|
||||
- `GET /api/v1/plugins?limit=&cursor=&sort=`
|
||||
- `sort`: `recommended` (default), `installs`, `updated`, `downloads`
|
||||
- `GET /api/v1/plugins/search?q=...`
|
||||
- `GET /api/v1/packages/{name}/versions/{version}/artifact`
|
||||
- `GET /api/v1/packages/{name}/versions/{version}/security`
|
||||
- `GET /api/v1/packages/{name}/versions/{version}/artifact/download`
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ pnpm add -g clawhub
|
||||
| Bundle plugins | Packaged plugin bundles for OpenClaw distribution | `clawhub package publish <source>` |
|
||||
|
||||
ClawHub tracks semver versions, tags such as `latest`, changelogs, files,
|
||||
downloads, stars, and security scan summaries. Public pages show current registry
|
||||
installs, stars, and security scan summaries. Public pages show current registry
|
||||
state so users can inspect a skill or plugin before installing it.
|
||||
|
||||
## Native OpenClaw flows
|
||||
|
||||
@@ -616,6 +616,7 @@ Query params:
|
||||
- `cursor` (optional): pagination cursor
|
||||
- `isOfficial` (optional): `true` or `false`
|
||||
- `executesCode` (optional): `true` or `false`
|
||||
- `sort` (optional): `recommended` (default), `installs`, `updated`, `downloads`
|
||||
- `capabilityTag` (optional): capability filter for plugin packages
|
||||
- `category` (optional): plugin category filter. Current values:
|
||||
`channels`, `mcp-tooling`, `data`, `security`, `observability`,
|
||||
|
||||
@@ -656,7 +656,7 @@ describe("plugin detail route", () => {
|
||||
expect(screen.queryByText("Verified")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders plugin download counts in the metadata sidebar", async () => {
|
||||
it("renders plugin install counts in the metadata sidebar", async () => {
|
||||
loaderDataMock = {
|
||||
...loaderDataMock,
|
||||
detail: {
|
||||
@@ -673,12 +673,12 @@ describe("plugin detail route", () => {
|
||||
|
||||
render(<Component />);
|
||||
|
||||
const downloadsLabel = screen.getByText("Downloads");
|
||||
const installsLabel = screen.getByText("Installs");
|
||||
const currentVersionLabel = screen.getByText("Current version");
|
||||
expect(downloadsLabel.compareDocumentPosition(currentVersionLabel)).toBe(
|
||||
expect(installsLabel.compareDocumentPosition(currentVersionLabel)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
expect(screen.getByText("1.2k")).toBeTruthy();
|
||||
expect(screen.getByText("9")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows plugin settings when the viewer can manage the plugin", async () => {
|
||||
@@ -867,7 +867,7 @@ describe("plugin detail route", () => {
|
||||
label?.startsWith("Security audit"),
|
||||
);
|
||||
expect(securityAuditLabelIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(securityAuditLabelIndex).toBeGreaterThan(sidebarLabels.indexOf("Downloads"));
|
||||
expect(securityAuditLabelIndex).toBeGreaterThan(sidebarLabels.indexOf("Installs"));
|
||||
expect(screen.queryByRole("tab", { name: "Capabilities" })).toBeNull();
|
||||
expect(screen.queryByRole("tab", { name: "Verification" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -361,7 +361,7 @@ describe("plugins route", () => {
|
||||
expect(fetchPluginCatalogMock.mock.calls[0]?.[0]).not.toHaveProperty("sort");
|
||||
});
|
||||
|
||||
it("forwards downloads sort for plugin browse", async () => {
|
||||
it("forwards explicit plugin browse sorts", async () => {
|
||||
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
|
||||
const { loadPluginsPageData } = await import("../routes/plugins/index");
|
||||
|
||||
@@ -375,6 +375,17 @@ describe("plugins route", () => {
|
||||
limit: 25,
|
||||
}),
|
||||
);
|
||||
|
||||
await loadPluginsPageData({
|
||||
sort: "installs",
|
||||
});
|
||||
|
||||
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sort: "installs",
|
||||
limit: 25,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards category through catalog loading without changing the query", async () => {
|
||||
@@ -433,7 +444,7 @@ describe("plugins route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders plugin download counts in browse results", async () => {
|
||||
it("renders plugin install counts in browse results", async () => {
|
||||
loaderDataMock = {
|
||||
items: [
|
||||
{
|
||||
@@ -445,7 +456,7 @@ describe("plugins route", () => {
|
||||
executesCode: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
stats: { downloads: 1_234, installs: 0, stars: 0, versions: 1 },
|
||||
stats: { downloads: 1_234, installs: 9, stars: 0, versions: 1 },
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
@@ -457,7 +468,7 @@ describe("plugins route", () => {
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByText("1.2k")).toBeTruthy();
|
||||
expect(screen.getByText("9")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the browse shell immediately while catalog data loads", async () => {
|
||||
@@ -854,6 +865,9 @@ describe("plugins route", () => {
|
||||
expect(validateSearch({ sort: "recommended" })).toEqual(
|
||||
expect.objectContaining({ sort: "recommended" }),
|
||||
);
|
||||
expect(validateSearch({ sort: "installs" })).toEqual(
|
||||
expect.objectContaining({ sort: "installs" }),
|
||||
);
|
||||
expect(validateSearch({ sort: "relevance" })).toEqual(
|
||||
expect.objectContaining({ sort: "relevance" }),
|
||||
);
|
||||
@@ -1030,6 +1044,7 @@ describe("plugins route", () => {
|
||||
expect(screen.getByRole("radio", { name: "Recommended" }).getAttribute("aria-checked")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: "Most installed" })).toBeTruthy();
|
||||
expect(screen.getByRole("radio", { name: "Recently updated" })).toBeTruthy();
|
||||
expect(screen.queryByRole("radio", { name: "Relevance" })).toBeNull();
|
||||
});
|
||||
@@ -1120,7 +1135,12 @@ describe("plugins route", () => {
|
||||
const sortOptions = Array.from(
|
||||
screen.getByRole("radiogroup", { name: "Sort order" }).querySelectorAll('[role="radio"]'),
|
||||
).map((option) => option.textContent);
|
||||
expect(sortOptions).toEqual(["Recommended", "Most downloaded", "Recently updated"]);
|
||||
expect(sortOptions).toEqual([
|
||||
"Recommended",
|
||||
"Most installed",
|
||||
"Most downloaded",
|
||||
"Recently updated",
|
||||
]);
|
||||
expect(screen.queryByRole("radio", { name: "Newest" })).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "Name" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ArrowDownToLine } from "lucide-react";
|
||||
import { PackageCheck } from "lucide-react";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PackageListItem } from "../lib/packageApi";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
@@ -11,7 +11,7 @@ type PluginListItemProps = {
|
||||
};
|
||||
|
||||
export function PluginListItem({ item, variant = "list" }: PluginListItemProps) {
|
||||
const downloads = formatCompactStat(item.stats?.downloads ?? 0);
|
||||
const installs = formatCompactStat(item.stats?.installs ?? 0);
|
||||
|
||||
if (variant === "card") {
|
||||
return (
|
||||
@@ -40,7 +40,7 @@ export function PluginListItem({ item, variant = "list" }: PluginListItemProps)
|
||||
<span className="skill-list-item-meta-item">v{item.latestVersion}</span>
|
||||
) : null}
|
||||
<span className="skill-list-item-meta-item">
|
||||
<ArrowDownToLine size={14} aria-hidden="true" /> {downloads}
|
||||
<PackageCheck size={14} aria-hidden="true" /> {installs}
|
||||
</span>
|
||||
<span className="skill-list-item-meta-item">
|
||||
{item.ownerHandle ? `@${item.ownerHandle}` : "community"}
|
||||
@@ -79,7 +79,7 @@ export function PluginListItem({ item, variant = "list" }: PluginListItemProps)
|
||||
<span className="skill-list-item-meta-item">v{item.latestVersion}</span>
|
||||
) : null}
|
||||
<span className="skill-list-item-meta-item">
|
||||
<ArrowDownToLine size={14} aria-hidden="true" /> {downloads}
|
||||
<PackageCheck size={14} aria-hidden="true" /> {installs}
|
||||
</span>
|
||||
<span className="skill-list-item-meta-item">
|
||||
{item.ownerHandle ? `@${item.ownerHandle}` : "community"}
|
||||
|
||||
@@ -121,8 +121,8 @@ describe("SkillHeader", () => {
|
||||
expect(onToggleStar).not.toHaveBeenCalled();
|
||||
expect(onOpenReport).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Owner")).toBeTruthy();
|
||||
expect(screen.getByText("Downloads")).toBeTruthy();
|
||||
expect(screen.getByText("2")).toBeTruthy();
|
||||
expect(screen.getByText("Installs")).toBeTruthy();
|
||||
expect(screen.getByText("3")).toBeTruthy();
|
||||
expect(container.querySelector('a[href="/user/local"]')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('nav[aria-label="Skill breadcrumbs"] a[href="/user/local"]'),
|
||||
@@ -174,7 +174,7 @@ describe("SkillHeader", () => {
|
||||
it("hides archive-only metadata for source-backed skills", () => {
|
||||
renderHeader({ showArchiveMetadata: false });
|
||||
|
||||
expect(screen.getByText("Downloads")).toBeTruthy();
|
||||
expect(screen.getByText("Installs")).toBeTruthy();
|
||||
expect(screen.getByText("Owner")).toBeTruthy();
|
||||
expect(screen.getByText("Last updated")).toBeTruthy();
|
||||
expect(screen.queryByText("Current version")).toBeNull();
|
||||
|
||||
@@ -476,7 +476,7 @@ function SkillSidebarStats({
|
||||
ariaLabel="Skill metadata"
|
||||
density="compact"
|
||||
blocks={[
|
||||
{ label: "Downloads", value: formattedStats.downloads, large: true },
|
||||
{ label: "Installs", value: formattedStats.installsAllTime, large: true },
|
||||
{ label: "Repository", value: githubRepositoryLink },
|
||||
{
|
||||
label: "Owner",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ArrowDownToLine, Star } from "lucide-react";
|
||||
import { PackageCheck, Star } from "lucide-react";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
@@ -49,8 +49,8 @@ export function SkillListItem({ skill, ownerHandle, owner }: SkillListItemProps)
|
||||
<Star size={14} aria-hidden="true" /> {formatCompactStat(skill.stats.stars)}
|
||||
</span>
|
||||
<span className="skill-list-item-meta-item">
|
||||
<ArrowDownToLine size={14} aria-hidden="true" />{" "}
|
||||
{formatCompactStat(skill.stats.downloads)}
|
||||
<PackageCheck size={14} aria-hidden="true" />{" "}
|
||||
{formatCompactStat(skill.stats.installsAllTime ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowDownToLine, Star } from "lucide-react";
|
||||
import { PackageCheck, Star } from "lucide-react";
|
||||
import { formatSkillStatsTriplet, type SkillStatsTriplet } from "../lib/numberFormat";
|
||||
|
||||
export function SkillStatsTripletLine({ stats }: { stats: SkillStatsTriplet }) {
|
||||
@@ -11,8 +11,8 @@ export function SkillStatsTripletLine({ stats }: { stats: SkillStatsTriplet }) {
|
||||
</span>
|
||||
<span className="skill-stats-dot">·</span>
|
||||
<span className="skill-stats-item">
|
||||
<ArrowDownToLine size={14} aria-hidden="true" />
|
||||
{formatted.downloads}
|
||||
<PackageCheck size={14} aria-hidden="true" />
|
||||
{formatted.installsAllTime}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -559,13 +559,11 @@ describe("fetchPluginCatalog", () => {
|
||||
expect(url.searchParams.get("isOfficial")).toBe("true");
|
||||
});
|
||||
|
||||
it("forwards downloads sort to the dedicated plugins browse endpoint", async () => {
|
||||
it("forwards explicit sort values to the dedicated plugins browse endpoint", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ items: [], nextCursor: null }), { status: 200 }),
|
||||
);
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
|
||||
return new Response(JSON.stringify({ items: [], nextCursor: null }), { status: 200 });
|
||||
});
|
||||
|
||||
await fetchPluginCatalog({
|
||||
sort: "downloads",
|
||||
@@ -575,6 +573,15 @@ describe("fetchPluginCatalog", () => {
|
||||
const url = new URL(fetchMock.mock.calls[0]?.[0] as string);
|
||||
expect(url.pathname).toBe("/api/v1/plugins");
|
||||
expect(url.searchParams.get("sort")).toBe("downloads");
|
||||
|
||||
await fetchPluginCatalog({
|
||||
sort: "installs",
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const installsUrl = new URL(fetchMock.mock.calls[1]?.[0] as string);
|
||||
expect(installsUrl.pathname).toBe("/api/v1/plugins");
|
||||
expect(installsUrl.searchParams.get("sort")).toBe("installs");
|
||||
});
|
||||
|
||||
it("uses the dedicated plugins search endpoint for search mode", async () => {
|
||||
|
||||
@@ -169,7 +169,7 @@ export type PackageVersionDetail = {
|
||||
};
|
||||
|
||||
type PluginFamily = "code-plugin" | "bundle-plugin";
|
||||
type PackageCatalogSort = "updated" | "downloads" | "recommended";
|
||||
type PackageCatalogSort = "updated" | "downloads" | "recommended" | "installs";
|
||||
|
||||
type PluginCatalogResult = {
|
||||
items: PackageListItem[];
|
||||
|
||||
@@ -560,7 +560,7 @@ function SkillsHome() {
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
|
||||
<Package size={12} /> {formatStat(entry.skill.stats?.installsAllTime)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-c-install">
|
||||
@@ -596,7 +596,7 @@ function SkillsHome() {
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
|
||||
<Package size={12} /> {formatStat(entry.skill.stats?.installsAllTime)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-c-install">
|
||||
@@ -677,7 +677,7 @@ function SkillsHome() {
|
||||
<span className="home-v2-proof-sep" />
|
||||
<div className="home-v2-proof-item">
|
||||
<span className="home-v2-proof-num">12M</span>
|
||||
<span className="home-v2-proof-label">downloads</span>
|
||||
<span className="home-v2-proof-label">installs</span>
|
||||
</div>
|
||||
<span className="home-v2-proof-sep" />
|
||||
<div className="home-v2-proof-item">
|
||||
@@ -727,7 +727,7 @@ function SkillsHome() {
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
|
||||
<Package size={12} /> {formatStat(entry.skill.stats?.installsAllTime)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-trend-install">
|
||||
|
||||
@@ -729,8 +729,8 @@ function PluginDetailPageContent({ name, loaderData }: PluginDetailPageProps) {
|
||||
density="compact"
|
||||
blocks={[
|
||||
{
|
||||
label: "Downloads",
|
||||
value: formatCompactStat(pkg.stats?.downloads ?? 0),
|
||||
label: "Installs",
|
||||
value: formatCompactStat(pkg.stats?.installs ?? 0),
|
||||
large: true,
|
||||
},
|
||||
{ label: "Repository", value: sourceRepoLink },
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type PackageListItem,
|
||||
} from "../../lib/packageApi";
|
||||
|
||||
type VisiblePluginSort = "recommended" | "updated" | "downloads";
|
||||
type VisiblePluginSort = "recommended" | "updated" | "downloads" | "installs";
|
||||
type PluginSort = VisiblePluginSort | "relevance";
|
||||
type LegacyPluginSort = PluginSort | "newest" | "name";
|
||||
|
||||
@@ -39,6 +39,7 @@ type LegacyPluginView = PluginView | "cards";
|
||||
|
||||
const PLUGIN_SORT_OPTIONS = [
|
||||
{ value: "recommended", label: "Recommended" },
|
||||
{ value: "installs", label: "Most installed" },
|
||||
{ value: "downloads", label: "Most downloaded" },
|
||||
{ value: "updated", label: "Recently updated" },
|
||||
];
|
||||
@@ -97,6 +98,7 @@ function parsePluginSort(value: unknown): LegacyPluginSort | undefined {
|
||||
value === "relevance" ||
|
||||
value === "updated" ||
|
||||
value === "downloads" ||
|
||||
value === "installs" ||
|
||||
value === "newest" ||
|
||||
value === "name"
|
||||
) {
|
||||
@@ -118,6 +120,9 @@ function sortPluginSearchItems(items: PackageListItem[], sort: PluginSort) {
|
||||
if (sort === "downloads") {
|
||||
return (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0) || tieBreak();
|
||||
}
|
||||
if (sort === "installs") {
|
||||
return (b.stats?.installs ?? 0) - (a.stats?.installs ?? 0) || tieBreak();
|
||||
}
|
||||
|
||||
return tieBreak();
|
||||
});
|
||||
@@ -145,7 +150,11 @@ export async function loadPluginsPageData(
|
||||
featured: args.featured,
|
||||
isOfficial: args.official,
|
||||
executesCode: args.executesCode,
|
||||
...(!args.q && (args.sort === "downloads" || !args.sort || args.sort === "recommended")
|
||||
...(!args.q &&
|
||||
(args.sort === "downloads" ||
|
||||
args.sort === "installs" ||
|
||||
!args.sort ||
|
||||
args.sort === "recommended")
|
||||
? { sort: args.sort ?? "recommended" }
|
||||
: {}),
|
||||
limit: PLUGINS_PAGE_SIZE,
|
||||
@@ -223,6 +232,7 @@ export const Route = createFileRoute("/plugins/")({
|
||||
search.sort !== "recommended" &&
|
||||
search.sort !== "updated" &&
|
||||
search.sort !== "downloads" &&
|
||||
search.sort !== "installs" &&
|
||||
!(hasQuery && search.sort === "relevance");
|
||||
const staleFeatured = Boolean(search.featured);
|
||||
const invalidCategory = Boolean(search.category && !isPluginCategorySlug(search.category));
|
||||
|
||||
Reference in New Issue
Block a user