Compare commits

..
Author SHA1 Message Date
Dallin Romney f2ea75c921 fix: preserve scan report access 2026-06-12 10:08:39 -07:00
Dallin Romney 1e3bc9b02a refactor: extract github app common helpers 2026-06-12 10:06:06 -07:00
Dallin Romney af9cfce93b docs: add github sync runtime proof checklist 2026-06-12 10:06:06 -07:00
Dallin Romney be29a89a72 feat: gate github sync ui rollout 2026-06-12 10:06:06 -07:00
Dallin Romney 273d11c5a9 fix: address github sync review feedback 2026-06-12 10:06:06 -07:00
Dallin Romney cf6441add5 fix: preserve version scan blocks 2026-06-12 10:06:06 -07:00
Dallin Romney 869e340ecc fix: address final github sync review 2026-06-12 10:05:18 -07:00
Dallin Romney 2a2e6c538c fix: continue github sync candidate batches 2026-06-12 10:03:00 -07:00
Dallin Romney 9a2342d627 fix: enforce version scan gates for skill artifacts 2026-06-12 10:03:00 -07:00
Dallin Romney 85be25bccb fix: batch github app repository linking 2026-06-12 10:00:27 -07:00
Dallin Romney e953810d8b fix: enforce github app install target 2026-06-12 10:00:27 -07:00
Dallin Romney bdeb8d0120 fix: retry pending github app install callbacks 2026-06-12 10:00:27 -07:00
Dallin Romney 47454585be fix: address github app sync review findings 2026-06-12 10:00:27 -07:00
Dallin Romney bbc5e2c385 feat: add github app sync UI 2026-06-12 10:00:27 -07:00
Dallin Romney 8683f18d3a fix: harden github app sync handshake 2026-06-12 09:59:38 -07:00
Dallin Romney a01323e835 feat: add github app skill sync 2026-06-12 09:59:38 -07:00
Jesse Merhi b2447d750a Show unfiltered browse totals 2026-06-12 19:04:57 +10:00
Momo 8ce11888aa fix: tolerate duplicate github auth rows (#2612) 2026-06-12 14:49:49 +08:00
Momoandclawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> 3e66b50065 feat: expose skill description setup metadata (#2571)
* feat: expose skill description setup metadata

* feat: expose skill description setup metadata

* fix: narrow skill setup metadata contract

* fix: preserve required setup env metadata

* fix: import public skill file access helper

---------

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
2026-06-12 11:19:38 +08:00
Patrick Erichsen 4f2d20e1e0 chore: remove clawscan cleanup migration
Remove the temporary ClawScan cleanup migration and narrow the schema after production cleanup.
2026-06-11 19:55:33 -07:00
Patrick Erichsen 1f66c9cde6 chore: add clawscan cleanup migration (#2610) 2026-06-11 19:12:31 -07:00
Patrick Erichsen dfac8d83ce fix: allow npm-compatible publisher handles
Allow publisher/org handles to use npm-compatible dots and underscores, update route validation and user-facing copy, and use neutral scoped package examples in docs/tests.
2026-06-11 18:45:25 -07:00
83 changed files with 8563 additions and 1061 deletions
+6
View File
@@ -24,6 +24,7 @@ import type * as functions from "../functions.js";
import type * as githubAccountAgeBackfill from "../githubAccountAgeBackfill.js";
import type * as githubBackups from "../githubBackups.js";
import type * as githubBackupsNode from "../githubBackupsNode.js";
import type * as githubApp from "../githubApp.js";
import type * as githubIdentity from "../githubIdentity.js";
import type * as githubImport from "../githubImport.js";
import type * as githubRestore from "../githubRestore.js";
@@ -61,6 +62,7 @@ import type * as lib_emails from "../lib/emails.js";
import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js";
import type * as lib_embeddings from "../lib/embeddings.js";
import type * as lib_githubAccount from "../lib/githubAccount.js";
import type * as lib_githubAppSync from "../lib/githubAppSync.js";
import type * as lib_githubActionsOidc from "../lib/githubActionsOidc.js";
import type * as lib_githubAuth from "../lib/githubAuth.js";
import type * as lib_githubBackup from "../lib/githubBackup.js";
@@ -93,6 +95,7 @@ import type * as lib_publisherAbuseScoring from "../lib/publisherAbuseScoring.js
import type * as lib_publisherCatalogDisplay from "../lib/publisherCatalogDisplay.js";
import type * as lib_publisherStats from "../lib/publisherStats.js";
import type * as lib_publishers from "../lib/publishers.js";
import type * as lib_recommendationScore from "../lib/recommendationScore.js";
import type * as lib_reporting from "../lib/reporting.js";
import type * as lib_reservedHandles from "../lib/reservedHandles.js";
import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
@@ -168,6 +171,7 @@ declare const fullApi: ApiFromModules<{
githubAccountAgeBackfill: typeof githubAccountAgeBackfill;
githubBackups: typeof githubBackups;
githubBackupsNode: typeof githubBackupsNode;
githubApp: typeof githubApp;
githubIdentity: typeof githubIdentity;
githubImport: typeof githubImport;
githubRestore: typeof githubRestore;
@@ -205,6 +209,7 @@ declare const fullApi: ApiFromModules<{
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
"lib/embeddings": typeof lib_embeddings;
"lib/githubAccount": typeof lib_githubAccount;
"lib/githubAppSync": typeof lib_githubAppSync;
"lib/githubActionsOidc": typeof lib_githubActionsOidc;
"lib/githubAuth": typeof lib_githubAuth;
"lib/githubBackup": typeof lib_githubBackup;
@@ -237,6 +242,7 @@ declare const fullApi: ApiFromModules<{
"lib/publisherCatalogDisplay": typeof lib_publisherCatalogDisplay;
"lib/publisherStats": typeof lib_publisherStats;
"lib/publishers": typeof lib_publishers;
"lib/recommendationScore": typeof lib_recommendationScore;
"lib/reporting": typeof lib_reporting;
"lib/reservedHandles": typeof lib_reservedHandles;
"lib/reservedSlugs": typeof lib_reservedSlugs;
+31
View File
@@ -7,6 +7,10 @@ import { internalAction, internalMutation } from "./functions";
import { EMBEDDING_DIMENSIONS, generateEmbedding } from "./lib/embeddings";
import { normalizePackageName } from "./lib/packageRegistry";
import { ensurePersonalPublisherForUser } from "./lib/publishers";
import {
computeRecommendationScore,
RECOMMENDATION_SCORE_VERSION,
} from "./lib/recommendationScore";
import { buildEmbeddingText, parseClawdisMetadata, parseFrontmatter } from "./lib/skills";
import { generateToken, hashToken } from "./lib/tokens";
@@ -31,6 +35,25 @@ type SeedActionResult = {
type SeedMutationResult = Record<string, unknown>;
function seededPackageRecommendationScore(stats: {
downloads: number;
installs: number;
stars: number;
}) {
return computeRecommendationScore(stats);
}
function seededPackageRecommendationPatch(stats: {
downloads: number;
installs: number;
stars: number;
}) {
return {
recommendedScore: seededPackageRecommendationScore(stats),
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
};
}
const displayManifestStatusValidator = v.union(
v.literal("ok"),
v.literal("missing"),
@@ -1003,6 +1026,7 @@ export const seedPublicCorpusBatchMutation = internalMutation({
verification,
scanStatus: "clean",
stats: { ...stats, versions: 0 },
...seededPackageRecommendationPatch(stats),
softDeletedAt: undefined,
createdAt,
updatedAt: now,
@@ -2317,6 +2341,7 @@ export async function seedLocalModerationFixturesHandler(
},
scanStatus: "malicious",
stats: { downloads: 2, installs: 0, stars: 0, versions: 0 },
...seededPackageRecommendationPatch({ downloads: 2, installs: 0, stars: 0 }),
softDeletedAt: undefined,
createdAt: now,
updatedAt: now,
@@ -2402,6 +2427,7 @@ export async function seedLocalModerationFixturesHandler(
},
tags: { latest: packageReleaseId },
stats: { downloads: 2, installs: 0, stars: 0, versions: 1 },
...seededPackageRecommendationPatch({ downloads: 2, installs: 0, stars: 0 }),
updatedAt: now,
});
const scannedPackageId = await ctx.db.insert("packages", {
@@ -2437,6 +2463,7 @@ export async function seedLocalModerationFixturesHandler(
},
scanStatus: "suspicious",
stats: { downloads: 7, installs: 1, stars: 1, versions: 0 },
...seededPackageRecommendationPatch({ downloads: 7, installs: 1, stars: 1 }),
softDeletedAt: undefined,
createdAt: now,
updatedAt: now,
@@ -2514,6 +2541,7 @@ export async function seedLocalModerationFixturesHandler(
},
tags: { latest: scannedPackageReleaseId },
stats: { downloads: 7, installs: 1, stars: 1, versions: 1 },
...seededPackageRecommendationPatch({ downloads: 7, installs: 1, stars: 1 }),
updatedAt: now,
});
await ctx.db.insert("packageInspectorWarnings", {
@@ -2952,6 +2980,7 @@ export const seedFeaturedPluginPackagesMutation = internalMutation({
verification,
scanStatus: "clean",
stats: { ...spec.stats, versions: 0 },
...seededPackageRecommendationPatch(spec.stats),
softDeletedAt: undefined,
createdAt: now,
updatedAt: now,
@@ -3396,6 +3425,7 @@ export const seedOrgDeletionFixtureMutation = internalMutation({
verification,
scanStatus: "clean",
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
...seededPackageRecommendationPatch({ downloads: 0, installs: 0, stars: 0 }),
softDeletedAt: undefined,
createdAt: now,
updatedAt: now,
@@ -3611,6 +3641,7 @@ export const seedAccountDeletionFixtureMutation = internalMutation({
verification,
scanStatus: "clean",
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
...seededPackageRecommendationPatch({ downloads: 0, installs: 0, stars: 0 }),
softDeletedAt: undefined,
createdAt: now,
updatedAt: now,
+3 -1
View File
@@ -210,7 +210,7 @@ describe("downloads helpers", () => {
expect(storageGet).not.toHaveBeenCalled();
});
it("blocks the exact requested skill version when its ClawScan verdict is malicious", async () => {
it("blocks explicit downloads of a malicious historical version even when the skill is staff-cleared", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
if ("slug" in args) {
@@ -227,6 +227,8 @@ describe("downloads helpers", () => {
isPendingScan: false,
isHiddenByMod: false,
isRemoved: false,
overrideActive: true,
verdict: "clean",
},
};
}
-7
View File
@@ -69,13 +69,6 @@ export async function downloadZipHandler(
headers: mergeHeaders(rate.headers, corsHeaders()),
});
}
if (version.softDeletedAt) {
return new Response("Version not available", {
status: 410,
headers: mergeHeaders(rate.headers, corsHeaders()),
});
}
const moderationBlock = getPublicSkillVersionDownloadBlock(
skillResult.moderationInfo,
version,
+2
View File
@@ -16,6 +16,7 @@ import {
syncPackageSearchDigestsForOwnerUserId,
syncSkillSearchDigestsForOwnerPublisherId,
} from "./functions";
import { computeRecommendationScore } from "./lib/recommendationScore";
type WrappedHandler = {
_handler: (ctx: unknown, args: Record<string, never>) => Promise<unknown>;
@@ -956,6 +957,7 @@ describe("publisher digest scheduling", () => {
statsDownloads: 13,
statsStars: 7,
statsInstallsAllTime: 11,
recommendedScore: computeRecommendationScore({ downloads: 13, installs: 11, stars: 7 }),
stats: expect.objectContaining({
downloads: 13,
stars: 7,
+2175
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -2,6 +2,7 @@ import { ApiRoutes, LegacyApiRoutes } from "clawhub-schema";
import { httpRouter } from "convex/server";
import { auth } from "./auth";
import { downloadZip } from "./downloads";
import { githubWebhookHttp } from "./githubApp";
import {
cliPublishHttp,
cliDeviceCodeHttp,
@@ -315,6 +316,12 @@ http.route({
handler: preflightHandler,
});
http.route({
path: "/api/webhooks/github-app",
method: "POST",
handler: githubWebhookHttp,
});
// TODO: remove legacy /api routes after deprecation window.
http.route({
path: LegacyApiRoutes.download,
+552 -5
View File
@@ -64,6 +64,18 @@ function hasPackageNameArgs(args: unknown): args is { name: string } {
return typeof value.name === "string";
}
function hasPluginRecommendedScoreReadinessArgs(
args: unknown,
): args is { families: Array<"code-plugin" | "bundle-plugin"> } {
if (!args || typeof args !== "object") return false;
const value = args as Record<string, unknown>;
return (
Array.isArray(value.families) &&
value.families.includes("code-plugin") &&
value.families.includes("bundle-plugin")
);
}
function hasPackageDownloadMetricTarget(args: unknown, packageId: string) {
if (!args || typeof args !== "object") return false;
const value = args as Record<string, unknown>;
@@ -117,6 +129,7 @@ function makeCatalogItem(
family: "code-plugin" | "bundle-plugin" | "skill";
updatedAt: number;
score?: number;
stats?: { downloads: number; installs: number; stars: number; versions: number };
},
) {
return {
@@ -128,6 +141,7 @@ function makeCatalogItem(
createdAt: options.updatedAt,
updatedAt: options.updatedAt,
...(typeof options.score === "number" ? { score: options.score } : {}),
...(options.stats ? { stats: options.stats } : {}),
};
}
@@ -1788,6 +1802,79 @@ describe("httpApiV1 handlers", () => {
expect(json.items[0].tags.latest).toBe("1.0.0");
});
it("lists skills with long description metadata and setup requirements", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("cursor" in args || "numItems" in args) {
return {
page: [
{
skill: {
_id: "skills:1",
slug: "home-assistant",
displayName: "Home Assistant",
summary: "Control Home Assistant.",
tags: {},
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
createdAt: 1,
updatedAt: 2,
},
latestVersion: {
version: "1.0.0",
createdAt: 3,
changelog: "c",
parsed: {
description: "Long-form manifest description.",
clawdis: {
requires: { env: ["HA_TOKEN"], config: ["HA_URL"] },
envVars: [
{
name: "HA_TOKEN",
required: false,
description: "Long-lived access token.",
},
{
name: "HA_THEME",
required: false,
description: "Optional dashboard theme.",
},
],
os: ["linux"],
nix: { systems: ["x86_64-linux"] },
},
},
},
},
],
nextCursor: null,
};
}
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.listSkillsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/skills?limit=1"),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.items[0].description).toBe("Long-form manifest description.");
expect(json.items[0].metadata.setup).toEqual([
{
key: "HA_TOKEN",
required: true,
},
{
key: "HA_URL",
required: true,
},
{
key: "HA_THEME",
required: false,
},
]);
});
it("lists skills keeps the v1 no-sort default on updated ranking", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("cursor" in args || "numItems" in args) {
@@ -2095,6 +2182,196 @@ describe("httpApiV1 handlers", () => {
});
});
it("get skill includes readme markdown description and setup requirements", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
return {
skill: {
_id: "skills:1",
slug: "home-assistant",
displayName: "Home Assistant",
summary: "Control Home Assistant.",
latestVersionId: "skillVersions:1",
tags: {},
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
createdAt: 1,
updatedAt: 2,
},
latestVersion: {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
createdAt: 3,
changelog: "c",
files: [],
parsed: {
description: "Frontmatter description.",
clawdis: {
requires: { env: ["HA_TOKEN"], config: ["HA_URL"] },
envVars: [{ name: "HA_TOKEN", description: "Long-lived access token." }],
},
},
},
owner: { handle: "p", displayName: "Peter", image: null },
moderationInfo: null,
};
}
if ("versionIds" in args) return [];
if ("versionId" in args) {
return {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
files: [
{
path: "SKILL.md",
size: 21,
storageId: "_storage:skill-readme",
sha256: "abc123",
contentType: "text/markdown",
},
],
softDeletedAt: undefined,
};
}
return null;
});
const storageGet = vi.fn().mockResolvedValue({
text: vi.fn().mockResolvedValue("# Home Assistant\nSetup."),
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation, storage: { get: storageGet } }),
new Request("https://example.com/api/v1/skills/home-assistant"),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.skill.description).toBe("# Home Assistant\nSetup.");
expect(json.metadata.setup).toEqual([
{
key: "HA_TOKEN",
required: true,
},
{
key: "HA_URL",
required: true,
},
]);
expect(storageGet).toHaveBeenCalledWith("_storage:skill-readme");
});
it("get skill does not read raw markdown descriptions for malware-blocked skills", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
return {
skill: {
_id: "skills:1",
slug: "home-assistant",
displayName: "Home Assistant",
summary: "Control Home Assistant.",
latestVersionId: "skillVersions:1",
tags: {},
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
createdAt: 1,
updatedAt: 2,
},
latestVersion: {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
createdAt: 3,
changelog: "c",
files: [
{
path: "SKILL.md",
size: 21,
storageId: "_storage:skill-readme",
sha256: "abc123",
contentType: "text/markdown",
},
],
parsed: {
description: "Frontmatter description.",
},
},
owner: null,
moderationInfo: {
isPendingScan: false,
isMalwareBlocked: true,
isSuspicious: false,
isHiddenByMod: false,
isRemoved: false,
verdict: "malicious",
reasonCodes: ["blocked.malware"],
summary: "Malware detected.",
sourceVersionId: "skillVersions:1",
},
};
}
if ("versionIds" in args) return [];
if ("versionId" in args) throw new Error("unexpected raw version lookup");
return null;
});
const storageGet = vi.fn();
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation, storage: { get: storageGet } }),
new Request("https://example.com/api/v1/skills/home-assistant"),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.skill.description).toBe("Frontmatter description.");
expect(json.moderation.isMalwareBlocked).toBe(true);
expect(storageGet).not.toHaveBeenCalled();
});
it("get skill uses GitHub-backed cached markdown when no hosted version exists", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("slug" in args) {
return {
skill: {
_id: "skills:github",
slug: "aiq-deploy",
displayName: "AIQ Deploy",
summary: "Deploy workflows.",
tags: {},
stats: { downloads: 0, stars: 0, versions: 0, comments: 0 },
createdAt: 1,
updatedAt: 2,
},
latestVersion: null,
owner: { handle: "nvidia", displayName: "NVIDIA", image: null },
moderationInfo: null,
};
}
if ("versionIds" in args) return [];
if (args.skillId === "skills:github" && args.kind === "readme") {
return {
path: "skills/aiq-deploy/SKILL.md",
text: "# AIQ Deploy\n\nLong GitHub-backed README.",
sourceBaseUrl:
"https://github.com/NVIDIA/skills/blob/1111111111111111111111111111111111111111/skills/aiq-deploy",
};
}
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.skillsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/skills/aiq-deploy"),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.skill.description).toBe("# AIQ Deploy\n\nLong GitHub-backed README.");
expect(runQuery).toHaveBeenCalledWith(expect.anything(), {
skillId: "skills:github",
kind: "readme",
});
});
it("skill install resolver returns archive descriptor for hosted direct uploads", async () => {
const runQuery = makeInstallResolverRunQuery({
skill: {
@@ -7251,6 +7528,12 @@ describe("httpApiV1 handlers", () => {
updatedAt: 100,
};
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (Object.keys(args).length === 0) {
return 2;
}
if (hasPluginRecommendedScoreReadinessArgs(args)) {
return false;
}
if (args.family === "code-plugin") {
return { page: [codePlugin], isDone: true, continueCursor: "" };
}
@@ -7267,13 +7550,18 @@ describe("httpApiV1 handlers", () => {
);
expect(response.status).toBe(200);
expect((await response.json()).items.map((entry: { name: string }) => entry.name)).toEqual([
const json = await response.json();
expect(json.items.map((entry: { name: string }) => entry.name)).toEqual([
"code-plugin",
"bundle-plugin",
]);
const families = runQuery.mock.calls.map(([, args]) => (args as { family?: string }).family);
expect(json.totalCount).toBe(2);
const families = runQuery.mock.calls
.map(([, args]) => (args as { family?: string }).family)
.filter(Boolean);
expect(families).toEqual(["code-plugin", "bundle-plugin"]);
for (const [, args] of runQuery.mock.calls) {
if (!("family" in (args as Record<string, unknown>))) continue;
expect(args).toEqual(
expect.objectContaining({
category: undefined,
@@ -7284,7 +7572,12 @@ describe("httpApiV1 handlers", () => {
});
it("plugins list forwards category to both plugin families", async () => {
const runQuery = vi.fn().mockResolvedValue({ page: [], isDone: true, continueCursor: "" });
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (hasPluginRecommendedScoreReadinessArgs(args)) {
return false;
}
return { page: [], isDone: true, continueCursor: "" };
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.listPluginsV1Handler(
@@ -7294,6 +7587,7 @@ describe("httpApiV1 handlers", () => {
expect(response.status).toBe(200);
for (const [, args] of runQuery.mock.calls) {
if (hasPluginRecommendedScoreReadinessArgs(args)) continue;
expect(args).toEqual(
expect.objectContaining({
category: "data",
@@ -7303,6 +7597,201 @@ describe("httpApiV1 handlers", () => {
}
});
it("plugins list recommended sort uses weighted scores across plugin families", async () => {
const codePlugin = makeCatalogItem("code-starred", {
family: "code-plugin",
updatedAt: 100,
stats: { downloads: 10, installs: 20, stars: 5, versions: 1 },
});
const bundlePlugin = makeCatalogItem("bundle-downloaded", {
family: "bundle-plugin",
updatedAt: 200,
stats: { downloads: 1_000, installs: 0, stars: 1, versions: 1 },
});
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (Object.keys(args).length === 0) return 2;
if (hasPluginRecommendedScoreReadinessArgs(args)) return false;
expect(args).toEqual(expect.objectContaining({ sort: "recommended" }));
if (args.family === "code-plugin") {
return { page: [codePlugin], isDone: true, continueCursor: "" };
}
if (args.family === "bundle-plugin") {
return { page: [bundlePlugin], isDone: true, continueCursor: "" };
}
throw new Error(`unexpected family ${String(args.family)}`);
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.listPluginsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/plugins?limit=2&sort=recommended"),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.items.map((entry: { name: string }) => entry.name)).toEqual([
"bundle-downloaded",
"code-starred",
]);
});
it("plugins list recommended sort lets strong downloads beat smaller installs", async () => {
const codePlugin = makeCatalogItem("code-downloaded", {
family: "code-plugin",
updatedAt: 100,
stats: { downloads: 43_080, installs: 2, stars: 0, versions: 1 },
});
const bundlePlugin = makeCatalogItem("bundle-installed", {
family: "bundle-plugin",
updatedAt: 200,
stats: { downloads: 393, installs: 74, stars: 0, versions: 1 },
});
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (Object.keys(args).length === 0) return 2;
if (hasPluginRecommendedScoreReadinessArgs(args)) return false;
expect(args).toEqual(expect.objectContaining({ sort: "recommended" }));
if (args.family === "code-plugin") {
return { page: [codePlugin], isDone: true, continueCursor: "" };
}
if (args.family === "bundle-plugin") {
return { page: [bundlePlugin], isDone: true, continueCursor: "" };
}
throw new Error(`unexpected family ${String(args.family)}`);
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.listPluginsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/plugins?limit=2&sort=recommended"),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.items.map((entry: { name: string }) => entry.name)).toEqual([
"code-downloaded",
"bundle-installed",
]);
});
it("plugins list falls back to updated sort while recommendation scores backfill", async () => {
const codePlugin = makeCatalogItem("code-older-high-score", {
family: "code-plugin",
updatedAt: 100,
stats: { downloads: 50_000, installs: 500, stars: 10, versions: 1 },
});
const bundlePlugin = makeCatalogItem("bundle-newer-low-score", {
family: "bundle-plugin",
updatedAt: 200,
stats: { downloads: 1, installs: 0, stars: 0, versions: 1 },
});
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (Object.keys(args).length === 0) return 2;
if (hasPluginRecommendedScoreReadinessArgs(args)) return true;
expect(args).toEqual(expect.objectContaining({ sort: "updated" }));
if (args.family === "code-plugin") {
return { page: [codePlugin], isDone: true, continueCursor: "" };
}
if (args.family === "bundle-plugin") {
return { page: [bundlePlugin], isDone: true, continueCursor: "" };
}
throw new Error(`unexpected family ${String(args.family)}`);
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.listPluginsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/plugins?limit=2&sort=recommended"),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.items.map((entry: { name: string }) => entry.name)).toEqual([
"bundle-newer-low-score",
"code-older-high-score",
]);
});
it("plugins list keeps updated fallback sort from recommended pagination cursors", async () => {
const fallbackCursor = `pkgplugins:${JSON.stringify({
codePlugins: { cursor: null, offset: 0, pageSize: 1, done: false },
bundlePlugins: { cursor: null, offset: 0, pageSize: 1, done: true },
recommendedFallback: "updated",
})}`;
const codePlugin = makeCatalogItem("code-next", {
family: "code-plugin",
updatedAt: 100,
stats: { downloads: 50_000, installs: 500, stars: 10, versions: 1 },
});
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (Object.keys(args).length === 0) return 1;
if (hasPluginRecommendedScoreReadinessArgs(args)) {
throw new Error("readiness should come from the pagination cursor");
}
expect(args).toEqual(expect.objectContaining({ sort: "updated" }));
if (args.family === "code-plugin") {
return { page: [codePlugin], isDone: true, continueCursor: "" };
}
throw new Error(`unexpected family ${String(args.family)}`);
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.listPluginsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(
`https://example.com/api/v1/plugins?limit=1&sort=recommended&cursor=${encodeURIComponent(
fallbackCursor,
)}`,
),
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.items.map((entry: { name: string }) => entry.name)).toEqual(["code-next"]);
});
it("plugins list keeps legacy recommended cursors on recommended sort", async () => {
const legacyCursor = `pkgplugins:${JSON.stringify({
codePlugins: { cursor: "legacy-code-next", offset: 0, pageSize: 1, done: false },
bundlePlugins: { cursor: null, offset: 0, pageSize: 1, done: true },
})}`;
const codePlugin = makeCatalogItem("code-next", {
family: "code-plugin",
updatedAt: 100,
stats: { downloads: 50_000, installs: 500, stars: 10, versions: 1 },
});
const readinessCalls: unknown[] = [];
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (Object.keys(args).length === 0) return 1;
if (hasPluginRecommendedScoreReadinessArgs(args)) {
readinessCalls.push(args);
return true;
}
expect(args).toEqual(
expect.objectContaining({
family: "code-plugin",
sort: "recommended",
paginationOpts: expect.objectContaining({ cursor: "legacy-code-next" }),
}),
);
return { page: [codePlugin], isDone: true, continueCursor: "" };
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.listPluginsV1Handler(
makeCtx({ runQuery, runMutation }),
new Request(
`https://example.com/api/v1/plugins?limit=1&sort=recommended&cursor=${encodeURIComponent(
legacyCursor,
)}`,
),
);
expect(response.status).toBe(200);
expect(readinessCalls).toEqual([]);
const json = await response.json();
expect(json.items.map((entry: { name: string }) => entry.name)).toEqual(["code-next"]);
});
it("plugins list rejects invalid categories", async () => {
const runQuery = vi.fn();
const runMutation = vi.fn().mockResolvedValue(okRate());
@@ -7331,6 +7820,8 @@ describe("httpApiV1 handlers", () => {
updatedAt: 200,
});
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (Object.keys(args).length === 0) return 3;
if (hasPluginRecommendedScoreReadinessArgs(args)) return false;
const pagination = args.paginationOpts as { cursor: string | null };
if (args.family === "code-plugin" && pagination.cursor === null) {
return { page: [codeNewest], isDone: false, continueCursor: "code-cursor" };
@@ -7384,7 +7875,11 @@ describe("httpApiV1 handlers", () => {
});
it("plugins list ignores stale plugin search cursors", async () => {
const runQuery = vi.fn().mockResolvedValue({ page: [], isDone: true, continueCursor: "" });
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (Object.keys(args).length === 0) return 0;
if (hasPluginRecommendedScoreReadinessArgs(args)) return false;
return { page: [], isDone: true, continueCursor: "" };
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const staleSearchCursor = `pkgpluginsearch:${JSON.stringify({
codePlugins: { cursor: "code-search", offset: 0, pageSize: 2, done: false },
@@ -7406,7 +7901,10 @@ describe("httpApiV1 handlers", () => {
});
it("package and plugin lists ignore stale skill cursors", async () => {
const runQuery = vi.fn().mockResolvedValue({ page: [], isDone: true, continueCursor: "" });
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (hasPluginRecommendedScoreReadinessArgs(args)) return false;
return { page: [], isDone: true, continueCursor: "" };
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const staleSkillCursor = `skillcat:${JSON.stringify({
cursor: "skill-cursor",
@@ -7816,6 +8314,55 @@ describe("httpApiV1 handlers", () => {
});
});
it("packages version detail blocks malicious skill compatibility versions", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("name" in args) return null;
if ("slug" in args) {
return {
skill: {
_id: "skills:demo",
slug: "demo",
displayName: "Demo Skill",
summary: "Skill summary",
latestVersionId: "skillVersions:demo-2",
tags: { latest: "skillVersions:demo-2" },
badges: {},
createdAt: 1,
updatedAt: 2,
},
latestVersion: null,
owner: { handle: "steipete", displayName: "Peter" },
moderationInfo: null,
};
}
if (args.version === "1.0.0") {
return {
_id: "skillVersions:demo-1",
skillId: "skills:demo",
version: "1.0.0",
createdAt: 3,
changelog: "init",
files: [{ path: "SKILL.md", size: 11, sha256: "abc" }],
llmAnalysis: {
status: "malicious",
verdict: "malicious",
checkedAt: 4,
},
};
}
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.packagesGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/packages/demo/versions/1.0.0"),
);
expect(response.status).toBe(403);
expect(await response.text()).toContain("flagged as malicious");
});
it("packages detail returns not found for invalid package lookup names", async () => {
const runQuery = vi.fn(async () => {
throw new Error("unexpected package lookup");
+66 -15
View File
@@ -53,9 +53,9 @@ import {
MAX_PUBLISH_FILE_BYTES,
MAX_PUBLISH_TOTAL_BYTES,
} from "../lib/publishLimits";
import { compareRecommendationStats } from "../lib/recommendationScore";
import {
getPublicSkillVersionAccessBlock,
getPublicSkillVersionDownloadBlock,
getPublicSkillVersionFileAccessBlock,
getSkillFileModerationInfoFromSkill,
isSkillVersionForSkill,
} from "../lib/skillFileAccess";
@@ -97,7 +97,9 @@ const apiRefs = api as unknown as {
};
const internalRefs = internal as unknown as {
packages: {
countPublicPluginsInternal: unknown;
getByNameForViewerInternal: unknown;
hasMissingRecommendationScoresInternal: unknown;
listPluginExportPageInternal: unknown;
listPageForViewerInternal: unknown;
searchForViewerInternal: unknown;
@@ -255,7 +257,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"] as const;
const PACKAGE_LIST_SORT_VALUES = ["updated", "downloads", "recommended"] as const;
const MAX_PLUGIN_EXPORT_FILE_COUNT = 10_000;
const MAX_PLUGIN_EXPORT_PAGE_LIMIT = 250;
const DEFAULT_PLUGIN_EXPORT_PAGE_LIMIT = 250;
@@ -453,6 +455,10 @@ type SkillVersionLike = {
contentType?: string;
}>;
softDeletedAt?: number;
sha256hash?: string;
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
llmAnalysis?: Doc<"skillVersions">["llmAnalysis"];
staticScan?: Doc<"skillVersions">["staticScan"];
};
type ReleaseLike = {
@@ -808,6 +814,7 @@ type UnifiedCatalogCursorState = {
type PluginCatalogCursorState = {
codePlugins: CatalogSourceCursorState;
bundlePlugins: CatalogSourceCursorState;
recommendedFallback?: "updated";
};
type CatalogPageResult<T> = {
@@ -913,6 +920,7 @@ function decodeMultiPluginCursor(
return {
codePlugins: normalize(parsed.codePlugins),
bundlePlugins: normalize(parsed.bundlePlugins),
recommendedFallback: parsed.recommendedFallback === "updated" ? "updated" : undefined,
};
} catch {
return {
@@ -995,6 +1003,21 @@ function compareCatalogItemsForSort(
b: CatalogListItem,
sort: (typeof PACKAGE_LIST_SORT_VALUES)[number] | undefined,
) {
if (sort === "recommended") {
const score = compareRecommendationStats(
{
downloads: a.stats?.downloads ?? 0,
installs: a.stats?.installs ?? 0,
stars: a.stats?.stars ?? 0,
},
{
downloads: b.stats?.downloads ?? 0,
installs: b.stats?.installs ?? 0,
stars: b.stats?.stars ?? 0,
},
);
if (score !== 0) return score;
}
if (sort === "downloads") {
const downloads = (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0);
if (downloads !== 0) return downloads;
@@ -1541,9 +1564,35 @@ async function listPackages(
}
if (!effectiveFamily && options?.pluginFamilies?.length) {
const includeTotalCount =
!includeSkills &&
!category &&
!channelParam.value &&
typeof isOfficial.value !== "boolean" &&
!highlightedOnly &&
typeof executesCode.value !== "boolean" &&
!capabilityTag;
const totalCount = includeTotalCount
? await runQueryRef<number | null>(ctx, internalRefs.packages.countPublicPluginsInternal, {})
: null;
const decodedCursor = decodePluginCatalogCursor(cursor);
const codePluginSource = initCatalogSource<CatalogListItem>(decodedCursor.codePlugins);
const bundlePluginSource = initCatalogSource<CatalogListItem>(decodedCursor.bundlePlugins);
const isFreshRecommendedRequest = sortParam.value === "recommended" && !cursor;
const hasMissingRecommendationScores = isFreshRecommendedRequest
? await runQueryRef<boolean>(
ctx,
internalRefs.packages.hasMissingRecommendationScoresInternal,
{
families: options.pluginFamilies,
},
)
: false;
const useUpdatedRecommendationFallback =
sortParam.value === "recommended" &&
(decodedCursor.recommendedFallback === "updated" ||
(isFreshRecommendedRequest && hasMissingRecommendationScores));
const pluginListSort = useUpdatedRecommendationFallback ? "updated" : sortParam.value;
const pageSize = limit;
const items: CatalogListItem[] = [];
const fetchPluginPage = async (
@@ -1563,7 +1612,7 @@ async function listPackages(
executesCode: executesCode.value,
capabilityTag,
category,
sort: sortParam.value,
sort: pluginListSort,
viewerUserId: viewerUserId ?? undefined,
paginationOpts: { cursor: pageCursor, numItems },
});
@@ -1592,7 +1641,7 @@ async function listPackages(
if (
!bundlePluginCandidate ||
(codePluginCandidate &&
compareCatalogItemsForSort(codePluginCandidate, bundlePluginCandidate, sortParam.value) <=
compareCatalogItemsForSort(codePluginCandidate, bundlePluginCandidate, pluginListSort) <=
0)
) {
items.push(codePluginCandidate!);
@@ -1606,6 +1655,7 @@ async function listPackages(
const nextState = {
codePlugins: finalizeCatalogSource(codePluginSource),
bundlePlugins: finalizeCatalogSource(bundlePluginSource),
recommendedFallback: useUpdatedRecommendationFallback ? ("updated" as const) : undefined,
};
const isDoneAll =
nextState.codePlugins.done &&
@@ -1616,6 +1666,7 @@ async function listPackages(
{
items,
nextCursor: isDoneAll ? null : encodePluginCatalogCursor(nextState),
...(totalCount !== null ? { totalCount } : {}),
},
200,
rate.headers,
@@ -2879,9 +2930,9 @@ async function getUnavailableSkillPackageVersionBlock(
if (!version || !isSkillVersionForSkill(version, skill._id)) return null;
if (version.softDeletedAt) return { status: 410, message: "Version not available" };
return getPublicSkillVersionAccessBlock(
return getPublicSkillVersionFileAccessBlock(
version,
getSkillFileModerationInfoFromSkill(skill),
version._id,
skill.latestVersionId ?? skill.tags?.latest,
);
}
@@ -3443,13 +3494,13 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
if (!version || version.softDeletedAt) return text("Version not found", 404, rate.headers);
const effectiveLatestVersionId =
skillDetail.skill.latestVersionId ?? skillDetail.skill.tags?.latest;
const moderationBlock = getPublicSkillVersionAccessBlock(
const versionAccessBlock = getPublicSkillVersionFileAccessBlock(
version,
skillDetail.moderationInfo,
version._id,
effectiveLatestVersionId,
);
if (moderationBlock)
return text(moderationBlock.message, moderationBlock.status, rate.headers);
if (versionAccessBlock)
return text(versionAccessBlock.message, versionAccessBlock.status, rate.headers);
const tags = await resolveSkillTags(ctx, skillDetail.skill._id, skillDetail.skill.tags);
return json(
{
@@ -3535,13 +3586,13 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
if (!version || version.softDeletedAt) return text("Version not found", 404, rate.headers);
const effectiveLatestVersionId =
skillDetail.skill.latestVersionId ?? skillDetail.skill.tags?.latest;
const moderationBlock = getPublicSkillVersionDownloadBlock(
skillDetail.moderationInfo,
const versionAccessBlock = getPublicSkillVersionFileAccessBlock(
version,
skillDetail.moderationInfo,
effectiveLatestVersionId,
);
if (moderationBlock)
return text(moderationBlock.message, moderationBlock.status, rate.headers);
if (versionAccessBlock)
return text(versionAccessBlock.message, versionAccessBlock.status, rate.headers);
const file = resolveSkillFilePath(version, path);
if (!file) return text("File not found", 404, rate.headers);
if (!("storageId" in file) || !file.storageId)
+115 -20
View File
@@ -34,8 +34,9 @@ import type {
} from "../lib/securityPrompt";
import { selectGeneratedSkillCardFile, sourceSkillVersionFiles } from "../lib/skillCards";
import {
getPublicSkillFileAccessBlock,
getPublicSkillVersionAccessBlock,
getPublicSkillVersionDownloadBlock,
getPublicSkillVersionFileAccessBlock,
getSkillFileModerationInfoFromSkill,
isSkillVersionForSkill,
} from "../lib/skillFileAccess";
@@ -106,10 +107,7 @@ type ListSkillsResult = {
version: string;
createdAt: number;
changelog: string;
parsed?: {
license?: "MIT-0";
clawdis?: { os?: string[]; nix?: { plugin?: boolean; systems?: string[] } };
};
parsed?: PublicSkillVersionParsed;
} | null;
}>;
nextCursor: string | null;
@@ -123,8 +121,19 @@ type PublicSkillVersionFile = {
};
type PublicSkillVersionParsed = {
description?: string;
license?: "MIT-0";
clawdis?: { os?: string[]; nix?: { plugin?: boolean; systems?: string[] } };
clawdis?: {
os?: string[];
nix?: { plugin?: boolean; systems?: string[] };
requires?: { env?: string[]; config?: string[] };
envVars?: Array<{ name: string; required?: boolean; description?: string }>;
};
};
type SkillSetupEntry = {
key: string;
required: boolean;
};
type PublicSkillVersionStaticScan = Pick<
@@ -1001,6 +1010,74 @@ function buildSecurityAuditUrl(
return url.toString();
}
function addSetupEntry(
entries: SkillSetupEntry[],
seen: Set<string>,
key: string,
options: { required?: boolean } = {},
) {
const normalizedKey = key.trim();
if (!normalizedKey) return;
if (seen.has(normalizedKey)) return;
seen.add(normalizedKey);
entries.push({
key: normalizedKey,
required: options.required ?? true,
});
}
function buildSkillSetup(parsed: PublicSkillVersionParsed | undefined): SkillSetupEntry[] {
const clawdis = parsed?.clawdis;
if (!clawdis) return [];
const entries: SkillSetupEntry[] = [];
const seen = new Set<string>();
for (const key of clawdis.requires?.env ?? []) {
addSetupEntry(entries, seen, key, { required: true });
}
for (const key of clawdis.requires?.config ?? []) {
addSetupEntry(entries, seen, key, { required: true });
}
for (const entry of clawdis.envVars ?? []) {
addSetupEntry(entries, seen, entry.name, { required: entry.required ?? true });
}
return entries;
}
function selectSkillReadmeFile(version: Doc<"skillVersions"> | null | undefined) {
return version?.files.find((file) => {
const path = file.path.trim().toLowerCase();
return path === "skill.md" || path === "skills.md";
});
}
async function readSkillDescriptionMarkdown(
ctx: ActionCtx,
skillId: Id<"skills">,
versionId: Id<"skillVersions"> | undefined,
) {
if (versionId) {
const version = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId,
})) as Doc<"skillVersions"> | null;
if (version && isSkillVersionForSkill(version, skillId) && !version.softDeletedAt) {
const file = selectSkillReadmeFile(version);
if (file && file.size <= MAX_RAW_FILE_BYTES) {
const blob = await ctx.storage.get(file.storageId);
if (blob) return await blob.text();
}
}
}
const githubContent = (await ctx.runQuery(api.skills.getGitHubSkillContent, {
skillId,
kind: "readme",
})) as { text?: string } | null;
return githubContent?.text ?? null;
}
function buildSecurityVerdictError(
item: SecurityVerdictRequestItem,
code: string,
@@ -1404,6 +1481,7 @@ export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
slug: item.skill.slug,
displayName: item.skill.displayName,
summary: item.skill.summary ?? null,
description: item.latestVersion?.parsed?.description ?? null,
tags: resolvedTagsList[idx],
stats: item.skill.stats,
createdAt: item.skill.createdAt,
@@ -1418,6 +1496,7 @@ export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
: null,
metadata: item.latestVersion?.parsed?.clawdis
? {
setup: buildSkillSetup(item.latestVersion.parsed),
os: item.latestVersion.parsed.clawdis.os ?? null,
systems: item.latestVersion.parsed.clawdis.nix?.systems ?? null,
}
@@ -1545,9 +1624,9 @@ async function getUnavailableSkillVersionBlock(
if (!version || !isSkillVersionForSkill(version, skill._id)) return null;
if (version.softDeletedAt) return { status: 410, message: "Version not available" };
return getPublicSkillVersionAccessBlock(
return getPublicSkillVersionFileAccessBlock(
version,
getSkillFileModerationInfoFromSkill(skill),
version._id,
skill.latestVersionId ?? skill.tags?.latest,
);
}
@@ -1684,12 +1763,27 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
[result.latestVersion],
[result.skill._id],
);
const latestVersionId =
result.skill.latestVersionId ?? result.skill.tags?.latest ?? result.latestVersion?._id;
const descriptionAccessBlock = result.latestVersion
? getPublicSkillVersionFileAccessBlock(
result.latestVersion,
result.moderationInfo,
latestVersionId,
)
: getPublicSkillFileAccessBlock(result.moderationInfo);
const description = descriptionAccessBlock
? null
: await readSkillDescriptionMarkdown(ctx, result.skill._id, latestVersionId);
const setup = buildSkillSetup(result.latestVersion?.parsed);
return json(
{
skill: {
slug: result.skill.slug,
displayName: result.skill.displayName,
summary: result.skill.summary ?? null,
description: description ?? result.latestVersion?.parsed?.description ?? null,
tags,
stats: result.skill.stats,
createdAt: result.skill.createdAt,
@@ -1705,6 +1799,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
: null,
metadata: result.latestVersion?.parsed?.clawdis
? {
setup,
os: result.latestVersion.parsed.clawdis.os ?? null,
systems: result.latestVersion.parsed.clawdis.nix?.systems ?? null,
}
@@ -1862,13 +1957,13 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
if (version.softDeletedAt) return text("Version not available", 410, rate.headers);
const effectiveLatestVersionId =
skillResult.skill.latestVersionId ?? skillResult.skill.tags?.latest;
const moderationBlock = getPublicSkillVersionAccessBlock(
const versionAccessBlock = getPublicSkillVersionFileAccessBlock(
version,
skillResult.moderationInfo,
version._id,
effectiveLatestVersionId,
);
if (moderationBlock) {
return text(moderationBlock.message, moderationBlock.status, rate.headers);
if (versionAccessBlock) {
return text(versionAccessBlock.message, versionAccessBlock.status, rate.headers);
}
const security = buildSkillSecuritySnapshot(version);
@@ -2157,13 +2252,13 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
if (version.softDeletedAt) return text("Version not available", 410, rate.headers);
const effectiveLatestVersionId =
skillResult.skill.latestVersionId ?? skillResult.skill.tags?.latest;
const moderationBlock = getPublicSkillVersionDownloadBlock(
skillResult.moderationInfo,
const versionAccessBlock = getPublicSkillVersionFileAccessBlock(
version,
skillResult.moderationInfo,
effectiveLatestVersionId,
);
if (moderationBlock) {
return text(moderationBlock.message, moderationBlock.status, rate.headers);
if (versionAccessBlock) {
return text(versionAccessBlock.message, versionAccessBlock.status, rate.headers);
}
const fingerprintEntries = ((await ctx.runQuery(
@@ -2222,13 +2317,13 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
if (version.softDeletedAt) return text("Version not available", 410, rate.headers);
const effectiveLatestVersionId =
skillResult.skill.latestVersionId ?? skillResult.skill.tags?.latest;
const moderationBlock = getPublicSkillVersionDownloadBlock(
skillResult.moderationInfo,
const versionAccessBlock = getPublicSkillVersionFileAccessBlock(
version,
skillResult.moderationInfo,
effectiveLatestVersionId,
);
if (moderationBlock) {
return text(moderationBlock.message, moderationBlock.status, rate.headers);
if (versionAccessBlock) {
return text(versionAccessBlock.message, versionAccessBlock.status, rate.headers);
}
const normalized = path.trim();
+180
View File
@@ -0,0 +1,180 @@
import { generateKeyPairSync } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
buildGitHubAppInstallUrl,
createGitHubAppJwt,
deriveSlugFromCandidatePath,
hashGitHubAppState,
isPathUnderAnyRoot,
normalizeGitHubRepoFullName,
normalizeGitHubSyncRoots,
signGitHubAppState,
sourceLinkMatchesProvenance,
verifyGitHubAppState,
verifyGitHubWebhookSignature,
} from "./githubAppSync";
describe("github app sync helpers", () => {
it("normalizes repository identity and sync roots", () => {
expect(normalizeGitHubRepoFullName("https://github.com/OpenClaw/Skills.git")).toBe(
"OpenClaw/Skills",
);
expect(normalizeGitHubRepoFullName("git+https://github.com/OpenClaw/Skills.git")).toBe(
"OpenClaw/Skills",
);
expect(normalizeGitHubRepoFullName("git@github.com:OpenClaw/Skills.git")).toBe(
"OpenClaw/Skills",
);
expect(normalizeGitHubRepoFullName("https://www.github.com/OpenClaw/Skills/tree/main")).toBe(
"OpenClaw/Skills",
);
expect(normalizeGitHubRepoFullName("not a repo")).toBeNull();
expect(isPathUnderAnyRoot("skills/demo/SKILL.md", ["skills"])).toBe(true);
expect(isPathUnderAnyRoot("packages/demo/package.json", ["skills"])).toBe(false);
expect(normalizeGitHubSyncRoots(["", "skills/demo"])).toEqual(["", "skills/demo"]);
expect(() => normalizeGitHubSyncRoots(["../skills"])).toThrow(/Invalid sync root/);
});
it("derives stable skill slugs from candidate paths", () => {
expect(deriveSlugFromCandidatePath("skills/Demo Skill", "OpenClaw/catalog")).toBe("demo-skill");
expect(deriveSlugFromCandidatePath("", "OpenClaw/Catalog Repo")).toBe("catalog-repo");
});
it("signs setup state, verifies it, and rejects tampering", async () => {
const secret = "state-secret";
const state = await signGitHubAppState(
{
publisherId: "publishers:org",
requestedByUserId: "users:admin",
nonce: "nonce",
targetAccountId: "12345",
exp: 2_000,
},
secret,
1_000,
);
await expect(hashGitHubAppState(state)).resolves.toMatch(/^[a-f0-9]{64}$/);
await expect(verifyGitHubAppState(state, secret, 1_500)).resolves.toEqual({
publisherId: "publishers:org",
requestedByUserId: "users:admin",
nonce: "nonce",
targetAccountId: "12345",
exp: 2_000,
});
await expect(verifyGitHubAppState(`${state}x`, secret, 1_500)).rejects.toThrow(
/Invalid GitHub setup state/,
);
await expect(verifyGitHubAppState(state, secret, 2_001)).rejects.toThrow(
/GitHub setup state expired/,
);
});
it("builds the app install URL with signed state", () => {
const url = buildGitHubAppInstallUrl({
appSlug: "clawhub-test",
state: "signed-state",
targetId: "123",
});
expect(url).toBe(
"https://github.com/apps/clawhub-test/installations/new?state=signed-state&target_id=123",
);
});
it("creates app JWTs from PKCS#8 and GitHub-style PKCS#1 RSA private keys", async () => {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const pkcs8Pem = privateKey.export({ type: "pkcs8", format: "pem" });
const pkcs1Pem = privateKey.export({ type: "pkcs1", format: "pem" });
await expect(
createGitHubAppJwt({ appId: "12345", privateKeyPem: pkcs8Pem, now: 1_700_000_000_000 }),
).resolves.toMatch(/^[^.]+\.[^.]+\.[^.]+$/);
await expect(
createGitHubAppJwt({ appId: "12345", privateKeyPem: pkcs1Pem, now: 1_700_000_000_000 }),
).resolves.toMatch(/^[^.]+\.[^.]+\.[^.]+$/);
});
it("verifies webhook signatures and rejects bad signatures", async () => {
const body = new TextEncoder().encode(JSON.stringify({ zen: "Keep it logically awesome." }));
const signature = await buildGitHubWebhookSignature(body, "webhook-secret");
await expect(
verifyGitHubWebhookSignature({
body: body.buffer as ArrayBuffer,
signatureHeader: signature,
secret: "webhook-secret",
}),
).resolves.toEqual({ ok: true });
await expect(
verifyGitHubWebhookSignature({
body: body.buffer as ArrayBuffer,
signatureHeader: signature,
secret: "wrong-secret",
}),
).resolves.toEqual({ ok: false, reason: "bad-signature" });
});
it("requires exact source sync context for source-managed publishes", () => {
const link = {
repoFullName: "OpenClaw/catalog",
path: "skills/demo",
status: "active",
};
const sourceProvenance = {
kind: "github" as const,
repo: "openclaw/catalog",
path: "skills/demo",
};
expect(
sourceLinkMatchesProvenance({
link,
sourceProvenance,
sourceSync: { sourceLinkId: "skillSourceLinks:1" },
expectedSourceLinkId: "skillSourceLinks:1",
}),
).toBe(true);
expect(
sourceLinkMatchesProvenance({
link: { ...link, status: "conflict" },
sourceProvenance,
sourceSync: { sourceLinkId: "skillSourceLinks:1" },
expectedSourceLinkId: "skillSourceLinks:1",
}),
).toBe(true);
expect(
sourceLinkMatchesProvenance({
link: { ...link, status: "disabled" },
sourceProvenance,
sourceSync: { sourceLinkId: "skillSourceLinks:1" },
expectedSourceLinkId: "skillSourceLinks:1",
}),
).toBe(false);
expect(
sourceLinkMatchesProvenance({
link,
sourceProvenance,
sourceSync: undefined,
expectedSourceLinkId: "skillSourceLinks:1",
}),
).toBe(false);
expect(
sourceLinkMatchesProvenance({
link,
sourceProvenance: { ...sourceProvenance, path: "skills/other" },
sourceSync: { sourceLinkId: "skillSourceLinks:1" },
expectedSourceLinkId: "skillSourceLinks:1",
}),
).toBe(false);
});
});
async function buildGitHubWebhookSignature(body: Uint8Array, secret: string) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign("HMAC", key, body.buffer as ArrayBuffer);
const hex = Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0"));
return `sha256=${hex.join("")}`;
}
+183
View File
@@ -0,0 +1,183 @@
import { ConvexError } from "convex/values";
import {
base64UrlDecode,
base64UrlEncode,
hmacSha256Base64Url,
hmacSha256Hex,
isRepoPathUnderRoot,
normalizeGitHubRepo,
sha256Hex,
timingSafeEqual,
} from "./githubCommon";
import { normalizeRepoPath } from "./githubImport";
import { normalizeSkillSlug } from "./skillSlugValidator";
export { createGitHubAppJwt } from "./githubCommon";
export type GitHubWebhookVerificationResult =
| { ok: true }
| {
ok: false;
reason: "missing-secret" | "missing-signature" | "malformed-signature" | "bad-signature";
};
export type GitHubAppStatePayload = {
publisherId: string;
requestedByUserId: string;
nonce: string;
targetAccountId?: string;
exp: number;
};
const DEFAULT_SETUP_STATE_TTL_MS = 10 * 60 * 1000;
const MAX_SYNC_ROOTS = 25;
export function normalizeGitHubRepoFullName(value: string) {
return normalizeGitHubRepo(value);
}
export function normalizeGitHubSyncRef(value: string | undefined | null, defaultBranch: string) {
const raw = value?.trim() || defaultBranch.trim();
if (!raw) throw new ConvexError("Sync ref is required");
return raw.replace(/^refs\/heads\//, "");
}
export function normalizeGitHubSyncRoots(roots: string[] | undefined | null) {
const normalized = new Set<string>();
for (const root of roots?.length ? roots : [""]) {
const trimmed = root.trim();
if (!trimmed) {
normalized.add("");
continue;
}
const value = normalizeRepoPath(root);
if (!value) throw new ConvexError("Invalid sync root");
normalized.add(value);
if (normalized.size > MAX_SYNC_ROOTS) throw new ConvexError("Too many sync roots");
}
return Array.from(normalized).sort((a, b) => a.localeCompare(b));
}
export function isPathUnderAnyRoot(path: string, roots: string[]) {
const normalizedPath = normalizeRepoPath(path);
const normalizedRoots = normalizeGitHubSyncRoots(roots);
if (normalizedRoots.includes("")) return true;
return normalizedRoots.some((root) => isRepoPathUnderRoot(normalizedPath, root));
}
export function deriveSlugFromCandidatePath(candidatePath: string, repoFullName: string) {
const repoName = repoFullName.split("/").at(1) ?? repoFullName;
const base = candidatePath ? (candidatePath.split("/").at(-1) ?? candidatePath) : repoName;
return normalizeSkillSlug(
base
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
.replace(/--+/g, "-"),
);
}
export function sourceLinkMatchesProvenance(params: {
link: { repoFullName: string; path: string; status: string };
sourceProvenance?: { kind: "github"; repo: string; path?: string } | null;
sourceSync?: { sourceLinkId: string } | null;
expectedSourceLinkId: string;
}) {
if (params.link.status === "disabled") return false;
if (params.sourceSync?.sourceLinkId !== params.expectedSourceLinkId) return false;
const provenance = params.sourceProvenance;
if (!provenance || provenance.kind !== "github") return false;
return (
normalizeGitHubRepoFullName(provenance.repo)?.toLowerCase() ===
normalizeGitHubRepoFullName(params.link.repoFullName)?.toLowerCase() &&
normalizeRepoPath(provenance.path ?? "") === normalizeRepoPath(params.link.path)
);
}
export async function signGitHubAppState(
payload: Omit<GitHubAppStatePayload, "exp"> & { exp?: number },
secret: string,
now = Date.now(),
) {
const exp = payload.exp ?? now + DEFAULT_SETUP_STATE_TTL_MS;
const body = base64UrlEncode(
new TextEncoder().encode(
JSON.stringify({
publisherId: payload.publisherId,
requestedByUserId: payload.requestedByUserId,
nonce: payload.nonce,
targetAccountId: payload.targetAccountId,
exp,
} satisfies GitHubAppStatePayload),
),
);
const signature = await hmacSha256Base64Url(secret, body);
return `${body}.${signature}`;
}
export async function verifyGitHubAppState(
state: string,
secret: string,
now = Date.now(),
): Promise<GitHubAppStatePayload> {
const [body, signature, extra] = state.split(".");
if (!body || !signature || extra) throw new ConvexError("Invalid GitHub setup state");
const expected = await hmacSha256Base64Url(secret, body);
if (!timingSafeEqual(signature, expected)) throw new ConvexError("Invalid GitHub setup state");
let parsed: unknown;
try {
parsed = JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));
} catch {
throw new ConvexError("Invalid GitHub setup state");
}
const payload = parsed as Partial<GitHubAppStatePayload>;
if (
typeof payload.publisherId !== "string" ||
typeof payload.requestedByUserId !== "string" ||
typeof payload.nonce !== "string" ||
(payload.targetAccountId !== undefined && typeof payload.targetAccountId !== "string") ||
typeof payload.exp !== "number"
) {
throw new ConvexError("Invalid GitHub setup state");
}
if (payload.exp < now) throw new ConvexError("GitHub setup state expired");
return {
publisherId: payload.publisherId,
requestedByUserId: payload.requestedByUserId,
nonce: payload.nonce,
targetAccountId: payload.targetAccountId,
exp: payload.exp,
};
}
export async function hashGitHubAppState(state: string) {
return sha256Hex(state);
}
export async function verifyGitHubWebhookSignature(params: {
body: ArrayBuffer;
signatureHeader: string | null;
secret: string | undefined;
}): Promise<GitHubWebhookVerificationResult> {
const secret = params.secret?.trim();
if (!secret) return { ok: false, reason: "missing-secret" };
const signature = params.signatureHeader?.trim();
if (!signature) return { ok: false, reason: "missing-signature" };
if (!signature.startsWith("sha256=")) return { ok: false, reason: "malformed-signature" };
const expected = `sha256=${await hmacSha256Hex(secret, params.body)}`;
if (!timingSafeEqual(signature, expected)) return { ok: false, reason: "bad-signature" };
return { ok: true };
}
export function buildGitHubAppInstallUrl(params: {
appSlug: string;
state: string;
targetId?: string;
}) {
const url = new URL(`https://github.com/apps/${params.appSlug}/installations/new`);
url.searchParams.set("state", params.state);
if (params.targetId) url.searchParams.set("target_id", params.targetId);
return url.toString();
}
+204
View File
@@ -0,0 +1,204 @@
const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]);
export function normalizeGitHubRepo(value: string) {
const trimmed = value
.trim()
.replace(/^git\+/, "")
.replace(/\.git$/i, "")
.replace(/^git@github\.com:/i, "https://github.com/");
if (!trimmed) return null;
const shorthand = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(trimmed);
if (shorthand) return `${shorthand[1]}/${shorthand[2]}`;
try {
const url = new URL(trimmed);
if (!GITHUB_HOSTS.has(url.hostname)) return null;
const segments = decodePathSegments(url.pathname);
const owner = segments[0] ?? "";
const repo = (segments[1] ?? "").replace(/\.git$/i, "");
if (!owner || !repo) return null;
return `${owner}/${repo}`;
} catch {
return null;
}
}
export function isRepoPathUnderRoot(path: string, root: string) {
if (!root) return true;
return path === root || path.startsWith(`${root}/`);
}
export async function createGitHubAppJwt(params: {
appId: string;
privateKeyPem: string;
now?: number;
}) {
const nowSeconds = Math.floor((params.now ?? Date.now()) / 1000);
const header = base64UrlEncode(
new TextEncoder().encode(JSON.stringify({ alg: "RS256", typ: "JWT" })),
);
const payload = base64UrlEncode(
new TextEncoder().encode(
JSON.stringify({
iat: nowSeconds - 60,
exp: nowSeconds + 9 * 60,
iss: params.appId,
}),
),
);
const signingInput = `${header}.${payload}`;
const key = await importPrivateKey(params.privateKeyPem);
const signature = await crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
key,
new TextEncoder().encode(signingInput),
);
return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
}
export async function sha256Hex(value: string) {
return toHex(
new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))),
);
}
export async function hmacSha256Base64Url(secret: string, value: string) {
const digest = await hmacSha256(secret, new TextEncoder().encode(value));
return base64UrlEncode(new Uint8Array(digest));
}
export async function hmacSha256Hex(secret: string, value: ArrayBuffer) {
const digest = await hmacSha256(secret, value);
return toHex(new Uint8Array(digest));
}
export function timingSafeEqual(a: string, b: string) {
const aBytes = new TextEncoder().encode(a);
const bBytes = new TextEncoder().encode(b);
if (aBytes.length !== bBytes.length) return false;
let diff = 0;
for (let i = 0; i < aBytes.length; i += 1) {
diff |= (aBytes[i] ?? 0) ^ (bBytes[i] ?? 0);
}
return diff === 0;
}
export function base64UrlEncode(bytes: Uint8Array) {
return bytesToBase64(bytes).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
export function base64UrlDecode(value: string) {
const padded = value
.replaceAll("-", "+")
.replaceAll("_", "/")
.padEnd(Math.ceil(value.length / 4) * 4, "=");
return base64Decode(padded);
}
function decodePathSegments(pathname: string) {
return pathname
.split("/")
.map((segment) => segment.trim())
.filter(Boolean)
.map((segment) => {
try {
return decodeURIComponent(segment);
} catch {
return "";
}
})
.filter(Boolean);
}
async function importPrivateKey(privateKeyPem: string) {
const normalized = privateKeyPem.replace(/\\n/g, "\n").trim();
const pkcs8Match = /-----BEGIN PRIVATE KEY-----([\s\S]+?)-----END PRIVATE KEY-----/.exec(
normalized,
);
const pkcs1Match = /-----BEGIN RSA PRIVATE KEY-----([\s\S]+?)-----END RSA PRIVATE KEY-----/.exec(
normalized,
);
const der = pkcs8Match
? base64Decode(pkcs8Match[1]?.replace(/\s+/g, "") ?? "")
: pkcs1Match
? wrapPkcs1RsaPrivateKeyAsPkcs8(base64Decode(pkcs1Match[1]?.replace(/\s+/g, "") ?? ""))
: base64Decode(normalized.replace(/\s+/g, ""));
return await crypto.subtle.importKey(
"pkcs8",
toArrayBuffer(der),
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"],
);
}
function wrapPkcs1RsaPrivateKeyAsPkcs8(pkcs1Der: Uint8Array) {
const rsaEncryptionOid = new Uint8Array([
0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00,
]);
const version = new Uint8Array([0x02, 0x01, 0x00]);
const privateKey = derEncode(0x04, pkcs1Der);
return derEncode(0x30, concatBytes([version, rsaEncryptionOid, privateKey]));
}
function derEncode(tag: number, value: Uint8Array) {
return concatBytes([new Uint8Array([tag]), derLength(value.byteLength), value]);
}
function derLength(length: number) {
if (length < 0x80) return new Uint8Array([length]);
const bytes: number[] = [];
let remaining = length;
while (remaining > 0) {
bytes.unshift(remaining & 0xff);
remaining >>= 8;
}
return new Uint8Array([0x80 | bytes.length, ...bytes]);
}
function concatBytes(parts: Uint8Array[]) {
const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.byteLength;
}
return out;
}
async function hmacSha256(secret: string, value: ArrayBuffer | Uint8Array) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
return await crypto.subtle.sign("HMAC", key, toArrayBuffer(value));
}
function base64Decode(value: string) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function toHex(bytes: Uint8Array) {
let out = "";
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
return out;
}
function toArrayBuffer(value: ArrayBuffer | Uint8Array) {
if (value instanceof ArrayBuffer) return value;
return new Uint8Array(value).buffer as ArrayBuffer;
}
+82 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { canHealSkillOwnershipByGitHubProviderAccountId } from "./githubIdentity";
import type { Doc, Id } from "../_generated/dataModel";
import type { QueryCtx } from "../_generated/server";
import {
canHealSkillOwnershipByGitHubProviderAccountId,
getGitHubProviderAccountId,
} from "./githubIdentity";
describe("canHealSkillOwnershipByGitHubProviderAccountId", () => {
it("denies when either providerAccountId is missing", () => {
@@ -17,3 +22,79 @@ describe("canHealSkillOwnershipByGitHubProviderAccountId", () => {
expect(canHealSkillOwnershipByGitHubProviderAccountId("123", "123")).toBe(true);
});
});
describe("getGitHubProviderAccountId", () => {
const userId = "users:github-user" as Id<"users">;
it("returns null when the user has no GitHub auth account", async () => {
const ctx = createQueryCtx([]);
await expect(getGitHubProviderAccountId(ctx, userId)).resolves.toBeNull();
});
it("returns the providerAccountId for duplicate rows with the same GitHub identity", async () => {
const ctx = createQueryCtx([
createAuthAccount("authAccounts:first", "123"),
createAuthAccount("authAccounts:second", "123"),
]);
await expect(getGitHubProviderAccountId(ctx, userId)).resolves.toBe("123");
});
it("fails closed when duplicate rows disagree on the GitHub identity", async () => {
const ctx = createQueryCtx([
createAuthAccount("authAccounts:first", "123"),
createAuthAccount("authAccounts:second", "456"),
]);
await expect(getGitHubProviderAccountId(ctx, userId)).rejects.toThrow(
"Conflicting GitHub auth accounts for user users:github-user: [authAccounts:first, authAccounts:second]",
);
});
it("fails closed when duplicate rows exceed the bounded reconciliation window", async () => {
const ctx = createQueryCtx(
Array.from({ length: 11 }, (_, index) =>
createAuthAccount(`authAccounts:${index + 1}`, "123"),
),
);
await expect(getGitHubProviderAccountId(ctx, userId)).rejects.toThrow(
"Too many GitHub auth accounts for user users:github-user; manual reconciliation required: [authAccounts:1, authAccounts:2, authAccounts:3, authAccounts:4, authAccounts:5, authAccounts:6, authAccounts:7, authAccounts:8, authAccounts:9, authAccounts:10, authAccounts:11]",
);
});
function createAuthAccount(id: string, providerAccountId: string): Doc<"authAccounts"> {
return {
_id: id,
_creationTime: 1,
userId,
provider: "github",
providerAccountId,
} as unknown as Doc<"authAccounts">;
}
function createQueryCtx(accounts: Array<Doc<"authAccounts">>): Pick<QueryCtx, "db"> {
const builder = {
eq: () => builder,
};
const query = {
withIndex: (name: string, configure: (q: typeof builder) => typeof builder) => {
expect(name).toBe("userIdAndProvider");
configure(builder);
return {
take: async (limit: number) => accounts.slice(0, limit),
};
},
};
return {
db: {
query: (table: string) => {
expect(table).toBe("authAccounts");
return query;
},
},
} as unknown as Pick<QueryCtx, "db">;
}
});
+35 -4
View File
@@ -1,6 +1,8 @@
import type { Id } from "../_generated/dataModel";
import type { Doc, Id } from "../_generated/dataModel";
import type { QueryCtx } from "../_generated/server";
const MAX_GITHUB_AUTH_ACCOUNTS_PER_USER = 10;
export function canHealSkillOwnershipByGitHubProviderAccountId(
ownerProviderAccountId: string | null | undefined,
callerProviderAccountId: string | null | undefined,
@@ -14,9 +16,38 @@ export async function getGitHubProviderAccountId(
ctx: Pick<QueryCtx, "db">,
userId: Id<"users">,
): Promise<string | null> {
const account = await ctx.db
const accounts = await ctx.db
.query("authAccounts")
.withIndex("userIdAndProvider", (q) => q.eq("userId", userId).eq("provider", "github"))
.unique();
return account?.providerAccountId ?? null;
.take(MAX_GITHUB_AUTH_ACCOUNTS_PER_USER + 1);
if (accounts.length === 0) return null;
if (accounts.length > MAX_GITHUB_AUTH_ACCOUNTS_PER_USER) {
throw new Error(formatTooManyGitHubAuthAccountsError(userId, accounts));
}
const providerAccountId = accounts[0]?.providerAccountId;
if (
typeof providerAccountId !== "string" ||
accounts.some((account) => account.providerAccountId !== providerAccountId)
) {
throw new Error(formatConflictingGitHubAuthAccountsError(userId, accounts));
}
return providerAccountId;
}
function formatConflictingGitHubAuthAccountsError(
userId: Id<"users">,
accounts: Array<Doc<"authAccounts">>,
) {
const accountIds = accounts.map((account) => account._id).join(", ");
return `Conflicting GitHub auth accounts for user ${userId}: [${accountIds}]`;
}
function formatTooManyGitHubAuthAccountsError(
userId: Id<"users">,
accounts: Array<Doc<"authAccounts">>,
) {
const accountIds = accounts.map((account) => account._id).join(", ");
return `Too many GitHub auth accounts for user ${userId}; manual reconciliation required: [${accountIds}]`;
}
+97
View File
@@ -1,5 +1,6 @@
import type { Doc } from "../_generated/dataModel";
import type { MutationCtx, QueryCtx } from "../_generated/server";
import { isPackageBlockedFromPublic } from "./packageSecurity";
export const GLOBAL_STATS_KEY = "default";
@@ -8,6 +9,10 @@ type SkillVisibilityFields = Pick<
"softDeletedAt" | "moderationStatus" | "moderationFlags"
> &
Partial<Pick<Doc<"skills">, "moderationVerdict">>;
type PackageVisibilityFields = Pick<
Doc<"packageSearchDigest">,
"softDeletedAt" | "family" | "channel" | "scanStatus"
>;
type GlobalStatsReadCtx = Pick<MutationCtx | QueryCtx, "db">;
type GlobalStatsWriteCtx = Pick<MutationCtx, "db">;
@@ -32,6 +37,26 @@ export function getPublicSkillVisibilityDelta(
return afterPublic ? 1 : -1;
}
export function isPublicPluginDoc<T extends PackageVisibilityFields>(
pkg: T | null | undefined,
): pkg is T {
if (!pkg || pkg.softDeletedAt) return false;
if (pkg.family !== "code-plugin" && pkg.family !== "bundle-plugin") return false;
if (pkg.channel === "private") return false;
if (isPackageBlockedFromPublic(pkg.scanStatus)) return false;
return true;
}
export function getPublicPluginVisibilityDelta(
before: PackageVisibilityFields | null | undefined,
after: PackageVisibilityFields | null | undefined,
) {
const beforePublic = isPublicPluginDoc(before);
const afterPublic = isPublicPluginDoc(after);
if (beforePublic === afterPublic) return 0;
return afterPublic ? 1 : -1;
}
function getErrorMessage(error: unknown) {
if (typeof error === "string") return error;
if (error && typeof error === "object" && "message" in error) {
@@ -83,6 +108,34 @@ export async function setGlobalPublicSkillsCount(
}
}
export async function setGlobalPublicPluginsCount(
ctx: GlobalStatsWriteCtx,
count: number,
now = Date.now(),
) {
const normalizedCount = Math.max(0, Math.trunc(Number.isFinite(count) ? count : 0));
try {
const existing = await ctx.db
.query("globalStats")
.withIndex("by_key", (q) => q.eq("key", GLOBAL_STATS_KEY))
.unique();
if (existing) {
await ctx.db.patch(existing._id, { activePluginsCount: normalizedCount, updatedAt: now });
} else {
await ctx.db.insert("globalStats", {
key: GLOBAL_STATS_KEY,
activeSkillsCount: 0,
activePluginsCount: normalizedCount,
updatedAt: now,
});
}
} catch (error) {
if (isGlobalStatsStorageNotReadyError(error)) return;
throw error;
}
}
export async function adjustGlobalPublicSkillsCount(
ctx: GlobalStatsWriteCtx,
delta: number,
@@ -118,6 +171,37 @@ export async function adjustGlobalPublicSkillsCount(
await ctx.db.patch(existing._id, { activeSkillsCount: nextCount, updatedAt: now });
}
export async function adjustGlobalPublicPluginsCount(
ctx: GlobalStatsWriteCtx,
delta: number,
now = Date.now(),
) {
const normalizedDelta = Math.trunc(Number.isFinite(delta) ? delta : 0);
if (normalizedDelta === 0) return;
let existing:
| {
_id: Doc<"globalStats">["_id"];
activePluginsCount?: number;
}
| null
| undefined;
try {
existing = await ctx.db
.query("globalStats")
.withIndex("by_key", (q) => q.eq("key", GLOBAL_STATS_KEY))
.unique();
} catch (error) {
if (isGlobalStatsStorageNotReadyError(error)) return;
throw error;
}
if (!existing || existing.activePluginsCount === undefined) return;
const nextCount = Math.max(0, existing.activePluginsCount + normalizedDelta);
await ctx.db.patch(existing._id, { activePluginsCount: nextCount, updatedAt: now });
}
export async function readGlobalPublicSkillsCount(ctx: GlobalStatsReadCtx) {
try {
const stats = await ctx.db
@@ -130,3 +214,16 @@ export async function readGlobalPublicSkillsCount(ctx: GlobalStatsReadCtx) {
throw error;
}
}
export async function readGlobalPublicPluginsCount(ctx: GlobalStatsReadCtx) {
try {
const stats = await ctx.db
.query("globalStats")
.withIndex("by_key", (q) => q.eq("key", GLOBAL_STATS_KEY))
.unique();
return stats?.activePluginsCount ?? null;
} catch (error) {
if (isGlobalStatsStorageNotReadyError(error)) return null;
throw error;
}
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from "vitest";
import { deletePackageSearchDigests } from "./packageSearchDigest";
describe("packageSearchDigest", () => {
it("decrements the public plugin count when deleting a public plugin digest", async () => {
const patch = vi.fn();
const deleteDoc = vi.fn();
const packageDigest = {
_id: "packageSearchDigest:demo",
family: "code-plugin",
channel: "community",
scanStatus: "clean",
softDeletedAt: undefined,
};
const ctx = {
db: {
query: vi.fn((table: string) => {
if (table === "packageSearchDigest") {
return {
withIndex: vi.fn((_indexName, callback) => {
callback({ eq: vi.fn(() => ({})) });
return { unique: vi.fn().mockResolvedValue(packageDigest) };
}),
};
}
if (table === "globalStats") {
return {
withIndex: vi.fn((_indexName, callback) => {
callback({ eq: vi.fn(() => ({})) });
return {
unique: vi.fn().mockResolvedValue({
_id: "globalStats:default",
activePluginsCount: 5,
}),
};
}),
};
}
if (
table === "packageCapabilitySearchDigest" ||
table === "packagePluginCategorySearchDigest"
) {
return {
withIndex: vi.fn((_indexName, callback) => {
callback({ eq: vi.fn(() => ({})) });
return { collect: vi.fn().mockResolvedValue([]) };
}),
};
}
throw new Error(`Unexpected table ${table}`);
}),
patch,
delete: deleteDoc,
},
};
await deletePackageSearchDigests(ctx as never, "packages:demo" as never);
expect(patch).toHaveBeenCalledWith("globalStats:default", {
activePluginsCount: 4,
updatedAt: expect.any(Number),
});
expect(deleteDoc).toHaveBeenCalledWith("packageSearchDigest:demo");
});
it("does not initialize plugin counts from deltas before reconciliation", async () => {
const patch = vi.fn();
const deleteDoc = vi.fn();
const packageDigest = {
_id: "packageSearchDigest:demo",
family: "code-plugin",
channel: "community",
scanStatus: "clean",
softDeletedAt: undefined,
};
const ctx = {
db: {
query: vi.fn((table: string) => {
if (table === "packageSearchDigest") {
return {
withIndex: vi.fn((_indexName, callback) => {
callback({ eq: vi.fn(() => ({})) });
return { unique: vi.fn().mockResolvedValue(packageDigest) };
}),
};
}
if (table === "globalStats") {
return {
withIndex: vi.fn((_indexName, callback) => {
callback({ eq: vi.fn(() => ({})) });
return {
unique: vi.fn().mockResolvedValue({
_id: "globalStats:default",
activeSkillsCount: 26,
}),
};
}),
};
}
if (
table === "packageCapabilitySearchDigest" ||
table === "packagePluginCategorySearchDigest"
) {
return {
withIndex: vi.fn((_indexName, callback) => {
callback({ eq: vi.fn(() => ({})) });
return { collect: vi.fn().mockResolvedValue([]) };
}),
};
}
throw new Error(`Unexpected table ${table}`);
}),
patch,
delete: deleteDoc,
},
};
await deletePackageSearchDigests(ctx as never, "packages:demo" as never);
expect(patch).not.toHaveBeenCalled();
expect(deleteDoc).toHaveBeenCalledWith("packageSearchDigest:demo");
});
});
+8 -1
View File
@@ -1,6 +1,7 @@
import { derivePluginCategoryTags } from "clawhub-schema";
import type { Doc, Id } from "../_generated/dataModel";
import type { MutationCtx } from "../_generated/server";
import { adjustGlobalPublicPluginsCount, getPublicPluginVisibilityDelta } from "./globalStats";
function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
return Object.fromEntries(keys.map((key) => [key, obj[key]])) as Pick<T, K>;
@@ -128,16 +129,19 @@ export async function upsertPackageSearchDigest(
.withIndex("by_package", (q) => q.eq("packageId", fields.packageId))
.unique();
if (existing) {
const visibilityDelta = getPublicPluginVisibilityDelta(existing, fields);
if (hasDigestChanged(existing, fields)) {
await ctx.db.patch(existing._id, fields);
}
await syncPackageCapabilitySearchDigests(ctx, fields);
await syncPackagePluginCategorySearchDigests(ctx, fields);
await adjustGlobalPublicPluginsCount(ctx, visibilityDelta);
return;
}
await ctx.db.insert("packageSearchDigest", fields);
await syncPackageCapabilitySearchDigests(ctx, fields);
await syncPackagePluginCategorySearchDigests(ctx, fields);
await adjustGlobalPublicPluginsCount(ctx, getPublicPluginVisibilityDelta(null, fields));
}
async function syncPackageCapabilitySearchDigests(
@@ -216,7 +220,10 @@ export async function deletePackageSearchDigests(
.query("packageSearchDigest")
.withIndex("by_package", (q) => q.eq("packageId", packageId))
.unique();
if (existing) await ctx.db.delete(existing._id);
if (existing) {
await adjustGlobalPublicPluginsCount(ctx, getPublicPluginVisibilityDelta(existing, null));
await ctx.db.delete(existing._id);
}
for (const row of await ctx.db
.query("packageCapabilitySearchDigest")
.withIndex("by_package", (q) => q.eq("packageId", packageId))
+4
View File
@@ -7,6 +7,10 @@ export type PublisherRole = "owner" | "admin" | "publisher";
type DbCtx = Pick<QueryCtx | MutationCtx, "db">;
export const PUBLISHER_HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,38}[a-z0-9])?$/;
export const PUBLISHER_HANDLE_REQUIREMENTS_MESSAGE =
"Handle must be 40 characters or fewer, start and end with a lowercase letter or number, and use only lowercase letters, numbers, hyphens, dots, or underscores";
type PersonalPublisherAuditOptions = {
actorUserId?: Id<"users">;
source: string;
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { compareRecommendationStats, computeRecommendationScore } from "./recommendationScore";
describe("recommendationScore", () => {
it("lets high usage outrank small one-off engagement", () => {
expect(
compareRecommendationStats(
{ downloads: 1, installs: 0, stars: 1 },
{ downloads: 43_080, installs: 2, stars: 0 },
),
).toBeGreaterThan(0);
});
it("lets strong download signal beat smaller seeded engagement", () => {
expect(
compareRecommendationStats(
{ downloads: 358, installs: 78, stars: 58 },
{ downloads: 43_080, installs: 2, stars: 0 },
),
).toBeGreaterThan(0);
});
it("lets stars contribute without becoming absolute precedence", () => {
const starred = computeRecommendationScore({ downloads: 100, installs: 5, stars: 5 });
const unstarred = computeRecommendationScore({ downloads: 100, installs: 5, stars: 0 });
expect(starred).toBeGreaterThan(unstarred);
});
it("weights installs more strongly than downloads", () => {
const installLed = computeRecommendationScore({ downloads: 0, installs: 10, stars: 0 });
const downloadLed = computeRecommendationScore({ downloads: 10, installs: 0, stars: 0 });
expect(installLed).toBeGreaterThan(downloadLed);
});
it("compresses large raw counts sublinearly", () => {
const firstThousand = computeRecommendationScore({ downloads: 1_000, installs: 0, stars: 0 });
const secondThousand = computeRecommendationScore({
downloads: 2_000,
installs: 0,
stars: 0,
});
expect(secondThousand - firstThousand).toBeLessThan(firstThousand);
});
});
+28
View File
@@ -0,0 +1,28 @@
export type RecommendationStats = {
downloads: number;
installs: number;
stars: number;
};
const DOWNLOAD_WEIGHT = 100;
const INSTALL_WEIGHT = 160;
const STAR_WEIGHT = 120;
// Bump this when changing weights, then run statsMaintenance:runRecommendationScoreBackfillInternal.
export const RECOMMENDATION_SCORE_VERSION = 3;
function safeCount(value: number) {
if (!Number.isFinite(value) || value <= 0) return 0;
return value;
}
export function computeRecommendationScore(stats: RecommendationStats) {
const downloads = Math.sqrt(safeCount(stats.downloads)) * DOWNLOAD_WEIGHT;
const installs = Math.sqrt(safeCount(stats.installs)) * INSTALL_WEIGHT;
const stars = Math.sqrt(safeCount(stats.stars)) * STAR_WEIGHT;
return Math.round(downloads + installs + stars);
}
export function compareRecommendationStats(a: RecommendationStats, b: RecommendationStats) {
return computeRecommendationScore(b) - computeRecommendationScore(a);
}
+77 -17
View File
@@ -6,14 +6,25 @@ export type SkillFileModerationInfo = {
isHiddenByMod?: boolean | null;
isRemoved?: boolean | null;
sourceVersionId?: Id<"skillVersions"> | string | null;
overrideActive?: boolean | null;
verdict?: string | null;
};
type SkillVersionSecuritySource = {
_id: Id<"skillVersions"> | string;
type SkillVersionSecurityInfo = {
_id?: Id<"skillVersions"> | string;
vtAnalysis?: {
status?: string | null;
verdict?: string | null;
} | null;
llmAnalysis?: {
status?: string | null;
verdict?: string | null;
} | null;
softDeletedAt?: number | null;
};
type SkillVersionSecuritySource = SkillVersionSecurityInfo & {
_id: Id<"skillVersions"> | string;
};
type SkillModerationSource = {
@@ -22,6 +33,7 @@ type SkillModerationSource = {
moderationFlags?: string[] | null;
moderationVerdict?: string | null;
moderationSourceVersionId?: Id<"skillVersions"> | string | null;
manualOverride?: boolean | null;
};
type SkillFileAccessBlock = {
@@ -39,15 +51,6 @@ function isPendingSkillModerationReason(reason: string | null | undefined) {
);
}
function normalizeSkillScanStatus(status: string | null | undefined) {
const normalized = status?.trim().toLowerCase();
if (normalized === "benign") return "clean";
if (normalized === "clean" || normalized === "suspicious" || normalized === "malicious") {
return normalized;
}
return null;
}
export function getSkillFileModerationInfoFromSkill(
skill: SkillModerationSource,
): SkillFileModerationInfo {
@@ -62,6 +65,8 @@ export function getSkillFileModerationInfoFromSkill(
isHiddenByMod: skill.moderationStatus === "hidden" && !isPendingScan && !isMalwareBlocked,
isRemoved: skill.moderationStatus === "removed",
sourceVersionId: skill.moderationSourceVersionId ?? null,
overrideActive: Boolean(skill.manualOverride),
verdict: skill.moderationVerdict ?? null,
};
}
@@ -104,6 +109,23 @@ export function getPublicSkillVersionAccessBlock(
return moderatedVersionId === versionId ? block : null;
}
export function getPublicSkillVersionFileAccessBlock(
version: SkillVersionSecurityInfo | null | undefined,
moderationInfo?: SkillFileModerationInfo | null,
fallbackModeratedVersionId?: Id<"skillVersions"> | string | null,
): SkillFileAccessBlock | null {
if (version?._id) {
const moderationBlock = getPublicSkillVersionAccessBlock(
moderationInfo,
version._id,
fallbackModeratedVersionId,
);
if (moderationBlock) return moderationBlock;
}
return getVersionSecurityAccessBlock(version, "served");
}
export function getPublicSkillVersionDownloadBlock(
moderationInfo: SkillFileModerationInfo | null | undefined,
version: SkillVersionSecuritySource,
@@ -116,17 +138,30 @@ export function getPublicSkillVersionDownloadBlock(
);
if (moderationBlock) return moderationBlock;
const scanStatus = normalizeSkillScanStatus(
version.llmAnalysis?.verdict ?? version.llmAnalysis?.status,
);
if (scanStatus === "malicious") {
return getVersionSecurityAccessBlock(version, "downloaded");
}
function getVersionSecurityAccessBlock(
version: SkillVersionSecurityInfo | null | undefined,
action: "downloaded" | "served" = "served",
): SkillFileAccessBlock | null {
if (version?.softDeletedAt) {
return { status: 410, message: "Version not available" };
}
if (hasVersionSecurityStatus(version, "malicious")) {
return {
status: 403,
message:
"Blocked: this skill version has been flagged as malicious by ClawScan and cannot be downloaded.",
`Blocked: this skill version has been flagged as malicious by ClawScan and cannot be ${action}.`,
};
}
if (hasVersionSecurityStatus(version, "pending")) {
return {
status: 423,
message:
"This skill version is pending a ClawScan security review. Please try again in a few minutes.",
};
}
return null;
}
@@ -149,3 +184,28 @@ export function isPublicSkillVersionAvailableForSkill(
) {
return Boolean(version && !version.softDeletedAt && isSkillVersionForSkill(version, skillId));
}
function hasVersionSecurityStatus(
version: SkillVersionSecurityInfo | null | undefined,
status: "malicious" | "pending",
) {
if (!version) return false;
return [
version.vtAnalysis?.verdict,
version.vtAnalysis?.status,
version.llmAnalysis?.verdict,
version.llmAnalysis?.status,
].some((value) => normalizeVersionSecurityStatus(value) === status);
}
function normalizeVersionSecurityStatus(value: string | null | undefined) {
switch (value?.trim().toLowerCase()) {
case "malicious":
return "malicious";
case "pending":
case "loading":
return "pending";
default:
return null;
}
}
+6
View File
@@ -88,6 +88,11 @@ export type PublishOptions = {
skipWebhook?: boolean;
ownerPublisherId?: Id<"publishers">;
sourceProvenance?: PublishVersionArgs["source"];
sourceSync?: {
sourceLinkId: Id<"skillSourceLinks">;
repositoryId: Id<"publisherGitHubRepositories">;
syncJobId?: Id<"githubSkillSyncJobs">;
};
// Explicit opt-in to owner migration. The `insertVersion` mutation refuses
// to rewrite a skill's `ownerPublisherId` unless this is `true`, so default
// publishes (including older CLIs that never pass this flag) can never
@@ -321,6 +326,7 @@ export async function publishVersionForUser(
changelog: changelogText,
changelogSource,
sourceProvenance: options.sourceProvenance,
sourceSync: options.sourceSync,
tags: args.tags?.map((tag) => tag.trim()).filter(Boolean),
fingerprint,
forkOf: args.forkOf
+9
View File
@@ -2,6 +2,7 @@
import { describe, expect, it } from "vitest";
import { toPublicSkill } from "./public";
import { computeRecommendationScore, RECOMMENDATION_SCORE_VERSION } from "./recommendationScore";
import {
digestToHydratableSkill,
extractDigestFields,
@@ -82,6 +83,10 @@ describe("extractDigestFields", () => {
expect(digest.statsStars).toBe(5);
expect(digest.statsInstallsCurrent).toBe(10);
expect(digest.statsInstallsAllTime).toBe(100);
expect(digest.recommendedScore).toBe(
computeRecommendationScore({ downloads: 42, installs: 100, stars: 5 }),
);
expect(digest.recommendedScoreVersion).toBe(RECOMMENDATION_SCORE_VERSION);
expect(digest.stats).toEqual({
downloads: 42,
installsCurrent: 10,
@@ -118,6 +123,10 @@ describe("extractDigestFields", () => {
expect(digest.statsStars).toBe(5);
expect(digest.statsInstallsCurrent).toBe(10);
expect(digest.statsInstallsAllTime).toBe(100);
expect(digest.recommendedScore).toBe(
computeRecommendationScore({ downloads: 42, installs: 100, stars: 5 }),
);
expect(digest.recommendedScoreVersion).toBe(RECOMMENDATION_SCORE_VERSION);
});
it("omits large fields not needed for search", () => {
+17 -4
View File
@@ -2,6 +2,7 @@ import type { Doc, Id } from "../_generated/dataModel";
import type { MutationCtx } from "../_generated/server";
import type { HydratableSkill, PublicPublisher } from "./public";
import { getOwnerPublisher } from "./publishers";
import { computeRecommendationScore, RECOMMENDATION_SCORE_VERSION } from "./recommendationScore";
import { tokenize } from "./searchText";
import { readCanonicalStat } from "./skillStats";
@@ -62,16 +63,28 @@ export type SkillSearchDigestFields = Pick<Doc<"skills">, (typeof SHARED_KEYS)[n
ownerName?: string;
ownerDisplayName?: string;
ownerImage?: string;
recommendedScore?: number;
recommendedScoreVersion?: number;
};
/** Pick the subset of fields from a full skill doc needed for the digest. */
export function extractDigestFields(skill: Doc<"skills">): SkillSearchDigestFields {
const statsDownloads = readCanonicalStat(skill, "downloads");
const statsStars = readCanonicalStat(skill, "stars");
const statsInstallsCurrent = readCanonicalStat(skill, "installsCurrent");
const statsInstallsAllTime = readCanonicalStat(skill, "installsAllTime");
return {
...pick(skill, [...SHARED_KEYS]),
statsDownloads: readCanonicalStat(skill, "downloads"),
statsStars: readCanonicalStat(skill, "stars"),
statsInstallsCurrent: readCanonicalStat(skill, "installsCurrent"),
statsInstallsAllTime: readCanonicalStat(skill, "installsAllTime"),
statsDownloads,
statsStars,
statsInstallsCurrent,
statsInstallsAllTime,
recommendedScore: computeRecommendationScore({
downloads: statsDownloads,
installs: statsInstallsAllTime,
stars: statsStars,
}),
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
skillId: skill._id,
normalizedSlug: normalizeSkillSearchText(skill.slug),
normalizedSlugFirstToken: getFirstSearchToken(skill.slug),
+3
View File
@@ -705,6 +705,7 @@ describe("maintenance backfill", () => {
changelog: "Same changelog",
changelogSource: "user",
clawdis: undefined,
apiKeyRequired: true,
},
},
],
@@ -718,6 +719,7 @@ describe("maintenance backfill", () => {
changelog: "Same changelog",
changelogSource: "auto",
parsed: { clawdis: { emoji: "lobster" } },
apiKeyRequired: true,
});
const patch = vi.fn().mockResolvedValue(undefined);
const runAfter = vi.fn();
@@ -749,6 +751,7 @@ describe("maintenance backfill", () => {
changelog: "Same changelog",
changelogSource: "auto",
clawdis: { emoji: "lobster" },
apiKeyRequired: true,
},
});
expect(runAfter).not.toHaveBeenCalled();
+7 -1
View File
@@ -22,7 +22,7 @@ import {
getTrustTier,
type TrustTier,
} from "./lib/skillQuality";
import { hashSkillFiles, isTextFile } from "./lib/skills";
import { getFrontmatterValue, hashSkillFiles, isTextFile } from "./lib/skills";
import { computeIsSuspicious } from "./lib/skillSafety";
import { generateSkillSummary } from "./lib/skillSummary";
@@ -2106,7 +2106,11 @@ export const backfillLatestVersionSummaryInternal = internalMutation({
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource,
description: version.parsed?.frontmatter
? getFrontmatterValue(version.parsed.frontmatter, "description")?.trim() || undefined
: undefined,
clawdis: version.parsed?.clawdis,
apiKeyRequired: version.apiKeyRequired,
};
// Skip if already in sync
@@ -2117,6 +2121,8 @@ export const backfillLatestVersionSummaryInternal = internalMutation({
existing.createdAt === expected.createdAt &&
existing.changelog === expected.changelog &&
existing.changelogSource === expected.changelogSource &&
existing.description === expected.description &&
existing.apiKeyRequired === expected.apiKeyRequired &&
JSON.stringify(existing.clawdis ?? null) === JSON.stringify(expected.clawdis ?? null)
) {
continue;
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
authTables: {},
}));
import { countPublicPlugins, countPublicPluginsInternal } from "./packages";
type WrappedHandler<TArgs, TResult> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
const countPublicPluginsHandler = (
countPublicPluginsInternal as unknown as WrappedHandler<Record<string, never>, number | null>
)._handler;
const countPublicPluginsPublicHandler = (
countPublicPlugins as unknown as WrappedHandler<Record<string, never>, number>
)._handler;
function makeCtx(globalStats: { activePluginsCount?: number } | null) {
return {
db: {
query: vi.fn((table: string) => {
if (table === "globalStats") {
return {
withIndex: () => ({
unique: async () => globalStats,
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
}
describe("packages.countPublicPluginsInternal", () => {
it("returns the precomputed global plugin count when available", async () => {
const result = await countPublicPluginsHandler(makeCtx({ activePluginsCount: 251 }), {});
expect(result).toBe(251);
});
it("returns null when the global stats row predates plugin counts", async () => {
const result = await countPublicPluginsHandler(makeCtx({}), {});
expect(result).toBeNull();
});
});
describe("packages.countPublicPlugins", () => {
it("returns the precomputed global plugin count when available", async () => {
const result = await countPublicPluginsPublicHandler(makeCtx({ activePluginsCount: 251 }), {});
expect(result).toBe(251);
});
it("returns zero when the global stats row predates plugin counts", async () => {
const result = await countPublicPluginsPublicHandler(makeCtx({}), {});
expect(result).toBe(0);
});
});
+382 -17
View File
@@ -3,6 +3,10 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MAX_PUBLISH_FILE_BYTES } from "./lib/publishLimits";
import {
computeRecommendationScore,
RECOMMENDATION_SCORE_VERSION,
} from "./lib/recommendationScore";
import {
backfillLatestPackageScanStatusInternal,
backfillPackageReleaseScansInternal,
@@ -131,7 +135,7 @@ const listPublicPageHandler = (
executesCode?: boolean;
capabilityTag?: string;
category?: string;
sort?: "updated" | "downloads";
sort?: "updated" | "downloads" | "recommended";
paginationOpts: { cursor: string | null; numItems: number };
},
{ page: Array<{ name: string }>; isDone: boolean; continueCursor: string }
@@ -146,7 +150,7 @@ const listPageForViewerInternalHandler = (
executesCode?: boolean;
capabilityTag?: string;
category?: string;
sort?: "updated" | "downloads";
sort?: "updated" | "downloads" | "recommended";
viewerUserId?: string;
paginationOpts: { cursor: string | null; numItems: number };
},
@@ -919,6 +923,13 @@ function makePackageDoc(overrides: Partial<Record<string, unknown>> = {}) {
};
}
function readTestField(row: Record<string, unknown>, field: string): unknown {
return field.split(".").reduce<unknown>((current, key) => {
if (typeof current !== "object" || current === null || Array.isArray(current)) return undefined;
return (current as Record<string, unknown>)[key];
}, row);
}
function makeReleaseDoc(overrides: Partial<Record<string, unknown>> = {}) {
return {
_id: "packageReleases:demo-1",
@@ -973,7 +984,7 @@ function makeDigestCtx(options: {
const indexNames: string[] = [];
const indexFilters: Array<{
indexName: string;
filters: Array<{ field: string; value: string | undefined }>;
filters: Array<{ field: string; value: unknown }>;
}> = [];
const tableNames: string[] = [];
@@ -1049,6 +1060,7 @@ function makeDigestCtx(options: {
indexNames.push(indexName);
let ordered = false;
return {
first: vi.fn(async () => null),
order: vi.fn(() => {
if (ordered) throw new Error("query builder reused after iteration");
ordered = true;
@@ -1082,7 +1094,7 @@ function makeDigestCtx(options: {
(
indexName: string,
builder?: (q: {
eq: (field: string, value: string | undefined) => unknown;
eq: (field: string, value: unknown) => unknown;
gte: (field: string, value: string) => unknown;
lt: (field: string, value: string) => unknown;
}) => unknown,
@@ -1090,29 +1102,54 @@ function makeDigestCtx(options: {
let matchedValue = "";
let lowerBound = "";
let upperBound = "";
const filters: Array<{ field: string; value: string | undefined }> = [];
const filters: Array<{ field: string; value: unknown }> = [];
const rangeFilters: Array<{ field: string; value: number }> = [];
const queryBuilder = {
eq: (field: string, value: string | undefined) => {
eq: (field: string, value: unknown) => {
filters.push({ field, value });
matchedValue = value ?? "";
matchedValue = typeof value === "string" ? value : "";
return queryBuilder;
},
gte: (_field: string, value: string) => {
lowerBound = value;
return queryBuilder;
},
lt: (_field: string, value: string) => {
upperBound = value;
lt: (field: string, value: string | number) => {
upperBound = typeof value === "string" ? value : "";
if (typeof value === "number") {
rangeFilters.push({ field, value });
}
return queryBuilder;
},
};
builder?.(queryBuilder);
if (
indexName === "by_active_downloads" ||
indexName === "by_active_family_downloads"
indexName === "by_active_family_downloads" ||
indexName === "by_active_recommended_rank" ||
indexName === "by_active_family_recommended_rank" ||
indexName === "by_active_recommended_score" ||
indexName === "by_active_family_recommended_score" ||
indexName === "by_active_recommended_score_version" ||
indexName === "by_active_family_recommended_score_version"
) {
indexFilters.push({ indexName, filters });
return withIndex(table, indexName);
const indexedQuery = withIndex(table, indexName);
return {
...indexedQuery,
first: vi.fn().mockResolvedValue(
(rowsByTable.get(table) ?? []).find(
(row) =>
filters.every(
({ field, value }) => readTestField(row, field) === value,
) &&
rangeFilters.every(({ field, value }) => {
const current = readTestField(row, field);
return typeof current === "number" && current < value;
}),
) ?? null,
),
};
}
if (indexName !== "by_name" && indexName !== "by_runtime_id") {
throw new Error(`Unexpected packages index ${indexName}`);
@@ -2265,6 +2302,335 @@ describe("packages public queries", () => {
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: [
{
page: [
makePackageDoc({
_id: "packages:code-plugin-downloaded",
name: "code-plugin-downloaded",
normalizedName: "code-plugin-downloaded",
displayName: "Code Plugin Downloaded",
family: "code-plugin",
stats: { downloads: 43_080, installs: 2, stars: 0, versions: 1 },
recommendedScore: computeRecommendationScore({
downloads: 43_080,
installs: 2,
stars: 0,
}),
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
}),
makePackageDoc({
_id: "packages:code-plugin-installed",
name: "code-plugin-installed",
normalizedName: "code-plugin-installed",
displayName: "Code Plugin Installed",
family: "code-plugin",
stats: { downloads: 393, installs: 74, stars: 0, versions: 1 },
recommendedScore: computeRecommendationScore({
downloads: 393,
installs: 74,
stars: 0,
}),
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
}),
],
isDone: true,
continueCursor: "",
},
],
});
const result = await listPublicPageHandler(ctx, {
family: "code-plugin",
sort: "recommended",
paginationOpts: { cursor: null, numItems: 1 },
});
expect(result.page.map((entry) => entry.name)).toEqual(["code-plugin-downloaded"]);
expect(result.isDone).toBe(false);
expect(result.continueCursor.startsWith("pkgpage:")).toBe(true);
expect(indexNames).toEqual([
"by_active_family_recommended_score",
"by_active_family_recommended_score_version",
"by_active_family_recommended_score_version",
"by_active_family_recommended_score",
]);
expect(indexFilters).toEqual([
{
indexName: "by_active_family_recommended_score",
filters: [
{ field: "softDeletedAt", value: undefined },
{ field: "family", value: "code-plugin" },
{ field: "recommendedScore", value: undefined },
],
},
{
indexName: "by_active_family_recommended_score_version",
filters: [
{ field: "softDeletedAt", value: undefined },
{ field: "family", value: "code-plugin" },
{ field: "recommendedScoreVersion", value: undefined },
],
},
{
indexName: "by_active_family_recommended_score_version",
filters: [
{ field: "softDeletedAt", value: undefined },
{ field: "family", value: "code-plugin" },
],
},
{
indexName: "by_active_family_recommended_score",
filters: [
{ field: "softDeletedAt", value: undefined },
{ field: "family", value: "code-plugin" },
],
},
]);
expect(paginate).toHaveBeenCalledTimes(1);
expect(paginate).toHaveBeenCalledWith({ cursor: null, numItems: 50 });
});
it("falls back to updated family digests while recommendation scores are missing", async () => {
const { ctx, indexFilters, indexNames } = makeDigestCtx({
pages: [
{
page: [
makeDigest("code-plugin-downloaded", {
packageId: "packages:code-plugin-downloaded",
displayName: "Code Plugin Downloaded",
family: "code-plugin",
}),
],
isDone: true,
continueCursor: "",
},
],
packagePages: [
{
page: [
makePackageDoc({
_id: "packages:code-plugin-downloaded",
name: "code-plugin-downloaded",
normalizedName: "code-plugin-downloaded",
displayName: "Code Plugin Downloaded",
family: "code-plugin",
stats: { downloads: 43_080, installs: 2, stars: 0, versions: 1 },
}),
],
isDone: true,
continueCursor: "",
},
],
});
await listPublicPageHandler(ctx, {
family: "code-plugin",
sort: "recommended",
paginationOpts: { cursor: null, numItems: 1 },
});
expect(indexNames).toEqual(["by_active_family_recommended_score", "by_active_family_updated"]);
expect(indexFilters).toEqual([
{
indexName: "by_active_family_recommended_score",
filters: [
{ field: "softDeletedAt", value: undefined },
{ field: "family", value: "code-plugin" },
{ field: "recommendedScore", value: undefined },
],
},
]);
});
it("falls back to updated family digests while recommendation score versions are missing", async () => {
const { ctx, indexFilters, indexNames } = makeDigestCtx({
pages: [
{
page: [
makeDigest("code-plugin-updated", {
packageId: "packages:code-plugin-updated",
displayName: "Code Plugin Updated",
family: "code-plugin",
}),
],
isDone: true,
continueCursor: "",
},
],
packagePages: [
{
page: [
makePackageDoc({
_id: "packages:code-plugin-stale-score",
name: "code-plugin-stale-score",
normalizedName: "code-plugin-stale-score",
displayName: "Code Plugin Stale Score",
family: "code-plugin",
stats: { downloads: 43_080, installs: 2, stars: 0, versions: 1 },
recommendedScore: computeRecommendationScore({
downloads: 43_080,
installs: 2,
stars: 0,
}),
}),
],
isDone: true,
continueCursor: "",
},
],
});
await listPublicPageHandler(ctx, {
family: "code-plugin",
sort: "recommended",
paginationOpts: { cursor: null, numItems: 1 },
});
expect(indexNames).toEqual([
"by_active_family_recommended_score",
"by_active_family_recommended_score_version",
"by_active_family_updated",
]);
expect(indexFilters).toEqual([
{
indexName: "by_active_family_recommended_score",
filters: [
{ field: "softDeletedAt", value: undefined },
{ field: "family", value: "code-plugin" },
{ field: "recommendedScore", value: undefined },
],
},
{
indexName: "by_active_family_recommended_score_version",
filters: [
{ field: "softDeletedAt", value: undefined },
{ field: "family", value: "code-plugin" },
{ field: "recommendedScoreVersion", value: undefined },
],
},
]);
});
it("keeps legacy recommended package cursors on the recommended score index", async () => {
const legacyCursor = `pkgpage:${JSON.stringify({
cursor: "legacy-recommended-next",
offset: 0,
pageSize: 50,
done: false,
})}`;
const { ctx, indexFilters, indexNames } = makeDigestCtx({
packagePages: [
{
page: [],
isDone: false,
continueCursor: "legacy-recommended-next",
},
{
page: [
makePackageDoc({
_id: "packages:code-plugin-next",
name: "code-plugin-next",
normalizedName: "code-plugin-next",
displayName: "Code Plugin Next",
family: "code-plugin",
stats: { downloads: 10, installs: 1, stars: 0, versions: 1 },
}),
],
isDone: true,
continueCursor: "",
},
],
});
const result = await listPublicPageHandler(ctx, {
family: "code-plugin",
sort: "recommended",
paginationOpts: { cursor: legacyCursor, numItems: 1 },
});
expect(result.page.map((entry) => entry.name)).toEqual(["code-plugin-next"]);
expect(indexNames).toEqual(["by_active_family_recommended_score"]);
expect(indexFilters).toEqual([
{
indexName: "by_active_family_recommended_score",
filters: [
{ field: "softDeletedAt", value: undefined },
{ field: "family", value: "code-plugin" },
],
},
]);
});
it("keeps recommended digest fallback cursors on the digest path after backfill", async () => {
const fallbackCursor = `pkgpage:${JSON.stringify({
cursor: "digest-next",
offset: 0,
pageSize: 50,
done: false,
mode: "digest",
})}`;
const { ctx, indexFilters, indexNames } = makeDigestCtx({
pages: [
{
page: [
makeDigest("code-plugin-first", {
packageId: "packages:code-plugin-first",
displayName: "Code Plugin First",
family: "code-plugin",
}),
],
isDone: false,
continueCursor: "digest-next",
},
{
page: [
makeDigest("code-plugin-second", {
packageId: "packages:code-plugin-second",
displayName: "Code Plugin Second",
family: "code-plugin",
}),
],
isDone: true,
continueCursor: "",
},
],
packagePages: [
{
page: [
makePackageDoc({
_id: "packages:code-plugin-second",
name: "code-plugin-second",
normalizedName: "code-plugin-second",
displayName: "Code Plugin Second",
family: "code-plugin",
stats: { downloads: 1, installs: 1, stars: 0, versions: 1 },
recommendedScore: computeRecommendationScore({
downloads: 1,
installs: 1,
stars: 0,
}),
}),
],
isDone: true,
continueCursor: "",
},
],
});
const result = await listPublicPageHandler(ctx, {
family: "code-plugin",
sort: "recommended",
paginationOpts: { cursor: fallbackCursor, numItems: 1 },
});
expect(result.page.map((entry) => entry.name)).toEqual(["code-plugin-second"]);
expect(indexNames).toEqual(["by_active_family_updated"]);
expect(indexFilters).toEqual([]);
});
it("continues scanning global download-sorted pages for non-indexed filters", async () => {
const { ctx, indexNames, paginate } = makeDigestCtx({
packagePages: [
@@ -4057,6 +4423,7 @@ describe("packages public queries", () => {
isOfficial: false,
tags: {},
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
recommendedScore: 0,
}),
);
expect(insert).not.toHaveBeenCalledWith("packageReleases", expect.anything());
@@ -7699,9 +8066,9 @@ describe("packages public queries", () => {
);
});
it("does not suggest publisher creation for package scopes that are invalid ClawHub handles", async () => {
it("suggests publisher creation for missing npm-compatible package scopes", async () => {
const runMutation = vi.fn(async () => {
throw new Error('Publisher "@foo.bar" not found');
throw new Error('Publisher "@example.tools" not found');
});
const ctx = {
runQuery: vi
@@ -7732,7 +8099,7 @@ describe("packages public queries", () => {
publishPackageForUserInternalHandler(ctx as never, {
actorUserId: "users:vincent",
payload: {
name: "@foo.bar/demo-plugin",
name: "@example.tools/demo-plugin",
displayName: "Demo",
family: "bundle-plugin",
version: "1.0.0",
@@ -7741,9 +8108,7 @@ describe("packages public queries", () => {
files: [],
},
}),
).rejects.toThrow(
'ClawHub publisher handles may only use lowercase letters, numbers, and hyphens. Rename package.json to a ClawHub-compatible scope, such as "@foo-bar/demo-plugin", then publish again.',
);
).rejects.toThrow('Create it with "clawhub publisher create example.tools".');
});
it("rejects scoped package publishes when --owner conflicts with the package scope", async () => {
+10
View File
@@ -1,6 +1,10 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import {
computeRecommendationScore,
RECOMMENDATION_SCORE_VERSION,
} from "./lib/recommendationScore";
import {
processPackageStatEventsInternal,
recordPackageDownloadInternal,
@@ -135,6 +139,12 @@ describe("package stat events", () => {
"packages:one",
expect.objectContaining({
stats: expect.objectContaining({ downloads: 11 }),
recommendedScore: computeRecommendationScore({
downloads: 11,
installs: 2,
stars: 2,
}),
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
}),
);
expect(patch).toHaveBeenCalledWith(
+188 -22
View File
@@ -49,6 +49,7 @@ import { sha256Hex } from "./lib/clawpack";
import { buildPackageInspectorFindingsEmail } from "./lib/emails";
import { requireGitHubAccountAge } from "./lib/githubAccount";
import { normalizeGitHubRepository } from "./lib/githubActionsOidc";
import { readGlobalPublicPluginsCount } from "./lib/globalStats";
import { isOfficialPublisher } from "./lib/officialPublishers";
import {
assertPackageVersion,
@@ -78,6 +79,7 @@ import {
getPublisherMembership,
isPublisherActive,
isPublisherRoleAllowed,
PUBLISHER_HANDLE_PATTERN,
normalizePublisherHandle,
} from "./lib/publishers";
import {
@@ -86,6 +88,10 @@ import {
getPublishTotalSizeError,
MAX_PUBLISH_TOTAL_BYTES,
} from "./lib/publishLimits";
import {
computeRecommendationScore,
RECOMMENDATION_SCORE_VERSION,
} from "./lib/recommendationScore";
import { MAX_ACTIVE_REPORTS_PER_USER, MAX_REPORT_REASON_LENGTH } from "./lib/reporting";
import { matchesAllTokens, matchesExploratoryTokenPrefixes, tokenize } from "./lib/searchText";
import { hashSkillFiles } from "./lib/skills";
@@ -101,7 +107,6 @@ const MAX_OFFICIAL_MIGRATION_BLOCKERS = 20;
const MAX_OFFICIAL_MIGRATION_FIELD_LENGTH = 300;
const MAX_OFFICIAL_MIGRATION_NOTES_LENGTH = 2_000;
const MAX_STORED_PACKAGE_METADATA_DEPTH = 10;
const CLAWHUB_PUBLISHER_HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
const REAL_BUNDLE_MANIFESTS = [
{ path: ".codex-plugin/plugin.json", format: "codex" },
{ path: ".claude-plugin/plugin.json", format: "claude" },
@@ -111,6 +116,95 @@ const INITIAL_PACKAGE_VT_SCAN_DELAY_MS = 30_000;
const PLUGIN_EXPORT_FAMILIES = ["code-plugin", "bundle-plugin"] as const;
const GET_PAGE_TIEBREAKER_FIELD_COUNT = 2;
function computePackageRecommendationScore(stats: Doc<"packages">["stats"]) {
return computeRecommendationScore({
downloads: stats.downloads,
installs: stats.installs,
stars: stats.stars,
});
}
function computePackageRecommendationPatch(stats: Doc<"packages">["stats"]) {
return {
recommendedScore: computePackageRecommendationScore(stats),
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
};
}
function getPackageRecommendedScoreIndexName(family: Doc<"packages">["family"] | undefined) {
return family ? "by_active_family_recommended_score" : "by_active_recommended_score";
}
async function getPackageRecommendedIndexName(
ctx: Pick<QueryCtx, "db">,
family: Doc<"packages">["family"] | undefined,
) {
const missingScore = await hasMissingPackageRecommendedScore(ctx, family);
if (missingScore) return null;
return getPackageRecommendedScoreIndexName(family);
}
async function hasMissingPackageRecommendedScore(
ctx: Pick<QueryCtx, "db">,
family: Doc<"packages">["family"] | undefined,
) {
if (family) {
const missingScore = await ctx.db
.query("packages")
.withIndex("by_active_family_recommended_score", (q) =>
q.eq("softDeletedAt", undefined).eq("family", family).eq("recommendedScore", undefined),
)
.first();
if (missingScore) return true;
const missingVersion = await ctx.db
.query("packages")
.withIndex("by_active_family_recommended_score_version", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("family", family)
.eq("recommendedScoreVersion", undefined),
)
.first();
if (missingVersion) return true;
const staleVersion = await ctx.db
.query("packages")
.withIndex("by_active_family_recommended_score_version", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("family", family)
.lt("recommendedScoreVersion", RECOMMENDATION_SCORE_VERSION),
)
.first();
return Boolean(staleVersion);
}
const missingScore = await ctx.db
.query("packages")
.withIndex("by_active_recommended_score", (q) =>
q.eq("softDeletedAt", undefined).eq("recommendedScore", undefined),
)
.first();
if (missingScore) return true;
const missingVersion = await ctx.db
.query("packages")
.withIndex("by_active_recommended_score_version", (q) =>
q.eq("softDeletedAt", undefined).eq("recommendedScoreVersion", undefined),
)
.first();
if (missingVersion) return true;
const staleVersion = await ctx.db
.query("packages")
.withIndex("by_active_recommended_score_version", (q) =>
q.eq("softDeletedAt", undefined).lt("recommendedScoreVersion", RECOMMENDATION_SCORE_VERSION),
)
.first();
return Boolean(staleVersion);
}
const llmAgenticRiskEvidenceValidator = v.object({
path: v.string(),
snippet: v.string(),
@@ -203,12 +297,12 @@ function getPackageSlugFromName(name: string) {
function getClawHubPublisherHandleSuggestion(handle: string) {
const suggestion = handle
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/^[._-]+|[._-]+$/g, "")
.slice(0, 40)
.replace(/-+$/g, "");
return CLAWHUB_PUBLISHER_HANDLE_PATTERN.test(suggestion) ? suggestion : null;
.replace(/[._-]+$/g, "");
return PUBLISHER_HANDLE_PATTERN.test(suggestion) ? suggestion : null;
}
function getScopedPackageMissingPublisherMessage(params: {
@@ -216,13 +310,13 @@ function getScopedPackageMissingPublisherMessage(params: {
packageName: string;
legacyPersonalOwnerHandle?: string;
}) {
if (!CLAWHUB_PUBLISHER_HANDLE_PATTERN.test(params.scopedOwnerHandle)) {
if (!PUBLISHER_HANDLE_PATTERN.test(params.scopedOwnerHandle)) {
const suggestedOwnerHandle = getClawHubPublisherHandleSuggestion(params.scopedOwnerHandle);
const packageSlug = getPackageSlugFromName(params.packageName);
const renameGuidance = suggestedOwnerHandle
? ` Rename package.json to a ClawHub-compatible scope, such as "@${suggestedOwnerHandle}/${packageSlug}", then publish again.`
: " Rename package.json to a ClawHub-compatible scope that uses lowercase letters, numbers, and hyphens, then publish again.";
return `Cannot publish ${params.packageName}: package.json name is scoped to "@${params.scopedOwnerHandle}", but ClawHub publisher handles may only use lowercase letters, numbers, and hyphens.${renameGuidance}`;
: " Rename package.json to a ClawHub-compatible scope that starts and ends with a lowercase letter or number and uses lowercase letters, numbers, hyphens, dots, or underscores, then publish again.";
return `Cannot publish ${params.packageName}: package.json name is scoped to "@${params.scopedOwnerHandle}", but ClawHub publisher handles must start and end with a lowercase letter or number and may only use lowercase letters, numbers, hyphens, dots, or underscores.${renameGuidance}`;
}
if (params.legacyPersonalOwnerHandle) {
const displayName = params.scopedOwnerHandle
@@ -693,6 +787,7 @@ type PublicPageCursorState = {
offset: number;
pageSize: number | null;
done: boolean;
mode?: "packages" | "digest";
};
const PUBLIC_PAGE_CURSOR_PREFIX = "pkgpage:";
@@ -1359,6 +1454,7 @@ function decodePublicPageCursor(raw: string | null | undefined): PublicPageCurso
offset: typeof parsed.offset === "number" && parsed.offset > 0 ? parsed.offset : 0,
pageSize: typeof parsed.pageSize === "number" && parsed.pageSize > 0 ? parsed.pageSize : null,
done: parsed.done === true,
mode: parsed.mode === "packages" || parsed.mode === "digest" ? parsed.mode : undefined,
};
} catch {
return { cursor: null, offset: 0, pageSize: null, done: false };
@@ -2569,7 +2665,9 @@ export const listPublicPage = query({
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
category: v.optional(v.string()),
sort: v.optional(v.union(v.literal("updated"), v.literal("downloads"))),
sort: v.optional(
v.union(v.literal("updated"), v.literal("downloads"), v.literal("recommended")),
),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
@@ -3037,7 +3135,9 @@ export const listPageForViewerInternal = internalQuery({
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
category: v.optional(v.string()),
sort: v.optional(v.union(v.literal("updated"), v.literal("downloads"))),
sort: v.optional(
v.union(v.literal("updated"), v.literal("downloads"), v.literal("recommended")),
),
viewerUserId: v.optional(v.id("users")),
paginationOpts: paginationOptsValidator,
},
@@ -3046,6 +3146,38 @@ export const listPageForViewerInternal = internalQuery({
},
});
export const countPublicPluginsInternal = internalQuery({
args: {},
handler: async (ctx) => {
return await readGlobalPublicPluginsCount(ctx);
},
});
export const hasMissingRecommendationScoresInternal = internalQuery({
args: {
families: v.optional(
v.array(v.union(v.literal("skill"), v.literal("code-plugin"), v.literal("bundle-plugin"))),
),
},
handler: async (ctx, args) => {
if (!args.families || args.families.length === 0) {
return await hasMissingPackageRecommendedScore(ctx, undefined);
}
for (const family of args.families) {
if (await hasMissingPackageRecommendedScore(ctx, family)) return true;
}
return false;
},
});
export const countPublicPlugins = query({
args: {},
handler: async (ctx) => {
const statsCount = await readGlobalPublicPluginsCount(ctx);
return statsCount ?? 0;
},
});
async function listPackagePageImpl(
ctx: DbReaderCtx,
args: {
@@ -3056,7 +3188,7 @@ async function listPackagePageImpl(
executesCode?: boolean;
capabilityTag?: string;
category?: string;
sort?: "updated" | "downloads";
sort?: "updated" | "downloads" | "recommended";
viewerUserId?: Id<"users">;
paginationOpts: { cursor: string | null; numItems: number };
},
@@ -3101,21 +3233,35 @@ async function listPackagePageImpl(
),
);
if (args.sort === "downloads") {
const keepDigestCursor = args.sort === "recommended" && decodedCursor.mode === "digest";
const keepRecommendedPackageCursor =
args.sort === "recommended" &&
Boolean(args.paginationOpts.cursor) &&
decodedCursor.mode !== "digest";
const recommendedIndexName =
args.sort === "recommended" && !keepDigestCursor
? keepRecommendedPackageCursor
? getPackageRecommendedScoreIndexName(family)
: await getPackageRecommendedIndexName(ctx, family)
: null;
if (args.sort === "downloads" || recommendedIndexName) {
let cursor = pageCursor;
let pageOffset = offset;
let pageSize: number | null = decodedCursor.pageSize ?? null;
let done = decodedCursor.done;
const buildDownloadsQuery = () =>
const buildSortedQuery = () =>
family
? ctx.db
.query("packages")
.withIndex("by_active_family_downloads", (q) =>
.withIndex(recommendedIndexName ?? "by_active_family_downloads", (q) =>
q.eq("softDeletedAt", undefined).eq("family", family),
)
: ctx.db
.query("packages")
.withIndex("by_active_downloads", (q) => q.eq("softDeletedAt", undefined));
.withIndex(recommendedIndexName ?? "by_active_downloads", (q) =>
q.eq("softDeletedAt", undefined),
);
while ((pageOffset > 0 || !done) && collected.length < targetCount) {
const scanPageSize = Math.min(
@@ -3125,7 +3271,7 @@ async function listPackagePageImpl(
: Math.max(targetCount * 5, targetCount, 50),
);
const currentCursor = cursor;
const page = await buildDownloadsQuery()
const page = await buildSortedQuery()
.order("desc")
.paginate({ cursor: currentCursor, numItems: scanPageSize });
@@ -3143,12 +3289,14 @@ async function listPackagePageImpl(
offset: nextOffset,
pageSize: scanPageSize,
done: page.isDone,
mode: "packages" as const,
}
: {
cursor: page.continueCursor,
offset: 0,
pageSize: scanPageSize,
done: page.isDone,
mode: "packages" as const,
};
return {
page: collected,
@@ -3172,6 +3320,7 @@ async function listPackagePageImpl(
offset: pageOffset,
pageSize,
done,
mode: "packages",
}),
};
}
@@ -3221,12 +3370,14 @@ async function listPackagePageImpl(
offset: nextOffset,
pageSize: effectivePageSize,
done: page.isDone,
mode: "digest" as const,
}
: {
cursor: page.continueCursor,
offset: 0,
pageSize: effectivePageSize,
done: page.isDone,
mode: "digest" as const,
};
return {
page: collected,
@@ -3244,6 +3395,7 @@ async function listPackagePageImpl(
offset: 0,
pageSize: effectivePageSize,
done: page.isDone,
mode: "digest",
}),
};
}
@@ -3461,13 +3613,15 @@ export const processPackageStatEventsInternal = internalMutation({
for (const [packageId, stats] of statsByPackage) {
const pkg = await ctx.db.get(packageId);
if (!pkg) continue;
const nextStats = {
downloads: (pkg.stats?.downloads ?? 0) + stats.downloads,
installs: (pkg.stats?.installs ?? 0) + stats.installs,
stars: pkg.stats?.stars ?? 0,
versions: pkg.stats?.versions ?? 0,
};
await ctx.db.patch(pkg._id, {
stats: {
downloads: (pkg.stats?.downloads ?? 0) + stats.downloads,
installs: (pkg.stats?.installs ?? 0) + stats.installs,
stars: pkg.stats?.stars ?? 0,
versions: pkg.stats?.versions ?? 0,
},
stats: nextStats,
...computePackageRecommendationPatch(nextStats),
});
packagesUpdated += 1;
}
@@ -6528,6 +6682,12 @@ export const reservePackageNameInternal = internalMutation({
capabilityTags: [],
executesCode: false,
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
...computePackageRecommendationPatch({
downloads: 0,
installs: 0,
stars: 0,
versions: 0,
}),
createdAt: now,
updatedAt: now,
});
@@ -7512,6 +7672,12 @@ export const insertReleaseInternal = internalMutation({
verification: args.verification,
scanStatus: args.verification?.scanStatus,
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
...computePackageRecommendationPatch({
downloads: 0,
installs: 0,
stars: 0,
versions: 0,
}),
createdAt: now,
updatedAt: now,
}));
+32
View File
@@ -3837,6 +3837,38 @@ describe("self-serve org publisher creation", () => {
);
});
it("creates org publishers for npm-compatible scoped package handles", async () => {
const examples = ["example.tools", "lab_1", "studio_tools", "market_square"];
for (const handle of examples) {
const { ctx, inserts } = makeCreateOrgPublisherCtx({});
await expect(
createOrgPublisherForUserInternalHandler(ctx as never, {
actorUserId: "users:vincent",
handle,
displayName: handle,
}),
).resolves.toMatchObject({
ok: true,
handle,
created: true,
});
expect(inserts).toEqual(
expect.arrayContaining([
expect.objectContaining({
table: "publishers",
value: expect.objectContaining({
kind: "org",
handle,
displayName: handle,
}),
}),
]),
);
}
});
it("rejects creation when the org publisher already exists", async () => {
const { ctx } = makeCreateOrgPublisherCtx({
existingPublisher: { _id: "publishers:opik", kind: "org", handle: "opik" },
+5 -4
View File
@@ -27,12 +27,13 @@ import {
getPersonalPublisherForUserOrFallback,
getPersonalPublisherForUser,
isPublisherRoleAllowed,
PUBLISHER_HANDLE_PATTERN,
PUBLISHER_HANDLE_REQUIREMENTS_MESSAGE,
normalizePublisherHandle,
} from "./lib/publishers";
import { isHandleReservedForAnotherUser } from "./lib/reservedHandles";
import { readCanonicalStat } from "./lib/skillStats";
const PUBLISHER_HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
const MAX_PUBLIC_PUBLISHER_LIST_LIMIT = 500;
const PUBLISHER_LIST_PREVIEW_LIMIT = 3;
const publisherRoleValidator = v.union(
@@ -114,7 +115,7 @@ function validateHandle(rawHandle: string) {
const handle = normalizePublisherHandle(rawHandle);
if (!handle) throw new ConvexError("Handle is required");
if (!PUBLISHER_HANDLE_PATTERN.test(handle)) {
throw new ConvexError("Handle must be lowercase, url-safe, and 2-40 characters");
throw new ConvexError(PUBLISHER_HANDLE_REQUIREMENTS_MESSAGE);
}
if (isReservedPublicOwnerHandle(handle)) {
throw new ConvexError(formatReservedPublicOwnerHandleMessage(handle));
@@ -1777,7 +1778,7 @@ export const removeOrgPublisherMemberInternal = internalMutation({
const handle = normalizePublisherHandle(args.handle);
if (!handle || !PUBLISHER_HANDLE_PATTERN.test(handle)) {
throw new ConvexError("Handle must be lowercase, url-safe, and 2-40 characters");
throw new ConvexError(PUBLISHER_HANDLE_REQUIREMENTS_MESSAGE);
}
const memberHandle = normalizePublisherHandle(args.memberHandle);
if (!memberHandle) throw new ConvexError("memberHandle is required");
@@ -1859,7 +1860,7 @@ export const deleteEmptyOrgPublisherInternal = internalMutation({
const handle = normalizePublisherHandle(args.handle);
if (!handle || !PUBLISHER_HANDLE_PATTERN.test(handle)) {
throw new ConvexError("Handle must be lowercase, url-safe, and 2-40 characters");
throw new ConvexError(PUBLISHER_HANDLE_REQUIREMENTS_MESSAGE);
}
const reason = args.reason.trim();
if (!reason) throw new ConvexError("Reason is required");
+221 -1
View File
@@ -630,6 +630,7 @@ const skills = defineTable({
createdAt: v.number(),
changelog: v.string(),
changelogSource: v.optional(v.union(v.literal("auto"), v.literal("user"))),
description: v.optional(v.string()),
clawdis: v.optional(v.any()),
// Denormalised mirror of the latest version's `apiKeyRequired`.
apiKeyRequired: v.optional(v.boolean()),
@@ -988,6 +989,7 @@ const skillSearchDigest = defineTable({
createdAt: v.number(),
changelog: v.string(),
changelogSource: v.optional(v.union(v.literal("auto"), v.literal("user"))),
description: v.optional(v.string()),
clawdis: v.optional(v.any()),
// Mirrors `skills.latestVersionSummary.apiKeyRequired`.
apiKeyRequired: v.optional(v.boolean()),
@@ -1001,6 +1003,8 @@ const skillSearchDigest = defineTable({
statsStars: v.optional(v.number()),
statsInstallsCurrent: v.optional(v.number()),
statsInstallsAllTime: v.optional(v.number()),
recommendedScore: v.optional(v.number()),
recommendedScoreVersion: v.optional(v.number()),
softDeletedAt: v.optional(v.number()),
moderationStatus: moderationStatusValidator,
moderationFlags: v.optional(v.array(v.string())),
@@ -1036,6 +1040,8 @@ const skillSearchDigest = defineTable({
"statsDownloads",
"updatedAt",
])
.index("by_active_recommended_score", ["softDeletedAt", "recommendedScore", "updatedAt"])
.index("by_active_recommended_score_version", ["softDeletedAt", "recommendedScoreVersion"])
.index("by_nonsuspicious_updated", ["softDeletedAt", "isSuspicious", "updatedAt"])
.index("by_nonsuspicious_created", ["softDeletedAt", "isSuspicious", "createdAt"])
.index("by_nonsuspicious_name", ["softDeletedAt", "isSuspicious", "displayName"])
@@ -1075,6 +1081,17 @@ const skillSearchDigest = defineTable({
"statsDownloads",
"updatedAt",
])
.index("by_nonsuspicious_recommended_score", [
"softDeletedAt",
"isSuspicious",
"recommendedScore",
"updatedAt",
])
.index("by_nonsuspicious_recommended_score_version", [
"softDeletedAt",
"isSuspicious",
"recommendedScoreVersion",
])
.searchIndex("search_by_display_name", {
searchField: "displayName",
filterFields: ["softDeletedAt", "isSuspicious"],
@@ -1116,6 +1133,8 @@ const packages = defineTable({
verification: packageVerificationValidator,
scanStatus: packageScanStatusValidator,
stats: packageStatsValidator,
recommendedScore: v.optional(v.number()),
recommendedScoreVersion: v.optional(v.number()),
reportCount: v.optional(v.number()),
lastReportedAt: v.optional(v.number()),
softDeletedAt: v.optional(v.number()),
@@ -1155,7 +1174,35 @@ const packages = defineTable({
.index("by_runtime_id", ["runtimeId"])
.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_family_downloads", ["softDeletedAt", "family", "stats.downloads", "updatedAt"])
.index("by_active_recommended_rank", [
"softDeletedAt",
"stats.stars",
"stats.downloads",
"stats.installs",
"updatedAt",
])
.index("by_active_family_recommended_rank", [
"softDeletedAt",
"family",
"stats.stars",
"stats.downloads",
"stats.installs",
"updatedAt",
])
.index("by_active_recommended_score", ["softDeletedAt", "recommendedScore", "updatedAt"])
.index("by_active_recommended_score_version", ["softDeletedAt", "recommendedScoreVersion"])
.index("by_active_family_recommended_score", [
"softDeletedAt",
"family",
"recommendedScore",
"updatedAt",
])
.index("by_active_family_recommended_score_version", [
"softDeletedAt",
"family",
"recommendedScoreVersion",
]);
const packageReleases = defineTable({
packageId: v.id("packages"),
@@ -1817,6 +1864,7 @@ const skillStatBackfillState = defineTable({
const globalStats = defineTable({
key: v.string(),
activeSkillsCount: v.number(),
activePluginsCount: v.optional(v.number()),
updatedAt: v.number(),
}).index("by_key", ["key"]);
@@ -2367,6 +2415,170 @@ const githubBackupSyncState = defineTable({
updatedAt: v.number(),
}).index("by_key", ["key"]);
const githubAppInstallations = defineTable({
installationId: v.string(),
accountLogin: v.string(),
accountId: v.string(),
accountType: v.union(v.literal("User"), v.literal("Organization")),
createdByUserId: v.id("users"),
suspendedAt: v.optional(v.number()),
deletedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_installation_id", ["installationId"])
.index("by_account_id", ["accountId"]);
const publisherGitHubLinks = defineTable({
publisherId: v.id("publishers"),
installationId: v.string(),
githubAppInstallationId: v.id("githubAppInstallations"),
linkedByUserId: v.id("users"),
deletedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_publisher", ["publisherId"])
.index("by_installation_id", ["installationId"])
.index("by_publisher_installation_id", ["publisherId", "installationId"]);
const publisherGitHubRepositories = defineTable({
publisherId: v.id("publishers"),
githubLinkId: v.id("publisherGitHubLinks"),
installationId: v.string(),
repoFullName: v.string(),
repoId: v.string(),
defaultBranch: v.string(),
syncRef: v.string(),
syncRoots: v.array(v.string()),
mode: v.union(v.literal("discover"), v.literal("mapped")),
enabled: v.boolean(),
lastSyncedCommit: v.optional(v.string()),
lastSyncStatus: v.optional(
v.union(
v.literal("idle"),
v.literal("queued"),
v.literal("running"),
v.literal("succeeded"),
v.literal("failed"),
),
),
lastSyncError: v.optional(v.string()),
lastSyncedAt: v.optional(v.number()),
deletedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_publisher", ["publisherId"])
.index("by_installation_id", ["installationId"])
.index("by_installation_repo_id", ["installationId", "repoId"])
.index("by_repo_full_name", ["repoFullName"])
.index("by_enabled_status", ["enabled", "lastSyncStatus"]);
const skillSourceLinks = defineTable({
publisherId: v.id("publishers"),
skillId: v.optional(v.id("skills")),
repositoryId: v.id("publisherGitHubRepositories"),
repoFullName: v.string(),
repoId: v.string(),
path: v.string(),
slug: v.string(),
readmePath: v.string(),
status: v.union(
v.literal("active"),
v.literal("conflict"),
v.literal("missing"),
v.literal("disabled"),
),
conflictReason: v.optional(v.string()),
lastSyncedCommit: v.optional(v.string()),
lastSyncedVersionId: v.optional(v.id("skillVersions")),
lastFingerprint: v.optional(v.string()),
createdByUserId: v.id("users"),
disabledAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_publisher", ["publisherId"])
.index("by_skill", ["skillId"])
.index("by_repository", ["repositoryId"])
.index("by_repository_path", ["repositoryId", "path"])
.index("by_publisher_slug", ["publisherId", "slug"])
.index("by_status_updated", ["status", "updatedAt"]);
const githubSkillSyncJobs = defineTable({
publisherId: v.id("publishers"),
repositoryId: v.id("publisherGitHubRepositories"),
repoFullName: v.string(),
ref: v.string(),
commit: v.string(),
status: v.union(
v.literal("queued"),
v.literal("running"),
v.literal("succeeded"),
v.literal("failed"),
v.literal("cancelled"),
),
reason: v.union(
v.literal("push"),
v.literal("manual"),
v.literal("repository_linked"),
v.literal("backfill"),
),
candidateOffset: v.optional(v.number()),
requestedByUserId: v.optional(v.id("users")),
startedAt: v.optional(v.number()),
finishedAt: v.optional(v.number()),
error: v.optional(v.string()),
counts: v.object({
discovered: v.number(),
published: v.number(),
skipped: v.number(),
conflicted: v.number(),
missing: v.number(),
}),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_repository_status", ["repositoryId", "status"])
.index("by_repository_commit", ["repositoryId", "commit"])
.index("by_status_created", ["status", "createdAt"]);
const githubAppSetupStates = defineTable({
stateHash: v.string(),
publisherId: v.id("publishers"),
requestedByUserId: v.id("users"),
nonce: v.string(),
expiresAt: v.number(),
consumedAt: v.optional(v.number()),
createdAt: v.number(),
})
.index("by_state_hash", ["stateHash"])
.index("by_publisher", ["publisherId"])
.index("by_expires_at", ["expiresAt"]);
const githubWebhookDeliveries = defineTable({
deliveryId: v.string(),
event: v.string(),
status: v.union(v.literal("processing"), v.literal("processed"), v.literal("failed")),
installationId: v.optional(v.string()),
repoId: v.optional(v.string()),
error: v.optional(v.string()),
receivedAt: v.number(),
updatedAt: v.number(),
})
.index("by_delivery_id", ["deliveryId"])
.index("by_received_at", ["receivedAt"]);
const githubAppInstallationClaims = defineTable({
installationId: v.string(),
accountId: v.string(),
senderAccountId: v.string(),
event: v.string(),
receivedAt: v.number(),
updatedAt: v.number(),
}).index("by_installation_id", ["installationId"]);
const userSyncRoots = defineTable({
userId: v.id("users"),
rootId: v.string(),
@@ -2492,6 +2704,14 @@ export default defineSchema({
reservedSlugs,
reservedHandles,
githubBackupSyncState,
githubAppInstallations,
publisherGitHubLinks,
publisherGitHubRepositories,
skillSourceLinks,
githubSkillSyncJobs,
githubAppSetupStates,
githubWebhookDeliveries,
githubAppInstallationClaims,
userSyncRoots,
userSkillInstalls,
userSkillRootInstalls,
+100 -46
View File
@@ -1,5 +1,9 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import {
computeRecommendationScore,
RECOMMENDATION_SCORE_VERSION,
} from "./lib/recommendationScore";
import schema from "./schema";
vi.mock("@convex-dev/auth/server", () => ({
@@ -19,13 +23,22 @@ const listPublicPageV4Handler = (
)._handler;
describe("skills.listPublicPageV4", () => {
it("defines recommended rank indexes in contract order", () => {
it("defines recommended indexes in contract order", () => {
expect(getSkillSearchDigestIndexFields("by_active_recommended_rank")).toEqual([
"softDeletedAt",
"statsStars",
"statsDownloads",
"updatedAt",
]);
expect(getSkillSearchDigestIndexFields("by_active_recommended_score")).toEqual([
"softDeletedAt",
"recommendedScore",
"updatedAt",
]);
expect(getSkillSearchDigestIndexFields("by_active_recommended_score_version")).toEqual([
"softDeletedAt",
"recommendedScoreVersion",
]);
expect(getSkillSearchDigestIndexFields("by_nonsuspicious_recommended_rank")).toEqual([
"softDeletedAt",
"isSuspicious",
@@ -33,6 +46,17 @@ describe("skills.listPublicPageV4", () => {
"statsDownloads",
"updatedAt",
]);
expect(getSkillSearchDigestIndexFields("by_nonsuspicious_recommended_score")).toEqual([
"softDeletedAt",
"isSuspicious",
"recommendedScore",
"updatedAt",
]);
expect(getSkillSearchDigestIndexFields("by_nonsuspicious_recommended_score_version")).toEqual([
"softDeletedAt",
"isSuspicious",
"recommendedScoreVersion",
]);
});
it("forces Recommended ranking to descending for stale URLs", () => {
@@ -45,54 +69,43 @@ describe("skills.listPublicPageV4", () => {
expect(__test.resolvePublicListDir("downloads", "asc")).toBe("asc");
});
it("keeps recommended-rank cursors on the index that created them", () => {
it("uses the score index after recommendation scores are backfilled", () => {
expect(
__test.resolveRecommendedPublicListSort({
decodedCursor: null,
hasMissingRankStats: false,
__test.resolveRecommendedPublicListQuery({
scoreIndexName: "by_active_recommended_score",
rankIndexName: "by_active_recommended_rank",
updatedIndexName: "by_active_updated",
scoreCursor: null,
rankCursor: null,
updatedCursor: null,
hasMissingScores: false,
}),
).toBe("recommended");
expect(
__test.resolveRecommendedPublicListSort({
decodedCursor: null,
hasMissingRankStats: true,
}),
).toBe("updated");
expect(
__test.resolveRecommendedPublicListSort({
decodedCursor: [undefined, 123, 456, "skillSearchDigest:updated"],
hasMissingRankStats: false,
}),
).toBe("updated");
expect(
__test.resolveRecommendedPublicListSort({
decodedCursor: [undefined, false, 123, 456, "skillSearchDigest:nonsuspicious-updated"],
hasMissingRankStats: false,
}),
).toBe("updated");
expect(
__test.resolveRecommendedPublicListSort({
decodedCursor: [undefined, 10, 20, 123, 456, "skillSearchDigest:recommended"],
hasMissingRankStats: true,
}),
).toBe("recommended");
expect(
__test.resolveRecommendedPublicListSort({
decodedCursor: [
undefined,
false,
10,
20,
123,
456,
"skillSearchDigest:nonsuspicious-recommended",
],
hasMissingRankStats: true,
}),
).toBe("recommended");
).toEqual({
sort: "recommended",
indexName: "by_active_recommended_score",
decodedCursor: null,
});
});
it("sorts highlighted recommended results by stars, downloads, then updatedAt", async () => {
it("falls back to updated results while recommendation scores are missing", () => {
expect(
__test.resolveRecommendedPublicListQuery({
scoreIndexName: "by_active_recommended_score",
rankIndexName: "by_active_recommended_rank",
updatedIndexName: "by_active_updated",
scoreCursor: null,
rankCursor: null,
updatedCursor: null,
hasMissingScores: true,
}),
).toEqual({
sort: "updated",
indexName: "by_active_updated",
decodedCursor: null,
});
});
it("sorts highlighted recommended results by weighted score, then updatedAt", async () => {
const result = await listPublicPageV4Handler(
makeHighlightedCtx([
makeDigest({
@@ -132,10 +145,47 @@ describe("skills.listPublicPageV4", () => {
);
expect(result.page.map((entry) => entry.skill.slug)).toEqual([
"stars-skill",
"downloads-skill",
"updated-skill",
"installs-skill",
"stars-skill",
]);
});
it("recomputes highlighted recommended scores when the stored score is stale", async () => {
const result = await listPublicPageV4Handler(
makeHighlightedCtx([
makeDigest({
id: "old-download-score",
slug: "old-download-score",
stars: 0,
installsAllTime: 2,
downloads: 43_080,
updatedAt: 100,
recommendedScore: computeRecommendationScore({
downloads: 43_080,
installs: 2,
stars: 0,
}),
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
}),
makeDigest({
id: "stale-install-score",
slug: "stale-install-score",
stars: 0,
installsAllTime: 74,
downloads: 393,
updatedAt: 100,
recommendedScore: 1,
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION - 1,
}),
]),
{ highlightedOnly: true, numItems: 10 },
);
expect(result.page.map((entry) => entry.skill.slug)).toEqual([
"old-download-score",
"stale-install-score",
]);
});
});
@@ -218,6 +268,8 @@ function makeDigest(params: {
installsAllTime: number;
downloads: number;
updatedAt: number;
recommendedScore?: number;
recommendedScoreVersion?: number;
}) {
return {
_id: `skillSearchDigest:${params.id}`,
@@ -252,6 +304,8 @@ function makeDigest(params: {
statsStars: params.stars,
statsInstallsCurrent: 0,
statsInstallsAllTime: params.installsAllTime,
recommendedScore: params.recommendedScore,
recommendedScoreVersion: params.recommendedScoreVersion,
softDeletedAt: undefined,
moderationStatus: "active",
moderationFlags: [],
+1 -1
View File
@@ -314,7 +314,7 @@ function createMigrationFixture(params: {
if (table === "authAccounts") {
return {
withIndex: () => ({
unique: async () => null,
take: async () => [],
}),
};
}
+45 -9
View File
@@ -147,7 +147,7 @@ class TestEqBuilder {
}
}
function makeMissingRecommendedRankStatsCtx() {
function makeMissingRecommendedScoresCtx() {
const first = vi.fn(async () => makeSearchDigest({ statsStars: undefined }));
const withIndex = vi.fn((_indexName: string, build: (q: TestEqBuilder) => unknown) => {
build(new TestEqBuilder());
@@ -172,16 +172,15 @@ describe("public skill list deterministic cursors", () => {
getPageMock.mockResolvedValue({ page: [], hasMore: false, indexKeys: [] });
});
it("falls back to the updated index while default rank stats are missing", async () => {
const { ctx, withIndex } = makeMissingRecommendedRankStatsCtx();
it("falls back to the updated index while recommendation scores are missing", async () => {
const { ctx, withIndex } = makeMissingRecommendedScoresCtx();
await listPublicPageV4Handler(ctx, {
numItems: 10,
});
expect(withIndex.mock.calls.map(([indexName]) => indexName)).toEqual([
"by_active_stats_stars",
"by_active_stats_downloads",
"by_active_recommended_score",
]);
expect(getPageMock).toHaveBeenCalledTimes(1);
expect(getPageMock.mock.calls[0]?.[1]).toMatchObject({
@@ -192,8 +191,8 @@ describe("public skill list deterministic cursors", () => {
});
});
it("falls back to the non-suspicious updated index while default rank stats are missing", async () => {
const { ctx, withIndex } = makeMissingRecommendedRankStatsCtx();
it("falls back to the non-suspicious updated index while recommendation scores are missing", async () => {
const { ctx, withIndex } = makeMissingRecommendedScoresCtx();
await listPublicApiPageV1Handler(ctx, {
numItems: 10,
@@ -202,8 +201,7 @@ describe("public skill list deterministic cursors", () => {
});
expect(withIndex.mock.calls.map(([indexName]) => indexName)).toEqual([
"by_nonsuspicious_stars",
"by_nonsuspicious_downloads",
"by_nonsuspicious_recommended_score",
]);
expect(getPageMock).toHaveBeenCalledTimes(1);
expect(getPageMock.mock.calls[0]?.[1]).toMatchObject({
@@ -489,6 +487,44 @@ describe("public skill list deterministic cursors", () => {
});
});
it("carries denormalized latest-version descriptions through the public API list", async () => {
getPageMock.mockResolvedValueOnce({
page: [
makeSearchDigest({
latestVersionSummary: {
version: "1.0.0",
createdAt: 9,
changelog: "initial",
changelogSource: "user",
description: "Long-form frontmatter description.",
clawdis: {
requires: { env: ["HA_TOKEN"] },
},
},
}),
],
hasMore: false,
indexKeys: [],
});
const result = await listPublicApiPageV1Handler({} as never, {
numItems: 10,
sort: "updated",
});
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
latestVersion: {
parsed: {
description: "Long-form frontmatter description.",
clawdis: {
requires: { env: ["HA_TOKEN"] },
},
},
},
});
});
it("drops stale trending latest versions that belong to another skill", async () => {
const staleDigest = makeSearchDigest({
latestVersionId: "skillVersions:other",
+12 -12
View File
@@ -212,11 +212,11 @@ describe("skills anti-spam guards", () => {
withIndex: (name: string) => {
if (name !== "userIdAndProvider") throw new Error(`unexpected auth index ${name}`);
return {
unique: async () => {
take: async () => {
authAccountLookupCount += 1;
return authAccountLookupCount === 1
? { providerAccountId: "owner-gh" }
: { providerAccountId: "caller-gh" };
? [{ providerAccountId: "owner-gh" }]
: [{ providerAccountId: "caller-gh" }];
},
};
},
@@ -293,11 +293,11 @@ describe("skills anti-spam guards", () => {
withIndex: (name: string) => {
if (name !== "userIdAndProvider") throw new Error(`unexpected auth index ${name}`);
return {
unique: async () => {
take: async () => {
authAccountLookupCount += 1;
return authAccountLookupCount === 1
? { providerAccountId: "owner-gh" }
: { providerAccountId: "caller-gh" };
? [{ providerAccountId: "owner-gh" }]
: [{ providerAccountId: "caller-gh" }];
},
};
},
@@ -361,11 +361,11 @@ describe("skills anti-spam guards", () => {
withIndex: (name: string) => {
if (name !== "userIdAndProvider") throw new Error(`unexpected auth index ${name}`);
return {
unique: async () => {
take: async () => {
authAccountLookupCount += 1;
return authAccountLookupCount === 1
? { providerAccountId: "owner-gh" }
: { providerAccountId: "caller-gh" };
? [{ providerAccountId: "owner-gh" }]
: [{ providerAccountId: "caller-gh" }];
},
};
},
@@ -777,7 +777,7 @@ describe("skills anti-spam guards", () => {
return {
withIndex: (name: string) => {
if (name !== "userIdAndProvider") throw new Error(`unexpected auth index ${name}`);
return { unique: async () => null };
return { take: async () => [] };
},
};
}
@@ -945,9 +945,9 @@ describe("skills anti-spam guards", () => {
withIndex: (name: string) => {
if (name !== "userIdAndProvider") throw new Error(`unexpected auth index ${name}`);
return {
unique: async () => {
take: async () => {
authAccountLookupCount += 1;
return authAccountLookupCount <= 2 ? { providerAccountId: "shared-gh" } : null;
return authAccountLookupCount <= 2 ? [{ providerAccountId: "shared-gh" }] : [];
},
};
},
+5 -5
View File
@@ -268,16 +268,16 @@ function createCtx(options: {
throw new Error(`unexpected authAccounts index ${name}`);
}
return {
unique: async () => {
take: async () => {
authAccountLookupCount += 1;
if (authAccountLookupCount === 1) {
return options.ownerProviderAccountId
? { providerAccountId: options.ownerProviderAccountId }
: null;
? [{ providerAccountId: options.ownerProviderAccountId }]
: [];
}
return options.callerProviderAccountId
? { providerAccountId: options.callerProviderAccountId }
: null;
? [{ providerAccountId: options.callerProviderAccountId }]
: [];
},
};
},
+333 -98
View File
@@ -35,6 +35,7 @@ import { getSkillBadgeMap, getSkillBadgeMaps, isSkillHighlighted } from "./lib/b
import { scheduleNextBatchIfNeeded } from "./lib/batching";
import { generateChangelogPreview as buildChangelogPreview } from "./lib/changelog";
import { embeddingVisibilityFor } from "./lib/embeddingVisibility";
import { sourceLinkMatchesProvenance } from "./lib/githubAppSync";
import {
canHealSkillOwnershipByGitHubProviderAccountId,
getGitHubProviderAccountId,
@@ -83,6 +84,10 @@ import {
normalizePublisherHandle,
requirePublisherRole,
} from "./lib/publishers";
import {
computeRecommendationScore,
RECOMMENDATION_SCORE_VERSION,
} from "./lib/recommendationScore";
import {
AUTO_HIDE_REPORT_THRESHOLD,
MAX_ACTIVE_REPORTS_PER_USER,
@@ -508,6 +513,7 @@ function latestVersionSummaryFromSkillVersion(
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource,
description: skillSummaryFromSkillVersion(version),
clawdis: version.parsed?.clawdis,
apiKeyRequired: version.apiKeyRequired,
};
@@ -797,7 +803,7 @@ const NEW_SKILL_RATE_LIMITS = {
} as const;
const SORT_INDEXES = {
recommended: "by_active_recommended_rank",
recommended: "by_active_recommended_score",
newest: "by_active_created",
updated: "by_active_updated",
name: "by_active_name",
@@ -808,7 +814,7 @@ const SORT_INDEXES = {
// Compound indexes on skillSearchDigest that filter isSuspicious at the index level.
const NONSUSPICIOUS_SORT_INDEXES = {
recommended: "by_nonsuspicious_recommended_rank",
recommended: "by_nonsuspicious_recommended_score",
newest: "by_nonsuspicious_created",
updated: "by_nonsuspicious_updated",
name: "by_nonsuspicious_name",
@@ -816,6 +822,16 @@ const NONSUSPICIOUS_SORT_INDEXES = {
stars: "by_nonsuspicious_stars",
installs: "by_nonsuspicious_installs",
} as const;
const RECOMMENDED_RANK_INDEXES = {
active: "by_active_recommended_rank",
nonSuspicious: "by_nonsuspicious_recommended_rank",
} as const;
const RECOMMENDED_RANK_INDEX_FIELD_COUNTS = {
active: 5,
nonSuspicious: 6,
} as const;
const MAX_FILTERED_PUBLIC_LIST_SCAN_PAGES = 12;
const MAX_FILTERED_PUBLIC_LIST_SCAN_ROWS = 500;
@@ -2295,7 +2311,13 @@ function toPublicSkillListVersionFromSummary(
createdAt: summary.createdAt,
changelog: summary.changelog,
changelogSource: summary.changelogSource,
parsed: summary.clawdis ? { clawdis: summary.clawdis } : undefined,
parsed:
summary.description || summary.clawdis
? {
...(summary.description ? { description: summary.description } : {}),
...(summary.clawdis ? { clawdis: summary.clawdis } : {}),
}
: undefined,
apiKeyRequired: summary.apiKeyRequired,
};
}
@@ -4905,9 +4927,13 @@ export const listPublicPageV3 = query({
});
type PublicListSort = keyof typeof SORT_INDEXES;
type SkillSearchDigestSortIndexName =
| (typeof SORT_INDEXES)[keyof typeof SORT_INDEXES]
| (typeof NONSUSPICIOUS_SORT_INDEXES)[keyof typeof NONSUSPICIOUS_SORT_INDEXES]
| (typeof RECOMMENDED_RANK_INDEXES)[keyof typeof RECOMMENDED_RANK_INDEXES];
const SORT_INDEX_FIELD_COUNTS: Record<PublicListSort, number> = {
recommended: 4,
recommended: 3,
newest: 2,
updated: 2,
name: 2,
@@ -4917,7 +4943,7 @@ const SORT_INDEX_FIELD_COUNTS: Record<PublicListSort, number> = {
};
const NONSUSPICIOUS_SORT_INDEX_FIELD_COUNTS: Record<PublicListSort, number> = {
recommended: 5,
recommended: 4,
newest: 3,
updated: 3,
name: 3,
@@ -4956,11 +4982,13 @@ function decodePublicListCursor({
indexName,
maxIndexKeyLength,
eqPrefix,
allowLegacyArray = true,
}: {
cursor?: string;
indexName: string;
maxIndexKeyLength: number;
eqPrefix: IndexKey;
allowLegacyArray?: boolean;
}): IndexKey | null {
if (!cursor) return null;
try {
@@ -4972,11 +5000,12 @@ function decodePublicListCursor({
(parsed as { v?: unknown }).v === 1 &&
(parsed as { index?: unknown }).index === indexName &&
Array.isArray((parsed as { key?: unknown }).key);
const arr = Array.isArray(parsed)
? parsed
: isSelfDescribingCursor
? (parsed as { key: unknown[] }).key
: null;
const arr =
Array.isArray(parsed) && allowLegacyArray
? parsed
: isSelfDescribingCursor
? (parsed as { key: unknown[] }).key
: null;
if (!Array.isArray(arr)) return null;
const key = arr.map(decodeIndexKeyValue);
// Self-describing cursors include the index name, so they can safely carry
@@ -4998,12 +5027,14 @@ function getPublicListCursorKey({
nonSuspiciousOnly,
indexName,
eqPrefix,
allowLegacyArray,
}: {
cursor?: string;
sort: PublicListSort;
nonSuspiciousOnly: boolean;
indexName: string;
eqPrefix: IndexKey;
allowLegacyArray?: boolean;
}): IndexKey | null {
const fieldCounts = nonSuspiciousOnly
? NONSUSPICIOUS_SORT_INDEX_FIELD_COUNTS
@@ -5013,6 +5044,7 @@ function getPublicListCursorKey({
indexName,
maxIndexKeyLength: fieldCounts[sort],
eqPrefix,
allowLegacyArray,
});
}
@@ -5061,6 +5093,7 @@ export const listPublicPageV4 = query({
const recommendedIndexName = args.nonSuspiciousOnly
? NONSUSPICIOUS_SORT_INDEXES.recommended
: SORT_INDEXES.recommended;
const recommendedRankIndexName = getRecommendedRankIndexName(args.nonSuspiciousOnly ?? false);
const updatedIndexName = args.nonSuspiciousOnly
? NONSUSPICIOUS_SORT_INDEXES.updated
: SORT_INDEXES.updated;
@@ -5070,6 +5103,12 @@ export const listPublicPageV4 = query({
nonSuspiciousOnly: args.nonSuspiciousOnly ?? false,
indexName: recommendedIndexName,
eqPrefix,
allowLegacyArray: false,
});
const recommendedRankCursor = getRecommendedRankCursorKey({
cursor: args.cursor,
nonSuspiciousOnly: args.nonSuspiciousOnly ?? false,
eqPrefix,
});
const updatedCursor = getPublicListCursorKey({
cursor: args.cursor,
@@ -5095,29 +5134,40 @@ export const listPublicPageV4 = query({
});
}
const sort =
const recommendedAnyCursor = recommendedCursor ?? recommendedRankCursor ?? updatedCursor;
const hasMissingRecommendedScore =
requestedSort === "recommended"
? resolveRecommendedPublicListSort({
decodedCursor: recommendedCursor ?? updatedCursor,
hasMissingRankStats: await hasMissingRecommendedRankStats(
ctx,
args.nonSuspiciousOnly ?? false,
recommendedCursor ?? updatedCursor,
),
? await hasMissingRecommendedScores(
ctx,
args.nonSuspiciousOnly ?? false,
recommendedAnyCursor,
)
: false;
const recommendedResolution =
requestedSort === "recommended"
? resolveRecommendedPublicListQuery({
scoreIndexName: recommendedIndexName,
rankIndexName: recommendedRankIndexName,
updatedIndexName,
scoreCursor: recommendedCursor,
rankCursor: recommendedRankCursor,
updatedCursor,
hasMissingScores: hasMissingRecommendedScore,
})
: requestedSort;
const indexName = args.nonSuspiciousOnly
? NONSUSPICIOUS_SORT_INDEXES[sort]
: SORT_INDEXES[sort];
const decodedCursor = getPublicListCursorKey({
cursor: args.cursor,
sort,
nonSuspiciousOnly: args.nonSuspiciousOnly ?? false,
indexName,
eqPrefix,
});
: null;
const sort = recommendedResolution?.sort ?? requestedSort;
const indexName =
recommendedResolution?.indexName ??
(args.nonSuspiciousOnly ? NONSUSPICIOUS_SORT_INDEXES[sort] : SORT_INDEXES[sort]);
const decodedCursor =
recommendedResolution?.decodedCursor ??
getPublicListCursorKey({
cursor: args.cursor,
sort,
nonSuspiciousOnly: args.nonSuspiciousOnly ?? false,
indexName,
eqPrefix,
});
const isFirstPage = !decodedCursor;
const startIndexKey: IndexKey = decodedCursor ?? eqPrefix;
@@ -5614,6 +5664,7 @@ export const listPublicApiPageV1 = query({
const recommendedIndexName = args.nonSuspiciousOnly
? NONSUSPICIOUS_SORT_INDEXES.recommended
: SORT_INDEXES.recommended;
const recommendedRankIndexName = getRecommendedRankIndexName(args.nonSuspiciousOnly ?? false);
const updatedIndexName = args.nonSuspiciousOnly
? NONSUSPICIOUS_SORT_INDEXES.updated
: SORT_INDEXES.updated;
@@ -5623,6 +5674,12 @@ export const listPublicApiPageV1 = query({
nonSuspiciousOnly: args.nonSuspiciousOnly ?? false,
indexName: recommendedIndexName,
eqPrefix,
allowLegacyArray: false,
});
const recommendedRankCursor = getRecommendedRankCursorKey({
cursor: args.cursor,
nonSuspiciousOnly: args.nonSuspiciousOnly ?? false,
eqPrefix,
});
const updatedCursor = getPublicListCursorKey({
cursor: args.cursor,
@@ -5631,27 +5688,40 @@ export const listPublicApiPageV1 = query({
indexName: updatedIndexName,
eqPrefix,
});
const sort =
const recommendedAnyCursor = recommendedCursor ?? recommendedRankCursor ?? updatedCursor;
const hasMissingRecommendedScore =
requestedSort === "recommended"
? resolveRecommendedPublicListSort({
decodedCursor: recommendedCursor ?? updatedCursor,
hasMissingRankStats: await hasMissingRecommendedRankStats(
ctx,
args.nonSuspiciousOnly ?? false,
recommendedCursor ?? updatedCursor,
),
? await hasMissingRecommendedScores(
ctx,
args.nonSuspiciousOnly ?? false,
recommendedAnyCursor,
)
: false;
const recommendedResolution =
requestedSort === "recommended"
? resolveRecommendedPublicListQuery({
scoreIndexName: recommendedIndexName,
rankIndexName: recommendedRankIndexName,
updatedIndexName,
scoreCursor: recommendedCursor,
rankCursor: recommendedRankCursor,
updatedCursor,
hasMissingScores: hasMissingRecommendedScore,
})
: requestedSort;
const indexName = args.nonSuspiciousOnly
? NONSUSPICIOUS_SORT_INDEXES[sort]
: SORT_INDEXES[sort];
const decodedCursor = getPublicListCursorKey({
cursor: args.cursor,
sort,
nonSuspiciousOnly: args.nonSuspiciousOnly ?? false,
indexName,
eqPrefix,
});
: null;
const sort = recommendedResolution?.sort ?? requestedSort;
const indexName =
recommendedResolution?.indexName ??
(args.nonSuspiciousOnly ? NONSUSPICIOUS_SORT_INDEXES[sort] : SORT_INDEXES[sort]);
const decodedCursor =
recommendedResolution?.decodedCursor ??
getPublicListCursorKey({
cursor: args.cursor,
sort,
nonSuspiciousOnly: args.nonSuspiciousOnly ?? false,
indexName,
eqPrefix,
});
const isFirstPage = !decodedCursor;
const result = await getPage(ctx, {
table: "skillSearchDigest",
@@ -6087,61 +6157,126 @@ function resolvePublicListDir(sort: SortKeyInput, dir: "asc" | "desc" | undefine
return dir ?? (normalizedSort === "name" ? "asc" : "desc");
}
function resolveRecommendedPublicListSort({
decodedCursor,
hasMissingRankStats,
}: {
decodedCursor: readonly unknown[] | null;
hasMissingRankStats: boolean;
}): SortKey {
if (decodedCursor) {
return decodedCursor.length <= 5 ? "updated" : "recommended";
}
return hasMissingRankStats ? "updated" : "recommended";
function getRecommendedRankIndexName(nonSuspiciousOnly: boolean) {
return nonSuspiciousOnly
? RECOMMENDED_RANK_INDEXES.nonSuspicious
: RECOMMENDED_RANK_INDEXES.active;
}
async function hasMissingRecommendedRankStats(
function getRecommendedRankCursorKey({
cursor,
nonSuspiciousOnly,
eqPrefix,
}: {
cursor?: string;
nonSuspiciousOnly: boolean;
eqPrefix: IndexKey;
}) {
const rankKey = nonSuspiciousOnly ? "nonSuspicious" : "active";
return decodePublicListCursor({
cursor,
indexName: RECOMMENDED_RANK_INDEXES[rankKey],
maxIndexKeyLength: RECOMMENDED_RANK_INDEX_FIELD_COUNTS[rankKey],
eqPrefix,
});
}
function resolveRecommendedPublicListQuery({
scoreIndexName,
rankIndexName,
updatedIndexName,
scoreCursor,
rankCursor,
updatedCursor,
hasMissingScores,
}: {
scoreIndexName: SkillSearchDigestSortIndexName;
rankIndexName: SkillSearchDigestSortIndexName;
updatedIndexName: SkillSearchDigestSortIndexName;
scoreCursor: IndexKey | null;
rankCursor: IndexKey | null;
updatedCursor: IndexKey | null;
hasMissingScores: boolean;
}): { sort: SortKey; indexName: SkillSearchDigestSortIndexName; decodedCursor: IndexKey | null } {
if (scoreCursor) {
return { sort: "recommended", indexName: scoreIndexName, decodedCursor: scoreCursor };
}
if (rankCursor) {
return { sort: "recommended", indexName: rankIndexName, decodedCursor: rankCursor };
}
if (updatedCursor) {
return { sort: "updated", indexName: updatedIndexName, decodedCursor: updatedCursor };
}
if (hasMissingScores) {
return { sort: "updated", indexName: updatedIndexName, decodedCursor: null };
}
return { sort: "recommended", indexName: scoreIndexName, decodedCursor: null };
}
async function hasMissingRecommendedScores(
ctx: Pick<QueryCtx, "db">,
nonSuspiciousOnly: boolean,
decodedCursor: IndexKey | null,
) {
if (decodedCursor) return false;
if (nonSuspiciousOnly) {
const [missingStars, missingDownloads] = await Promise.all([
ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_stars", (q) =>
q.eq("softDeletedAt", undefined).eq("isSuspicious", false).eq("statsStars", undefined),
)
.first(),
ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_downloads", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.eq("statsDownloads", undefined),
)
.first(),
]);
return Boolean(missingStars || missingDownloads);
const missingScore = await ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_recommended_score", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.eq("recommendedScore", undefined),
)
.first();
if (missingScore) return true;
const missingVersion = await ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_recommended_score_version", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.eq("recommendedScoreVersion", undefined),
)
.first();
if (missingVersion) return true;
const staleVersion = await ctx.db
.query("skillSearchDigest")
.withIndex("by_nonsuspicious_recommended_score_version", (q) =>
q
.eq("softDeletedAt", undefined)
.eq("isSuspicious", false)
.lt("recommendedScoreVersion", RECOMMENDATION_SCORE_VERSION),
)
.first();
return Boolean(staleVersion);
}
const [missingStars, missingDownloads] = await Promise.all([
ctx.db
.query("skillSearchDigest")
.withIndex("by_active_stats_stars", (q) =>
q.eq("softDeletedAt", undefined).eq("statsStars", undefined),
)
.first(),
ctx.db
.query("skillSearchDigest")
.withIndex("by_active_stats_downloads", (q) =>
q.eq("softDeletedAt", undefined).eq("statsDownloads", undefined),
)
.first(),
]);
return Boolean(missingStars || missingDownloads);
const missingScore = await ctx.db
.query("skillSearchDigest")
.withIndex("by_active_recommended_score", (q) =>
q.eq("softDeletedAt", undefined).eq("recommendedScore", undefined),
)
.first();
if (missingScore) return true;
const missingVersion = await ctx.db
.query("skillSearchDigest")
.withIndex("by_active_recommended_score_version", (q) =>
q.eq("softDeletedAt", undefined).eq("recommendedScoreVersion", undefined),
)
.first();
if (missingVersion) return true;
const staleVersion = await ctx.db
.query("skillSearchDigest")
.withIndex("by_active_recommended_score_version", (q) =>
q.eq("softDeletedAt", undefined).lt("recommendedScoreVersion", RECOMMENDATION_SCORE_VERSION),
)
.first();
return Boolean(staleVersion);
}
function readDigestRankStat(
@@ -6153,6 +6288,19 @@ function readDigestRankStat(
return digest.statsInstallsAllTime ?? digest.stats.installsAllTime ?? 0;
}
function readDigestRecommendationScore(digest: Doc<"skillSearchDigest">): number {
return (
(digest.recommendedScoreVersion === RECOMMENDATION_SCORE_VERSION
? digest.recommendedScore
: undefined) ??
computeRecommendationScore({
downloads: readDigestRankStat(digest, "downloads"),
installs: readDigestRankStat(digest, "installsAllTime"),
stars: readDigestRankStat(digest, "stars"),
})
);
}
/** Fetch highlighted skills via the skillBadges index, then sort in JS. */
async function fetchHighlightedPage(
ctx: QueryCtx,
@@ -6206,8 +6354,7 @@ async function fetchHighlightedPage(
);
case "recommended":
return (
(readDigestRankStat(a, "stars") - readDigestRankStat(b, "stars")) * multiplier ||
(readDigestRankStat(a, "downloads") - readDigestRankStat(b, "downloads")) * multiplier ||
(readDigestRecommendationScore(a) - readDigestRecommendationScore(b)) * multiplier ||
(a.updatedAt - b.updatedAt) * multiplier
);
case "stars":
@@ -8881,6 +9028,7 @@ export const updateTags = mutation({
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource,
description: skillSummaryFromSkillVersion(version),
clawdis: version.parsed?.clawdis,
apiKeyRequired: version.apiKeyRequired,
};
@@ -10320,6 +10468,75 @@ export const hardDeleteInternal = internalMutation({
},
});
async function enforceSourceManagedPublishBoundary(
ctx: MutationCtx,
params: {
skill: Doc<"skills">;
sourceProvenance?: Doc<"skillVersions">["sourceProvenance"];
sourceSync?: {
sourceLinkId: Id<"skillSourceLinks">;
repositoryId: Id<"publisherGitHubRepositories">;
syncJobId?: Id<"githubSkillSyncJobs">;
};
},
) {
let links: Doc<"skillSourceLinks">[];
try {
links = await ctx.db
.query("skillSourceLinks")
.withIndex("by_skill", (q) => q.eq("skillId", params.skill._id))
.collect();
} catch (error) {
if (isMissingSourceLinkTableError(error)) return;
throw error;
}
const managedLinks = links.filter((link) => link.status !== "disabled");
if (managedLinks.length === 0) return;
const matchingLink = managedLinks.find((link) =>
sourceLinkMatchesProvenance({
link,
sourceProvenance: params.sourceProvenance,
sourceSync: params.sourceSync,
expectedSourceLinkId: link._id,
}),
);
if (matchingLink) return;
throw new ConvexError(
"This skill is managed by GitHub sync. Unlink source management before publishing manually.",
);
}
async function assertSourceSyncCanPublish(
ctx: MutationCtx,
sourceSync:
| {
sourceLinkId: Id<"skillSourceLinks">;
repositoryId: Id<"publisherGitHubRepositories">;
syncJobId?: Id<"githubSkillSyncJobs">;
}
| undefined,
) {
if (!sourceSync) return;
const link = await ctx.db.get(sourceSync.sourceLinkId);
if (!link || link.status === "disabled" || link.repositoryId !== sourceSync.repositoryId) {
throw new ConvexError("Source link is disabled");
}
const repo = await ctx.db.get(sourceSync.repositoryId);
if (!repo || repo.deletedAt || !repo.enabled) {
throw new ConvexError("GitHub repository link is disabled");
}
}
function isMissingSourceLinkTableError(error: unknown) {
return (
error instanceof Error &&
(/unexpected (query )?table:? skillSourceLinks/i.test(error.message) ||
error.message === "__owner_migration_sentinel_stop__")
);
}
export const insertVersion = internalMutation({
args: {
userId: v.id("users"),
@@ -10350,6 +10567,13 @@ export const insertVersion = internalMutation({
importedAt: v.number(),
}),
),
sourceSync: v.optional(
v.object({
sourceLinkId: v.id("skillSourceLinks"),
repositoryId: v.id("publisherGitHubRepositories"),
syncJobId: v.optional(v.id("githubSkillSyncJobs")),
}),
),
tags: v.optional(v.array(v.string())),
fingerprint: v.string(),
bypassNewSkillRateLimit: v.optional(v.boolean()),
@@ -10452,12 +10676,21 @@ export const insertVersion = internalMutation({
}
const now = Date.now();
await assertSourceSyncCanPublish(ctx, args.sourceSync);
let skill = await ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", normalizedSlug))
.unique();
if (skill) {
await enforceSourceManagedPublishBoundary(ctx, {
skill,
sourceProvenance: args.sourceProvenance,
sourceSync: args.sourceSync,
});
}
if (skill && skill.softDeletedAt && !(await canUserManageSkillOwner(ctx, skill, userId))) {
const unpublishedReservationExpiresAt = await getUnpublishedSlugReservationExpiresAt(
ctx,
@@ -10935,6 +11168,7 @@ export const insertVersion = internalMutation({
createdAt: now,
changelog: args.changelog,
changelogSource: args.changelogSource,
description: getFrontmatterValue(args.parsed.frontmatter, "description")?.trim(),
clawdis: args.parsed.clawdis,
// Filled later by the async analyser via
// `updateVersionApiKeyRequiredInternal`.
@@ -11507,6 +11741,7 @@ export const backfillLatestVersionSummaryApiKeyRequiredInternal = internalMutati
createdAt: version.createdAt,
changelog: version.changelog,
changelogSource: version.changelogSource,
description: skillSummaryFromSkillVersion(version),
clawdis: version.parsed?.clawdis,
apiKeyRequired: version.apiKeyRequired,
},
@@ -11532,6 +11767,6 @@ export const backfillLatestVersionSummaryApiKeyRequiredInternal = internalMutati
export const __test = {
normalizePublicListSort,
resolveRecommendedPublicListSort,
resolveRecommendedPublicListQuery,
resolvePublicListDir,
};
+413 -2
View File
@@ -13,15 +13,94 @@ vi.mock("./_generated/api", () => ({
internal: {
statsMaintenance: {
backfillSkillStatFieldsInternal: Symbol("backfillSkillStatFieldsInternal"),
backfillSkillDigestRecommendationScoresInternal: Symbol(
"backfillSkillDigestRecommendationScoresInternal",
),
backfillPackageRecommendationScoresInternal: Symbol(
"backfillPackageRecommendationScoresInternal",
),
getSkillStatBackfillStateInternal: Symbol("getSkillStatBackfillStateInternal"),
setSkillStatBackfillStateInternal: Symbol("setSkillStatBackfillStateInternal"),
reconcileSkillStarCounts: Symbol("reconcileSkillStarCounts"),
countPublicDigestPageInternal: Symbol("countPublicDigestPageInternal"),
countPublicPackageDigestPageInternal: Symbol("countPublicPackageDigestPageInternal"),
writeGlobalStatsInternal: Symbol("writeGlobalStatsInternal"),
},
},
}));
const { __test, reconcileSkillStarCountsHandler } = await import("./statsMaintenance");
const { buildSkillStatPatch } = __test;
const {
__test,
backfillPackageRecommendationScoresInternal,
backfillSkillDigestRecommendationScoresInternal,
countPublicPackageDigestPageInternal,
reconcileSkillStarCountsHandler,
runRecommendationScoreBackfillInternal,
updateGlobalStatsAction,
} = await import("./statsMaintenance");
const {
buildSkillStatPatch,
computePackageRecommendationScore,
computeSkillDigestRecommendationScore,
} = __test;
const { RECOMMENDATION_SCORE_VERSION } = await import("./lib/recommendationScore");
type WrappedHandler<TArgs, TResult> = {
handler?: (ctx: unknown, args: TArgs) => Promise<TResult>;
_handler?: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
function getHandler<TArgs, TResult>(fn: WrappedHandler<TArgs, TResult>) {
const handler = fn.handler ?? fn._handler;
if (!handler) throw new Error("Missing function handler");
return handler;
}
const countPublicPackageDigestPageHandler = getHandler<
{ cursor?: string; pageSize?: number },
{ count: number; isDone: boolean; cursor: string }
>(countPublicPackageDigestPageInternal as never);
const backfillSkillDigestRecommendationScoresHandler = getHandler<
{ cursor?: string; batchSize?: number; dryRun?: boolean },
{ scanned: number; patched: number; cursor: string | null; isDone: boolean; dryRun: boolean }
>(backfillSkillDigestRecommendationScoresInternal as never);
const backfillPackageRecommendationScoresHandler = getHandler<
{ cursor?: string; batchSize?: number; dryRun?: boolean },
{ scanned: number; patched: number; cursor: string | null; isDone: boolean; dryRun: boolean }
>(backfillPackageRecommendationScoresInternal as never);
const runRecommendationScoreBackfillHandler = getHandler<
{
skillCursor?: string;
packageCursor?: string;
skillsDone?: boolean;
packagesDone?: boolean;
batchSize?: number;
maxBatches?: number;
dryRun?: boolean;
},
{
ok: true;
dryRun: boolean;
scoreVersion: number;
isDone: boolean;
skillsDone: boolean;
packagesDone: boolean;
skillCursor: string | null;
packageCursor: string | null;
stats: {
skills: { scanned: number; patched: number; batches: number };
packages: { scanned: number; patched: number; batches: number };
};
}
>(runRecommendationScoreBackfillInternal as never);
const updateGlobalStatsActionHandler = getHandler<
Record<string, never>,
{ activeSkillsCount: number; activePluginsCount: number }
>(updateGlobalStatsAction as never);
// ---------------------------------------------------------------------------
// Helpers
@@ -47,6 +126,14 @@ function makeSkill(overrides: {
return overrides as never;
}
function oldRecommendationScore(stats: { downloads: number; installs: number; stars: number }) {
return Math.round(
Math.log1p(stats.downloads) * 100 +
Math.log1p(stats.installs) * 60 +
Math.log1p(stats.stars) * 120,
);
}
// ---------------------------------------------------------------------------
// buildSkillStatPatch
// ---------------------------------------------------------------------------
@@ -149,6 +236,330 @@ describe("buildSkillStatPatch", () => {
});
});
describe("public package digest count maintenance", () => {
it("counts only public code and bundle plugin digests", async () => {
const paginate = vi.fn().mockResolvedValue({
page: [
{
family: "code-plugin",
channel: "community",
scanStatus: "clean",
softDeletedAt: undefined,
},
{
family: "bundle-plugin",
channel: "official",
scanStatus: "not-run",
softDeletedAt: undefined,
},
{
family: "skill",
channel: "community",
scanStatus: "clean",
softDeletedAt: undefined,
},
{
family: "code-plugin",
channel: "private",
scanStatus: "clean",
softDeletedAt: undefined,
},
{
family: "bundle-plugin",
channel: "community",
scanStatus: "malicious",
softDeletedAt: undefined,
},
{
family: "code-plugin",
channel: "community",
scanStatus: "clean",
softDeletedAt: 123,
},
],
continueCursor: "next",
isDone: true,
});
const ctx = {
db: {
query: vi.fn((table: string) => {
expect(table).toBe("packageSearchDigest");
return {
paginate,
};
}),
},
};
const result = await countPublicPackageDigestPageHandler(ctx, {});
expect(result).toEqual({ count: 2, isDone: true, cursor: "next" });
});
it("writes skills and plugin counts in one global stats update", async () => {
const runQuery = vi
.fn()
.mockResolvedValueOnce({ count: 70_300, isDone: true, cursor: "" })
.mockResolvedValueOnce({ count: 321, isDone: true, cursor: "" });
const runMutation = vi.fn();
const result = await updateGlobalStatsActionHandler({ runQuery, runMutation }, {});
expect(result).toEqual({ activeSkillsCount: 70_300, activePluginsCount: 321 });
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
activeSkillsCount: 70_300,
activePluginsCount: 321,
});
});
});
describe("recommendation score backfills", () => {
it("computes skill digest recommendation scores from top-level stats first", () => {
expect(
computeSkillDigestRecommendationScore({
statsDownloads: 43_080,
statsInstallsAllTime: 2,
statsStars: 0,
stats: { downloads: 1, installsAllTime: 1, installsCurrent: 0, stars: 20 },
} as never),
).toBeGreaterThan(
computeSkillDigestRecommendationScore({
statsDownloads: 1,
statsInstallsAllTime: 0,
statsStars: 1,
stats: { downloads: 43_080, installsAllTime: 2, installsCurrent: 0, stars: 0 },
} as never),
);
});
it("patches stale skill digest recommendation scores in bounded pages", async () => {
const patch = vi.fn();
const stats = { downloads: 393, installsAllTime: 74, installsCurrent: 0, stars: 0 };
const paginate = vi.fn().mockResolvedValue({
page: [
{
_id: "skillSearchDigest:one",
statsDownloads: stats.downloads,
statsInstallsAllTime: stats.installsAllTime,
statsStars: 0,
recommendedScore: oldRecommendationScore({
downloads: stats.downloads,
installs: stats.installsAllTime,
stars: 0,
}),
stats,
},
],
isDone: false,
continueCursor: "next",
});
const ctx = {
db: {
query: vi.fn((table: string) => {
expect(table).toBe("skillSearchDigest");
return { order: vi.fn(() => ({ paginate })) };
}),
patch,
},
};
const result = await backfillSkillDigestRecommendationScoresHandler(ctx, {
cursor: "current",
batchSize: 1,
});
expect(result).toMatchObject({
scanned: 1,
patched: 1,
cursor: "next",
isDone: false,
dryRun: false,
});
expect(paginate).toHaveBeenCalledWith({ cursor: "current", numItems: 1 });
expect(patch).toHaveBeenCalledWith("skillSearchDigest:one", {
recommendedScore: computeSkillDigestRecommendationScore({
statsDownloads: stats.downloads,
statsInstallsAllTime: stats.installsAllTime,
statsStars: 0,
stats,
} as never),
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
});
});
it("dry-runs package recommendation score backfills without patching", async () => {
const patch = vi.fn();
const paginate = vi.fn().mockResolvedValue({
page: [
{
_id: "packages:one",
stats: { downloads: 100, installs: 5, stars: 2, versions: 1 },
recommendedScore: -1,
},
],
isDone: true,
continueCursor: "",
});
const ctx = {
db: {
query: vi.fn((table: string) => {
expect(table).toBe("packages");
return { order: vi.fn(() => ({ paginate })) };
}),
patch,
},
};
const result = await backfillPackageRecommendationScoresHandler(ctx, {
batchSize: 5,
dryRun: true,
});
expect(result).toMatchObject({
scanned: 1,
patched: 1,
cursor: null,
isDone: true,
dryRun: true,
});
expect(paginate).toHaveBeenCalledWith({ cursor: null, numItems: 5 });
expect(patch).not.toHaveBeenCalled();
expect(
computePackageRecommendationScore({
stats: { downloads: 100, installs: 5, stars: 2, versions: 1 },
} as never),
).toBeGreaterThan(0);
});
it("patches old-formula package recommendation scores", async () => {
const patch = vi.fn();
const stats = { downloads: 393, installs: 74, stars: 0, versions: 1 };
const paginate = vi.fn().mockResolvedValue({
page: [
{
_id: "packages:one",
stats,
recommendedScore: oldRecommendationScore(stats),
},
],
isDone: true,
continueCursor: "",
});
const ctx = {
db: {
query: vi.fn((table: string) => {
expect(table).toBe("packages");
return { order: vi.fn(() => ({ paginate })) };
}),
patch,
},
};
const result = await backfillPackageRecommendationScoresHandler(ctx, {
batchSize: 5,
});
expect(result).toMatchObject({
scanned: 1,
patched: 1,
cursor: null,
isDone: true,
dryRun: false,
});
expect(patch).toHaveBeenCalledWith("packages:one", {
recommendedScore: computePackageRecommendationScore({ stats } as never),
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
});
});
it("runs skill and package recommendation score backfills with resumable cursors", async () => {
const runMutation = vi
.fn()
.mockResolvedValueOnce({
scanned: 5,
patched: 4,
cursor: "skill-next",
isDone: false,
})
.mockResolvedValueOnce({
scanned: 3,
patched: 2,
cursor: null,
isDone: true,
})
.mockResolvedValueOnce({
scanned: 2,
patched: 1,
cursor: null,
isDone: true,
});
const result = await runRecommendationScoreBackfillHandler(
{ runMutation },
{ batchSize: 10, maxBatches: 2, dryRun: true },
);
expect(result).toMatchObject({
ok: true,
dryRun: true,
scoreVersion: RECOMMENDATION_SCORE_VERSION,
isDone: true,
skillsDone: true,
packagesDone: true,
skillCursor: null,
packageCursor: null,
stats: {
skills: { scanned: 7, patched: 5, batches: 2 },
packages: { scanned: 3, patched: 2, batches: 1 },
},
});
expect(runMutation).toHaveBeenCalledTimes(3);
expect(runMutation.mock.calls.map((call) => call[1])).toEqual([
{ cursor: undefined, batchSize: 10, dryRun: true },
{ cursor: undefined, batchSize: 10, dryRun: true },
{ cursor: "skill-next", batchSize: 10, dryRun: true },
]);
});
it("skips completed recommendation score backfill sides when resuming", async () => {
const runMutation = vi.fn().mockResolvedValueOnce({
scanned: 2,
patched: 1,
cursor: null,
isDone: true,
});
const result = await runRecommendationScoreBackfillHandler(
{ runMutation },
{
skillCursor: "skill-next",
skillsDone: false,
packagesDone: true,
batchSize: 10,
maxBatches: 1,
},
);
expect(result).toMatchObject({
isDone: true,
skillsDone: true,
packagesDone: true,
skillCursor: null,
packageCursor: null,
stats: {
skills: { scanned: 2, patched: 1, batches: 1 },
packages: { scanned: 0, patched: 0, batches: 0 },
},
});
expect(runMutation).toHaveBeenCalledTimes(1);
expect(runMutation.mock.calls[0]?.[1]).toEqual({
cursor: "skill-next",
batchSize: 10,
dryRun: false,
});
});
});
// ---------------------------------------------------------------------------
// reconcileSkillStarCountsHandler
// ---------------------------------------------------------------------------
+255 -10
View File
@@ -3,7 +3,16 @@ import { internal } from "./_generated/api";
import type { Doc } from "./_generated/dataModel";
import type { ActionCtx } from "./_generated/server";
import { internalAction, internalMutation, internalQuery } from "./functions";
import { isPublicSkillDoc, setGlobalPublicSkillsCount } from "./lib/globalStats";
import {
isPublicPluginDoc,
isPublicSkillDoc,
setGlobalPublicPluginsCount,
setGlobalPublicSkillsCount,
} from "./lib/globalStats";
import {
computeRecommendationScore,
RECOMMENDATION_SCORE_VERSION,
} from "./lib/recommendationScore";
const DEFAULT_BATCH_SIZE = 200;
const MAX_BATCH_SIZE = 1000;
@@ -177,6 +186,176 @@ export const runSkillStatBackfillInternal: ReturnType<typeof internalAction> = i
handler: runSkillStatBackfillInternalHandler,
});
export const backfillSkillDigestRecommendationScoresInternal = internalMutation({
args: {
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
dryRun: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
const { page, isDone, continueCursor } = await ctx.db
.query("skillSearchDigest")
.order("asc")
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
let patched = 0;
for (const digest of page) {
const recommendedScore = computeSkillDigestRecommendationScore(digest);
if (
digest.recommendedScore === recommendedScore &&
digest.recommendedScoreVersion === RECOMMENDATION_SCORE_VERSION
) {
continue;
}
patched += 1;
if (!args.dryRun) {
await ctx.db.patch(digest._id, {
recommendedScore,
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
});
}
}
return {
ok: true as const,
dryRun: args.dryRun === true,
scanned: page.length,
patched,
cursor: isDone ? null : continueCursor,
isDone,
};
},
});
export const backfillPackageRecommendationScoresInternal = internalMutation({
args: {
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
dryRun: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
const { page, isDone, continueCursor } = await ctx.db
.query("packages")
.order("asc")
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
let patched = 0;
for (const pkg of page) {
const recommendedScore = computePackageRecommendationScore(pkg);
if (
pkg.recommendedScore === recommendedScore &&
pkg.recommendedScoreVersion === RECOMMENDATION_SCORE_VERSION
) {
continue;
}
patched += 1;
if (!args.dryRun) {
await ctx.db.patch(pkg._id, {
recommendedScore,
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
});
}
}
return {
ok: true as const,
dryRun: args.dryRun === true,
scanned: page.length,
patched,
cursor: isDone ? null : continueCursor,
isDone,
};
},
});
type RecommendationScoreBackfillArgs = {
skillCursor?: string;
packageCursor?: string;
skillsDone?: boolean;
packagesDone?: boolean;
batchSize?: number;
maxBatches?: number;
dryRun?: boolean;
};
type RecommendationScoreBackfillTotals = {
scanned: number;
patched: number;
batches: number;
};
export const runRecommendationScoreBackfillInternal: ReturnType<typeof internalAction> =
internalAction({
args: {
skillCursor: v.optional(v.string()),
packageCursor: v.optional(v.string()),
skillsDone: v.optional(v.boolean()),
packagesDone: v.optional(v.boolean()),
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
dryRun: v.optional(v.boolean()),
},
handler: async (ctx, args: RecommendationScoreBackfillArgs) => {
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES);
const dryRun = args.dryRun === true;
let skillsDone = args.skillsDone === true;
let packagesDone = args.packagesDone === true;
let skillCursor: string | null = skillsDone ? null : (args.skillCursor ?? null);
let packageCursor: string | null = packagesDone ? null : (args.packageCursor ?? null);
const skills: RecommendationScoreBackfillTotals = { scanned: 0, patched: 0, batches: 0 };
const packages: RecommendationScoreBackfillTotals = { scanned: 0, patched: 0, batches: 0 };
for (let i = 0; i < maxBatches && (!skillsDone || !packagesDone); i += 1) {
if (!skillsDone) {
const result = (await ctx.runMutation(
internal.statsMaintenance.backfillSkillDigestRecommendationScoresInternal,
{
cursor: skillCursor ?? undefined,
batchSize,
dryRun,
},
)) as { scanned: number; patched: number; cursor: string | null; isDone: boolean };
skills.scanned += result.scanned;
skills.patched += result.patched;
skills.batches += 1;
skillCursor = result.cursor;
skillsDone = result.isDone;
}
if (!packagesDone) {
const result = (await ctx.runMutation(
internal.statsMaintenance.backfillPackageRecommendationScoresInternal,
{
cursor: packageCursor ?? undefined,
batchSize,
dryRun,
},
)) as { scanned: number; patched: number; cursor: string | null; isDone: boolean };
packages.scanned += result.scanned;
packages.patched += result.patched;
packages.batches += 1;
packageCursor = result.cursor;
packagesDone = result.isDone;
}
}
return {
ok: true as const,
dryRun,
scoreVersion: RECOMMENDATION_SCORE_VERSION,
isDone: skillsDone && packagesDone,
skillsDone,
packagesDone,
skillCursor,
packageCursor,
stats: { skills, packages },
};
},
});
function buildSkillStatPatch(skill: Doc<"skills">) {
const stats = skill.stats;
@@ -228,6 +407,22 @@ function buildSkillStatPatch(skill: Doc<"skills">) {
};
}
function computeSkillDigestRecommendationScore(digest: Doc<"skillSearchDigest">) {
return computeRecommendationScore({
downloads: digest.statsDownloads ?? digest.stats.downloads,
installs: digest.statsInstallsAllTime ?? digest.stats.installsAllTime ?? 0,
stars: digest.statsStars ?? digest.stats.stars,
});
}
function computePackageRecommendationScore(pkg: Doc<"packages">) {
return computeRecommendationScore({
downloads: pkg.stats.downloads,
installs: pkg.stats.installs,
stars: pkg.stats.stars,
});
}
/**
* Reconcile skill stats by counting actual records in source-of-truth tables.
*
@@ -352,6 +547,8 @@ function clampInt(value: number, min: number, max: number) {
// Exported for unit testing only — not part of the public API.
export const __test = {
buildSkillStatPatch,
computeSkillDigestRecommendationScore,
computePackageRecommendationScore,
};
/**
@@ -377,11 +574,38 @@ export const countPublicDigestPageInternal = internalQuery({
},
});
export const countPublicPackageDigestPageInternal = internalQuery({
args: { cursor: v.optional(v.string()), pageSize: v.optional(v.number()) },
handler: async (ctx, args) => {
const pageSize = clampInt(args.pageSize ?? 1000, 100, 2000);
const { page, isDone, continueCursor } = await ctx.db
.query("packageSearchDigest")
.paginate({ cursor: args.cursor ?? null, numItems: pageSize });
let count = 0;
for (const digest of page) {
if (isPublicPluginDoc(digest)) count++;
}
return { count, isDone, cursor: continueCursor };
},
});
/** Write the reconciled global stats count. */
export const writeGlobalStatsInternal = internalMutation({
args: { count: v.number() },
args: {
count: v.optional(v.number()),
activeSkillsCount: v.optional(v.number()),
activePluginsCount: v.optional(v.number()),
},
handler: async (ctx, args) => {
await setGlobalPublicSkillsCount(ctx, args.count);
if (args.activeSkillsCount !== undefined) {
await setGlobalPublicSkillsCount(ctx, args.activeSkillsCount);
} else if (args.count !== undefined) {
await setGlobalPublicSkillsCount(ctx, args.count);
}
if (args.activePluginsCount !== undefined) {
await setGlobalPublicPluginsCount(ctx, args.activePluginsCount);
}
},
});
@@ -393,22 +617,43 @@ export const writeGlobalStatsInternal = internalMutation({
export const updateGlobalStatsAction = internalAction({
args: {},
handler: async (ctx) => {
let total = 0;
let cursor: string | undefined;
let activeSkillsCount = 0;
let skillCursor: string | undefined;
// eslint-disable-next-line no-constant-condition
while (true) {
const result = (await ctx.runQuery(internal.statsMaintenance.countPublicDigestPageInternal, {
cursor,
cursor: skillCursor,
pageSize: 1000,
})) as { count: number; isDone: boolean; cursor: string };
total += result.count;
activeSkillsCount += result.count;
if (result.isDone) break;
cursor = result.cursor;
skillCursor = result.cursor;
}
await ctx.runMutation(internal.statsMaintenance.writeGlobalStatsInternal, { count: total });
return { count: total };
let activePluginsCount = 0;
let pluginCursor: string | undefined;
// eslint-disable-next-line no-constant-condition
while (true) {
const result = (await ctx.runQuery(
internal.statsMaintenance.countPublicPackageDigestPageInternal,
{
cursor: pluginCursor,
pageSize: 1000,
},
)) as { count: number; isDone: boolean; cursor: string };
activePluginsCount += result.count;
if (result.isDone) break;
pluginCursor = result.cursor;
}
await ctx.runMutation(internal.statsMaintenance.writeGlobalStatsInternal, {
activeSkillsCount,
activePluginsCount,
});
return { activeSkillsCount, activePluginsCount };
},
});
+2 -1
View File
@@ -171,7 +171,8 @@ Limits (server-side):
## Slugs
- Derived from folder name by default.
- Must be lowercase and URL-safe: `^[a-z0-9][a-z0-9-]*$`.
- Package scopes must match the ClawHub publisher handle exactly. Publisher handles can use lowercase letters, numbers, hyphens, dots, and underscores; they must start and end with a lowercase letter or number.
- Package slugs must be lowercase and npm-safe, for example `@example.tools/demo-plugin` or `demo-plugin`.
## Versioning + tags
@@ -118,6 +118,40 @@ describe("cmdCreateOrg", () => {
);
});
it("creates org publishers with npm-compatible handles", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
ok: true,
publisherId: "publishers:example.tools",
handle: "example.tools",
created: true,
migrated: false,
trusted: false,
member: {
userId: "users:vincent",
handle: "vincentkoc",
role: "owner",
},
});
await cmdCreateOrg(makeGlobalOpts(), "@Example.Tools", {
displayName: "Example Tools",
member: "vincentkoc",
json: true,
});
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
"https://clawhub.ai",
expect.objectContaining({
body: expect.objectContaining({
handle: "example.tools",
displayName: "Example Tools",
memberHandle: "vincentkoc",
}),
}),
expect.anything(),
);
});
it("requires a valid org member role", async () => {
await expect(
cmdCreateOrg(makeGlobalOpts(), "opik", {
@@ -396,14 +430,14 @@ describe("cmdRepairScopedPackages", () => {
const csv = await withCsv(
[
"packageName,intendedOrg,legacyOwner,orgDisplayName",
"@opik/opik-openclaw,opik,vincentkoc,Opik",
"@example.tools/demo-plugin,example.tools,vincentkoc,Example Tools",
].join("\n"),
);
httpMocks.apiRequest
.mockResolvedValueOnce({
ok: true,
publisherId: "publishers:opik",
handle: "opik",
publisherId: "publishers:example.tools",
handle: "example.tools",
created: false,
migrated: false,
trusted: false,
@@ -413,49 +447,53 @@ describe("cmdRepairScopedPackages", () => {
ok: true,
dryRun: true,
source: {
packageId: "packages:opik",
name: "@opik/opik-openclaw",
runtimeId: "opik-openclaw",
packageId: "packages:example-tools",
name: "@example.tools/demo-plugin",
runtimeId: "demo-plugin",
ownerUserId: "users:vincent",
ownerPublisherId: "publishers:vincent",
channel: "community",
softDeletedAt: null,
},
target: {
packageId: "packages:opik",
name: "@opik/opik-openclaw",
runtimeId: "opik-openclaw",
packageId: "packages:example-tools",
name: "@example.tools/demo-plugin",
runtimeId: "demo-plugin",
ownerUserId: "users:vincent",
ownerPublisherId: "publishers:vincent",
channel: "community",
softDeletedAt: null,
},
retiredName: null,
operations: [{ action: "transfer-owner", packageId: "packages:opik", owner: "opik" }],
operations: [
{ action: "transfer-owner", packageId: "packages:example-tools", owner: "example.tools" },
],
})
.mockResolvedValueOnce({
ok: true,
dryRun: false,
source: {
packageId: "packages:opik",
name: "@opik/opik-openclaw",
runtimeId: "opik-openclaw",
packageId: "packages:example-tools",
name: "@example.tools/demo-plugin",
runtimeId: "demo-plugin",
ownerUserId: "users:vincent",
ownerPublisherId: "publishers:vincent",
channel: "community",
softDeletedAt: null,
},
target: {
packageId: "packages:opik",
name: "@opik/opik-openclaw",
runtimeId: "opik-openclaw",
packageId: "packages:example-tools",
name: "@example.tools/demo-plugin",
runtimeId: "demo-plugin",
ownerUserId: "users:vincent",
ownerPublisherId: "publishers:vincent",
channel: "community",
softDeletedAt: null,
},
retiredName: null,
operations: [{ action: "transfer-owner", packageId: "packages:opik", owner: "opik" }],
operations: [
{ action: "transfer-owner", packageId: "packages:example-tools", owner: "example.tools" },
],
});
try {
@@ -472,8 +510,8 @@ describe("cmdRepairScopedPackages", () => {
method: "POST",
path: "/api/v1/users/publisher",
body: {
handle: "opik",
displayName: "Opik",
handle: "example.tools",
displayName: "Example Tools",
memberHandle: "vincentkoc",
memberRole: "owner",
},
@@ -484,11 +522,11 @@ describe("cmdRepairScopedPackages", () => {
2,
"https://clawhub.ai",
expect.objectContaining({
path: "/api/v1/packages/%40opik%2Fopik-openclaw/repair-name",
path: "/api/v1/packages/%40example.tools%2Fdemo-plugin/repair-name",
body: {
nextName: "@opik/opik-openclaw",
owner: "opik",
reason: "Move legacy personal package into @opik",
nextName: "@example.tools/demo-plugin",
owner: "example.tools",
reason: "Move legacy personal package into @example.tools",
dryRun: true,
},
}),
@@ -498,7 +536,7 @@ describe("cmdRepairScopedPackages", () => {
3,
"https://clawhub.ai",
expect.objectContaining({
path: "/api/v1/packages/%40opik%2Fopik-openclaw/repair-name",
path: "/api/v1/packages/%40example.tools%2Fdemo-plugin/repair-name",
body: expect.objectContaining({ dryRun: false }),
}),
expect.anything(),
+1 -1
View File
@@ -486,7 +486,7 @@ const publisherCmd = registerCommandGroup(program, ["publisher"])
registerCommand(publisherCmd, ["publisher", "create"])
.description("Create an org publisher you own")
.argument("<handle>", "Publisher handle, for example opik")
.argument("<handle>", "Publisher handle, for example example.tools")
.option("--display-name <name>", "Publisher display name")
.option("--json", "Output JSON")
.action(async (handle, options) => {
+22
View File
@@ -303,6 +303,7 @@ export const ApiV1SkillListResponseSchema = type({
slug: "string",
displayName: "string",
summary: "string|null?",
description: "string|null?",
tags: "unknown",
stats: "unknown",
createdAt: "number",
@@ -313,6 +314,16 @@ export const ApiV1SkillListResponseSchema = type({
changelog: "string",
license: '"MIT-0"|null?',
}).optional(),
metadata: type({
setup: type({
key: "string",
required: "boolean",
}).array(),
os: "string[]|null?",
systems: "string[]|null?",
})
.or("null")
.optional(),
}).array(),
nextCursor: "string|null",
});
@@ -322,6 +333,7 @@ export const ApiV1SkillResponseSchema = type({
slug: "string",
displayName: "string",
summary: "string|null?",
description: "string|null?",
tags: "unknown",
stats: "unknown",
createdAt: "number",
@@ -333,6 +345,16 @@ export const ApiV1SkillResponseSchema = type({
changelog: "string",
license: '"MIT-0"|null?',
}).or("null"),
metadata: type({
setup: type({
key: "string",
required: "boolean",
}).array(),
os: "string[]|null?",
systems: "string[]|null?",
})
.or("null")
.optional(),
owner: type({
handle: "string|null",
displayName: "string|null?",
+18
View File
@@ -235,12 +235,21 @@ export declare const ApiV1SkillListResponseSchema: import("arktype/internal/vari
createdAt: number;
updatedAt: number;
summary?: string | null | undefined;
description?: string | null | undefined;
latestVersion?: {
version: string;
createdAt: number;
changelog: string;
license?: "MIT-0" | null | undefined;
} | undefined;
metadata?: {
setup: {
key: string;
required: boolean;
}[];
os?: string[] | null | undefined;
systems?: string[] | null | undefined;
} | null | undefined;
}[];
nextCursor: string | null;
}, {}>;
@@ -253,6 +262,7 @@ export declare const ApiV1SkillResponseSchema: import("arktype/internal/variants
createdAt: number;
updatedAt: number;
summary?: string | null | undefined;
description?: string | null | undefined;
} | null;
latestVersion: {
version: string;
@@ -265,6 +275,14 @@ export declare const ApiV1SkillResponseSchema: import("arktype/internal/variants
displayName?: string | null | undefined;
image?: string | null | undefined;
} | null;
metadata?: {
setup: {
key: string;
required: boolean;
}[];
os?: string[] | null | undefined;
systems?: string[] | null | undefined;
} | null | undefined;
moderation?: {
isSuspicious: boolean;
isMalwareBlocked: boolean;
+22
View File
@@ -207,6 +207,7 @@ export const ApiV1SkillListResponseSchema = type({
slug: "string",
displayName: "string",
summary: "string|null?",
description: "string|null?",
tags: "unknown",
stats: "unknown",
createdAt: "number",
@@ -217,6 +218,16 @@ export const ApiV1SkillListResponseSchema = type({
changelog: "string",
license: SkillPlatformLicenseSchema.or("null").optional(),
}).optional(),
metadata: type({
setup: type({
key: "string",
required: "boolean",
}).array(),
os: "string[]|null?",
systems: "string[]|null?",
})
.or("null")
.optional(),
}).array(),
nextCursor: "string|null",
});
@@ -225,6 +236,7 @@ export const ApiV1SkillResponseSchema = type({
slug: "string",
displayName: "string",
summary: "string|null?",
description: "string|null?",
tags: "unknown",
stats: "unknown",
createdAt: "number",
@@ -236,6 +248,16 @@ export const ApiV1SkillResponseSchema = type({
changelog: "string",
license: SkillPlatformLicenseSchema.or("null").optional(),
}).or("null"),
metadata: type({
setup: type({
key: "string",
required: "boolean",
}).array(),
os: "string[]|null?",
systems: "string[]|null?",
})
.or("null")
.optional(),
owner: type({
handle: "string|null",
displayName: "string|null?",
File diff suppressed because one or more lines are too long
+22
View File
@@ -245,6 +245,7 @@ export const ApiV1SkillListResponseSchema = type({
slug: "string",
displayName: "string",
summary: "string|null?",
description: "string|null?",
tags: "unknown",
stats: "unknown",
createdAt: "number",
@@ -255,6 +256,16 @@ export const ApiV1SkillListResponseSchema = type({
changelog: "string",
license: SkillPlatformLicenseSchema.or("null").optional(),
}).optional(),
metadata: type({
setup: type({
key: "string",
required: "boolean",
}).array(),
os: "string[]|null?",
systems: "string[]|null?",
})
.or("null")
.optional(),
}).array(),
nextCursor: "string|null",
});
@@ -264,6 +275,7 @@ export const ApiV1SkillResponseSchema = type({
slug: "string",
displayName: "string",
summary: "string|null?",
description: "string|null?",
tags: "unknown",
stats: "unknown",
createdAt: "number",
@@ -275,6 +287,16 @@ export const ApiV1SkillResponseSchema = type({
changelog: "string",
license: SkillPlatformLicenseSchema.or("null").optional(),
}).or("null"),
metadata: type({
setup: type({
key: "string",
required: "boolean",
}).array(),
os: "string[]|null?",
systems: "string[]|null?",
})
.or("null")
.optional(),
owner: type({
handle: "string|null",
displayName: "string|null?",
+1
View File
@@ -21,6 +21,7 @@ into `docs/` and leave only the design record here.
- `orgs.md`: org, publisher membership, and scoped identity plan.
- `github-import.md`: GitHub import feature spec.
- `github-backed-skills.md`: source-backed GitHub skills catalog and install invariants.
- `github-app-skill-sync.md`: GitHub App org repository sync plan for source-managed skills.
- `diffing.md`: skill version diffing UI/API design.
- `slug-routing.md`: internal web route precedence and plugin alias contract.
- `ci.md`: PR check and production deploy audit-tag policy.
+8
View File
@@ -20,6 +20,14 @@ into strings such as `"undefined"` and used as `authAccounts.providerAccountId`.
Malformed GitHub API responses during provider outages are authentication
failures, not anonymous or linkable GitHub identities.
When reading GitHub auth accounts for authorization-sensitive checks, duplicate
`authAccounts` rows for the same ClawHub user may only be treated as recoverable
when every row in a bounded reconciliation window has the same GitHub
`providerAccountId`. Any disagreement or overflow beyond that bounded window
means the account binding is ambiguous and must fail closed with
operator-visible diagnostics instead of choosing by creation time or any other
arbitrary tie breaker.
`users.me`, protected mutations, ownership checks, and API token issuance must
derive the actor server-side from Convex Auth (`getAuthUserId` via
`requireUser`/`getOptionalActiveAuthUserId`). They must not accept client-supplied
+175
View File
@@ -0,0 +1,175 @@
---
summary: "Durable invariants for GitHub App repository sync of source-managed skills."
read_when:
- Changing GitHub App install, webhook, or repository sync behavior
- Changing source-managed skill publish boundaries
- Reviewing org/manual skill ownership interactions
---
# GitHub App Skill Sync
This note captures the behavior that must survive refactors. Public setup docs
belong in `docs/`; this file is for maintainer-facing invariants and trust
boundaries.
## Core Invariant
Linking a ClawHub org to GitHub does not make every org skill source-managed.
Only skills with a non-disabled `skillSourceLinks` row for a linked repository
path are controlled by GitHub sync.
Manual, CLI-managed, imported, or otherwise non-source-linked skills can remain
in the same org. Repository sync must not create, update, delete, rename,
restore, or retag those skills unless an org admin explicitly adopts the skill
into a source link first.
## Source Ownership
- A repository sync job may publish only through an exact source link for that
repository and path.
- A manual publish to a source-managed skill is rejected unless the source link
has been disabled first.
- Disabled source links are no longer authoritative. After unlinking, normal
manual publish behavior applies.
- A discovered repository candidate that collides with an existing manual org
skill slug becomes a source-link conflict. It must not overwrite the manual
skill.
- Adopting an existing manual skill into sync requires admin rights on the
publisher, a matching skill owner publisher, a matching slug, and no other
non-disabled source link for that skill.
- GitHub sync still uses the normal publish pipeline, including parser,
scanner, quality, moderation, ownership, and version rules. Sync must not lift
moderation or bypass bans.
## Install Handshake
The GitHub App setup handshake is intentionally multi-step:
1. A publisher admin starts setup from ClawHub.
2. ClawHub creates a short-lived HMAC-signed state and stores only a hash plus
nonce, publisher id, requesting user id, optional target GitHub account id,
and expiry.
3. GitHub redirects back with an installation id and the signed state.
4. ClawHub verifies the state signature, stored hash, nonce, expiry,
single-consumption status, requesting user, and current publisher admin role.
5. ClawHub fetches the installation with a GitHub App JWT.
6. User-account installs are accepted only when the installation account id
matches the current ClawHub user's GitHub provider account id.
7. Organization installs require a target GitHub account id in the setup state
and a signed GitHub installation webhook claim whose installation account id
matches that target and whose sender account id matches the current ClawHub
user's GitHub provider account id.
The webhook claim is what ties an org installation redirect to the GitHub user
who performed the install. If the redirect outruns the webhook, completion
should fail cleanly and can be retried after the webhook arrives.
Repository links created by an installation are disabled by default. A publisher
admin must explicitly enable sync for each repository.
## UI Rollout Gate
During internal rollout, the ClawHub UI must expose GitHub Sync controls only to
users who are both:
- owners or admins of the target ClawHub org publisher; and
- members of the `openclaw` ClawHub publisher.
This gate is intentionally a rollout/discoverability gate, not the durable
authorization boundary. Backend GitHub sync functions must continue to enforce
the target publisher admin role themselves.
## Runtime Proof Before Wide Release
Before removing the internal UI rollout gate or treating the feature as broadly
available, capture redacted proof from a real configured GitHub App install:
1. Start setup from an eligible ClawHub org publisher and record the generated
GitHub App install URL with the signed `state` value redacted.
2. Complete an org installation and confirm the callback succeeds only after the
matching signed installation webhook claim is received.
3. Confirm newly discovered repositories are present but disabled by default.
4. Enable one repository, configure branch/roots, and queue a manual sync.
5. Push a commit to the configured ref and confirm GitHub webhook delivery
queues or coalesces the repository sync job.
6. Confirm the sync creates or updates the expected source-managed skill version
through the normal publish/scanner pipeline.
7. Attempt a manual publish to the active source-managed skill and confirm it is
rejected until the source link is disabled.
8. Remove or suspend repository access and confirm affected repository/source
links are disabled while unrelated manual org skills remain untouched.
Acceptable PR proof is redacted terminal output, Convex logs, GitHub webhook
delivery screenshots, ClawHub UI screenshots, or a short recording that covers
the install, webhook claim, repository enablement, sync publish, and manual
publish blocking path.
## Webhook Trust Boundary
- Verify `X-Hub-Signature-256` for every GitHub webhook.
- Deduplicate by `X-GitHub-Delivery`, but mark a delivery processed only after
event work succeeds. Failed deliveries stay retryable.
- Treat GitHub installation id and repository id as stable identities. Repo
names may change.
- Repository rename events must update both repository rows and source-link
`repoFullName` values so source provenance checks continue to match.
- Repository removal, installation deletion, and installation suspension disable
affected repository links and their non-disabled source links. Manual org
skills remain untouched.
## Sync Semantics
- Push webhooks queue sync only for enabled repository links whose configured
ref matches the pushed branch/ref.
- Deletion pushes with GitHub's all-zero `after` SHA are ignored.
- Older queued push jobs for the same repository/ref are cancelled when a newer
push is queued.
- Sync jobs are serialized per repository so concurrent pushes cannot derive
and publish the same next version.
- Sync downloads the GitHub archive for the exact commit being processed.
- Candidate discovery can scan all configured roots to mark missing source
links correctly, but publishing work may be capped per job.
- If candidate file fingerprints are unchanged, sync validates that the source
link is still publishable and then records the latest commit without storing
duplicate blobs or creating a new version.
- On failure, repository metadata must preserve the last successful sync commit
and timestamp.
## Version Security Boundary
Each GitHub sync publish creates an ordinary `skillVersions` row and queues the
normal security scanners for that exact version. Scanner completion must only
promote scan outcomes onto the skill-level moderation row when the scanned
version is still the skill's latest version, so an older async scan result
cannot overwrite the current latest verdict.
Public artifact access must still enforce the requested version's own scan
state. Download, card, raw file, and version-detail endpoints must block a
historical version when that version's stored ClawScan or VirusTotal fields are
malicious or explicitly pending, even if a newer latest version currently
leaves the skill active. Static-scan-only findings remain advisory when
ClawScan/VT do not block the version. Scan and verification endpoints may
remain readable for blocked versions so users and automation can inspect the
reason.
## Required GitHub App Shape
Minimum GitHub App permissions:
- Contents: read-only
- Metadata: read-only
Webhook events:
- `push`
- `installation`
- `installation_repositories`
- `repository`
Required Convex environment:
- `GITHUB_APP_ID`
- `GITHUB_APP_SLUG`
- `GITHUB_APP_PRIVATE_KEY`
- `GITHUB_APP_WEBHOOK_SECRET`
- optional `GITHUB_APP_STATE_SECRET`
+4 -2
View File
@@ -219,6 +219,8 @@ describe("Header", () => {
expect(screen.queryByText("Dashboard")).toBeNull();
expect(screen.queryByText("Manage")).toBeNull();
expect(screen.getByPlaceholderText("Search skills and plugins")).toBeTruthy();
expect(document.querySelector(".navbar-tabs")?.textContent).toContain("Publishers");
expect(document.querySelector(".navbar-tabs-secondary")?.textContent).toBe("Docs");
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
expect(setModeMock).toHaveBeenCalledWith("light");
@@ -424,8 +426,8 @@ describe("Header", () => {
.map((element) => element.textContent?.trim())
.filter((label): label is string => Boolean(label));
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
expect(labels.slice(3, 5)).toEqual(["Publishers", "Docs"]);
expect(labels.slice(0, 4)).toEqual(["Home", "Skills", "Plugins", "Publishers"]);
expect(labels[4]).toBe("Docs");
});
it("links starred skills from the signed-in avatar menu", () => {
+415 -139
View File
@@ -1,8 +1,13 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
convexReactMocks,
resetConvexReactMocks,
setupDefaultConvexReactMocks,
} from "./helpers/convexReactMocks";
const fetchPluginCatalogMock = vi.fn();
const fetchFeaturedPluginsMock = vi.fn();
@@ -17,40 +22,34 @@ const redirectMock = vi.fn((args: unknown) => {
throw error;
});
let searchMock: Record<string, unknown> = {};
let loaderDataMock: {
items: Array<{
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
channel: "official" | "community" | "private";
isOfficial: boolean;
executesCode?: boolean;
summary?: string | null;
ownerHandle?: string | null;
latestVersion?: string | null;
stats?: { downloads: number; installs: number; stars: number; versions: number };
createdAt: number;
updatedAt: number;
}>;
nextCursor: string | null;
rateLimited: boolean;
retryAfterSeconds: number | null;
apiError?: boolean;
} = {
items: [],
nextCursor: null,
rateLimited: false,
retryAfterSeconds: null,
};
let loaderDataMock:
| {
items: Array<{
name: string;
displayName: string;
family: "skill" | "code-plugin" | "bundle-plugin";
channel: "official" | "community" | "private";
isOfficial: boolean;
executesCode?: boolean;
summary?: string | null;
ownerHandle?: string | null;
latestVersion?: string | null;
stats?: { downloads: number; installs: number; stars: number; versions: number };
createdAt: number;
updatedAt: number;
}>;
nextCursor: string | null;
rateLimited: boolean;
retryAfterSeconds: number | null;
totalCount?: number | null;
isLoading?: boolean;
apiError?: boolean;
}
| undefined;
vi.mock("@tanstack/react-router", () => ({
createFileRoute:
() =>
(config: {
loader?: (args: { deps: Record<string, unknown> }) => Promise<unknown>;
component?: unknown;
validateSearch?: unknown;
}) => ({
() => (config: { loader?: unknown; component?: unknown; validateSearch?: unknown }) => ({
__config: config,
useNavigate: () => navigateMock,
useSearch: () => searchMock,
@@ -69,10 +68,22 @@ vi.mock("../lib/featuredCatalog", () => ({
fetchFeaturedPlugins: (...args: unknown[]) => fetchFeaturedPluginsMock(...args),
}));
vi.mock("convex/react", () => ({
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
}));
vi.mock("../../convex/_generated/api", () => ({
api: {
packages: {
countPublicPlugins: "packages:countPublicPlugins",
},
},
}));
async function loadRoute() {
return (await import("../routes/plugins/index")).Route as unknown as {
__config: {
loader?: (args: { deps: Record<string, unknown> }) => Promise<unknown>;
loader?: unknown;
component?: ComponentType;
pendingComponent?: ComponentType;
validateSearch?: (search: Record<string, unknown>) => Record<string, unknown>;
@@ -83,18 +94,15 @@ async function loadRoute() {
describe("plugins route", () => {
beforeEach(() => {
fetchPluginCatalogMock.mockReset();
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
fetchFeaturedPluginsMock.mockReset();
isRateLimitedPackageApiErrorMock.mockClear();
resetConvexReactMocks();
setupDefaultConvexReactMocks();
navigateMock.mockReset();
redirectMock.mockClear();
searchMock = {};
loaderDataMock = {
items: [],
nextCursor: null,
rateLimited: false,
retryAfterSeconds: null,
apiError: false,
};
loaderDataMock = undefined;
});
it("rejects skill family filter in search state", async () => {
@@ -164,7 +172,7 @@ describe("plugins route", () => {
).toThrow();
});
it("keeps search-only sort choices when search is active", async () => {
it("keeps visible plugin sort choices when search is active", async () => {
const route = await loadRoute();
const beforeLoad = (
route.__config as never as {
@@ -179,13 +187,61 @@ describe("plugins route", () => {
).not.toThrow();
expect(() =>
beforeLoad?.({
search: { q: "security", sort: "newest" },
search: { q: "security", sort: "downloads" },
}),
).not.toThrow();
expect(() =>
beforeLoad?.({
search: { q: "security", sort: "newest" },
}),
).toThrow();
expect(() =>
beforeLoad?.({
search: { q: "security", sort: "name" },
}),
).toThrow();
});
it("redirects hidden legacy plugin sort choices while search is active", async () => {
const route = await loadRoute();
const beforeLoad = (
route.__config as never as {
beforeLoad?: (args: { search: Record<string, unknown> }) => void;
}
).beforeLoad;
expect(() =>
beforeLoad?.({
search: { q: "security", sort: "newest" },
}),
).toThrow();
expect(() =>
beforeLoad?.({
search: { q: "security", sort: "name" },
}),
).toThrow();
expect(redirectMock).toHaveBeenCalledWith(
expect.objectContaining({
search: expect.objectContaining({
q: "security",
sort: undefined,
}),
}),
);
});
it("keeps hidden relevance sort URLs compatible while search is active", async () => {
const route = await loadRoute();
const beforeLoad = (
route.__config as never as {
beforeLoad?: (args: { search: Record<string, unknown> }) => void;
}
).beforeLoad;
expect(() =>
beforeLoad?.({
search: { q: "security", sort: "relevance" },
}),
).not.toThrow();
});
@@ -214,14 +270,14 @@ describe("plugins route", () => {
expect(() =>
beforeLoad?.({
search: { q: "security", sort: "name", featured: true },
search: { q: "security", sort: "updated", featured: true },
}),
).toThrow();
expect(redirectMock).toHaveBeenCalledWith(
expect.objectContaining({
search: expect.objectContaining({
featured: undefined,
sort: "name",
sort: "updated",
}),
}),
);
@@ -253,48 +309,53 @@ describe("plugins route", () => {
);
});
it("forwards opaque cursors through the loader", async () => {
it("forwards opaque cursors through catalog loading", async () => {
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: "cursor:next" });
const route = await loadRoute();
const loader = route.__config.loader as (args: {
deps: Record<string, unknown>;
}) => Promise<unknown>;
const { loadPluginsPageData } = await import("../routes/plugins/index");
await loader({
deps: {
cursor: "cursor:current",
},
await loadPluginsPageData({
cursor: "cursor:current",
});
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({
cursor: "cursor:current",
limit: 100,
limit: 25,
sort: "recommended",
}),
);
expect(fetchPluginCatalogMock.mock.calls[0]?.[0]).not.toHaveProperty("family");
});
it("uses recommended as the plugin browse ranking", async () => {
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
const { loadPluginsPageData } = await import("../routes/plugins/index");
await loadPluginsPageData({});
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({
sort: "recommended",
limit: 25,
}),
);
});
it("uses relevance fetching for sorted search results", async () => {
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: "cursor:next" });
const route = await loadRoute();
const loader = route.__config.loader as (args: {
deps: Record<string, unknown>;
}) => Promise<unknown>;
const { loadPluginsPageData } = await import("../routes/plugins/index");
await loader({
deps: {
q: "security",
sort: "name",
cursor: "cursor:search",
},
await loadPluginsPageData({
q: "security",
sort: "downloads",
cursor: "cursor:search",
});
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({
q: "security",
cursor: undefined,
limit: 100,
limit: 25,
}),
);
expect(fetchPluginCatalogMock.mock.calls[0]?.[0]).not.toHaveProperty("sort");
@@ -302,37 +363,27 @@ describe("plugins route", () => {
it("forwards downloads sort for plugin browse", async () => {
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
const route = await loadRoute();
const loader = route.__config.loader as (args: {
deps: Record<string, unknown>;
}) => Promise<unknown>;
const { loadPluginsPageData } = await import("../routes/plugins/index");
await loader({
deps: {
sort: "downloads",
},
await loadPluginsPageData({
sort: "downloads",
});
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({
sort: "downloads",
limit: 100,
limit: 25,
}),
);
});
it("forwards category through the loader without changing the query", async () => {
it("forwards category through catalog loading without changing the query", async () => {
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
const route = await loadRoute();
const loader = route.__config.loader as (args: {
deps: Record<string, unknown>;
}) => Promise<unknown>;
const { loadPluginsPageData } = await import("../routes/plugins/index");
await loader({
deps: {
q: "api",
category: "data",
},
await loadPluginsPageData({
q: "api",
category: "data",
});
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
@@ -340,7 +391,7 @@ describe("plugins route", () => {
q: "api",
category: "data",
cursor: undefined,
limit: 100,
limit: 25,
}),
);
});
@@ -368,8 +419,8 @@ describe("plugins route", () => {
render(<Component />);
expect(screen.getByRole("heading", { name: "Plugins 1+" })).toBeTruthy();
expect(screen.getByText("1+ results")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Plugins" })).toBeTruthy();
expect(screen.queryByText("1+ results")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Next page" }));
@@ -409,7 +460,41 @@ describe("plugins route", () => {
expect(screen.getByText("1.2k")).toBeTruthy();
});
it("uses singular shown text on non-first browse pages", async () => {
it("renders the browse shell immediately while catalog data loads", async () => {
const item = {
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin" as const,
channel: "community" as const,
isOfficial: false,
executesCode: true,
createdAt: 1,
updatedAt: 1,
};
let resolveCatalog: (value: {
items: (typeof item)[];
nextCursor: string | null;
totalCount: number;
}) => void = () => {};
fetchPluginCatalogMock.mockReturnValue(
new Promise((resolve) => {
resolveCatalog = resolve;
}),
);
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("heading", { name: "Plugins" })).toBeTruthy();
expect(screen.getByRole("status", { name: "Loading results" })).toBeTruthy();
resolveCatalog({ items: [item], nextCursor: null, totalCount: 321 });
expect(await screen.findByText("Demo Plugin")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Plugins 321" })).toBeTruthy();
});
it("keeps plugin count copy hidden on non-first browse pages", async () => {
searchMock = { cursor: "cursor:current" };
loaderDataMock = {
items: [
@@ -433,11 +518,74 @@ describe("plugins route", () => {
render(<Component />);
expect(screen.getByRole("heading", { name: "Plugins 1 shown" })).toBeTruthy();
expect(screen.getByText("1 result shown")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Plugins" })).toBeTruthy();
expect(screen.queryByText("1 shown")).toBeNull();
expect(screen.queryByText("1 result shown")).toBeNull();
});
it("renders a title count and switches to grid view", async () => {
it("renders the total plugin count in the unfiltered page title", async () => {
loaderDataMock = {
items: [
{
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
channel: "community",
isOfficial: false,
executesCode: true,
createdAt: 1,
updatedAt: 1,
},
],
nextCursor: null,
totalCount: 321,
rateLimited: false,
retryAfterSeconds: null,
};
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("heading", { name: "Plugins 321" })).toBeTruthy();
});
it("falls back to the Convex plugin count when catalog data has no total", async () => {
convexReactMocks.useQuery.mockReturnValue(333);
loaderDataMock = {
items: [],
nextCursor: null,
totalCount: null,
rateLimited: false,
retryAfterSeconds: null,
};
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("heading", { name: "Plugins 333" })).toBeTruthy();
});
it("hides the total plugin count when filters are active", async () => {
searchMock = { official: true };
loaderDataMock = {
items: [],
nextCursor: null,
totalCount: 321,
rateLimited: false,
retryAfterSeconds: null,
};
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("heading", { name: "Plugins" })).toBeTruthy();
expect(screen.queryByText("321")).toBeNull();
});
it("renders a label-only title without positive count data and switches to grid view", async () => {
loaderDataMock = {
items: [
{
@@ -460,7 +608,12 @@ describe("plugins route", () => {
render(<Component />);
expect(screen.getByRole("heading", { name: "Plugins 1" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Plugins" })).toBeTruthy();
expect(screen.queryByText("1")).toBeNull();
expect(screen.getByRole("button", { name: "List" }).closest(".browse-page-header")).toBe(
document.querySelector(".browse-page-header"),
);
expect(document.querySelector(".browse-results-toolbar .browse-view-toggle")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Grid" }));
@@ -532,7 +685,7 @@ describe("plugins route", () => {
expect(lastCall.search({ view: "cards" })).toEqual({ view: undefined });
});
it("filters out skills from loader results", async () => {
it("preserves catalog results during catalog loading", async () => {
fetchPluginCatalogMock.mockResolvedValue({
items: [
{
@@ -556,96 +709,83 @@ describe("plugins route", () => {
],
nextCursor: null,
});
const route = await loadRoute();
const loader = route.__config.loader as (args: {
deps: Record<string, unknown>;
}) => Promise<{ items: Array<{ name: string }>; nextCursor: string | null }>;
const { loadPluginsPageData } = await import("../routes/plugins/index");
const result = await loader({ deps: {} });
const result = await loadPluginsPageData({});
expect(result.items).toHaveLength(2);
});
it("uses plugin-only catalog fetching for official browse", async () => {
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
const route = await loadRoute();
const loader = route.__config.loader as (args: {
deps: Record<string, unknown>;
}) => Promise<unknown>;
const { loadPluginsPageData } = await import("../routes/plugins/index");
await loader({
deps: {
official: true,
},
await loadPluginsPageData({
official: true,
});
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({
isOfficial: true,
limit: 100,
limit: 25,
}),
);
expect(fetchPluginCatalogMock.mock.calls[0]?.[0]).not.toHaveProperty("family");
});
it("selects featured from the sort group", async () => {
it("selects recommended from the plugin sort group", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
fireEvent.click(screen.getByRole("radio", { name: "Featured" }));
fireEvent.click(screen.getByRole("radio", { name: "Recommended" }));
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
replace?: boolean;
search: (prev: Record<string, unknown>) => Record<string, unknown>;
};
expect(lastCall.search({ family: "code-plugin", cursor: "cursor:current" })).toEqual({
expect(lastCall.replace).toBe(true);
expect(
lastCall.search({
family: "code-plugin",
cursor: "cursor:current",
featured: true,
sort: "updated",
}),
).toEqual({
family: undefined,
cursor: undefined,
featured: true,
q: undefined,
featured: undefined,
sort: undefined,
});
});
it("returns a retryable empty state when the catalog is rate limited", async () => {
fetchPluginCatalogMock.mockRejectedValue({ status: 429, retryAfterSeconds: 22 });
const route = await loadRoute();
const loader = route.__config.loader as (args: { deps: Record<string, unknown> }) => Promise<{
items: Array<{ name: string }>;
nextCursor: string | null;
rateLimited: boolean;
retryAfterSeconds: number | null;
}>;
const { loadPluginsPageData } = await import("../routes/plugins/index");
const result = await loader({ deps: {} });
const result = await loadPluginsPageData({});
expect(result).toEqual({
items: [],
nextCursor: null,
rateLimited: true,
retryAfterSeconds: 22,
totalCount: null,
isLoading: false,
apiError: false,
});
});
it("flags API errors for filtered catalog requests", async () => {
fetchPluginCatalogMock.mockRejectedValue(new Error("boom"));
const route = await loadRoute();
const loader = route.__config.loader as (args: { deps: Record<string, unknown> }) => Promise<{
items: Array<{ name: string }>;
nextCursor: string | null;
rateLimited: boolean;
retryAfterSeconds: number | null;
apiError?: boolean;
}>;
const { loadPluginsPageData } = await import("../routes/plugins/index");
const result = await loader({
deps: {
q: "demo",
executesCode: true,
},
const result = await loadPluginsPageData({
q: "demo",
executesCode: true,
});
expect(result).toEqual({
@@ -653,10 +793,39 @@ describe("plugins route", () => {
nextCursor: null,
rateLimited: false,
retryAfterSeconds: null,
totalCount: null,
isLoading: false,
apiError: true,
});
});
it("flags browser network failures instead of leaving plugin loading stuck", async () => {
fetchPluginCatalogMock.mockRejectedValue(new TypeError("Failed to fetch"));
const { loadPluginsPageData } = await import("../routes/plugins/index");
const result = await loadPluginsPageData({});
expect(result).toEqual({
items: [],
nextCursor: null,
rateLimited: false,
retryAfterSeconds: null,
totalCount: null,
isLoading: false,
apiError: true,
});
});
it("rethrows aborted plugin catalog requests", async () => {
const controller = new AbortController();
controller.abort();
const abortError = new DOMException("The operation was aborted.", "AbortError");
fetchPluginCatalogMock.mockRejectedValue(abortError);
const { loadPluginsPageData } = await import("../routes/plugins/index");
await expect(loadPluginsPageData({ signal: controller.signal })).rejects.toBe(abortError);
});
it("renders a rate-limit message instead of the global error boundary state", async () => {
loaderDataMock = {
items: [],
@@ -682,6 +851,9 @@ describe("plugins route", () => {
expect(validateSearch({ sort: "updated" })).toEqual(
expect.objectContaining({ sort: "updated" }),
);
expect(validateSearch({ sort: "recommended" })).toEqual(
expect.objectContaining({ sort: "recommended" }),
);
expect(validateSearch({ sort: "relevance" })).toEqual(
expect.objectContaining({ sort: "relevance" }),
);
@@ -762,6 +934,75 @@ describe("plugins route", () => {
});
});
it("updates plugin search while typing", async () => {
vi.useFakeTimers();
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
const input = screen.getByPlaceholderText("Search plugins...");
fireEvent.change(input, { target: { value: "github" } });
expect(navigateMock).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(220);
});
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
replace?: boolean;
search: (prev: Record<string, unknown>) => Record<string, unknown>;
};
expect(lastCall.replace).toBe(true);
expect(
lastCall.search({
cursor: "cursor:current",
family: "code-plugin",
featured: true,
sort: "updated",
}),
).toEqual({
cursor: undefined,
family: undefined,
featured: undefined,
q: "github",
sort: undefined,
});
vi.useRealTimers();
});
it("clears plugin search from the search field", async () => {
searchMock = { q: "github", cursor: "cursor:current", sort: "name", category: "security" };
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
fireEvent.click(screen.getByRole("button", { name: "Clear plugin search" }));
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace?: boolean;
};
expect(
lastCall.search({
q: "github",
cursor: "cursor:current",
sort: "name",
category: "security",
}),
).toEqual({
q: undefined,
cursor: undefined,
sort: undefined,
category: "security",
});
expect(lastCall.replace).toBe(true);
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
});
it("keeps browse sort choices when only a category is active", async () => {
searchMock = { category: "security" };
loaderDataMock = {
@@ -786,19 +1027,21 @@ describe("plugins route", () => {
render(<Component />);
expect(screen.getByRole("radio", { name: "Featured" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Recommended" }).getAttribute("aria-checked")).toBe(
"true",
);
expect(screen.getByRole("radio", { name: "Recently updated" })).toBeTruthy();
expect(screen.queryByRole("radio", { name: "Relevance" })).toBeNull();
});
it("selects loaded-result search sort without changing the query", async () => {
it("selects visible search sort without changing the query", async () => {
searchMock = { q: "security" };
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
fireEvent.click(screen.getByRole("radio", { name: "Name" }));
fireEvent.click(screen.getByRole("radio", { name: "Most downloaded" }));
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
@@ -808,12 +1051,12 @@ describe("plugins route", () => {
cursor: undefined,
family: undefined,
featured: undefined,
sort: "name",
sort: "downloads",
});
});
it("sorts loaded search results by the selected search sort", async () => {
searchMock = { q: "security", sort: "name" };
searchMock = { q: "security", sort: "downloads" };
loaderDataMock = {
items: [
{
@@ -825,6 +1068,7 @@ describe("plugins route", () => {
executesCode: true,
createdAt: 2,
updatedAt: 20,
stats: { downloads: 1, installs: 0, stars: 0, versions: 1 },
},
{
name: "alpha-plugin",
@@ -835,6 +1079,7 @@ describe("plugins route", () => {
executesCode: true,
createdAt: 1,
updatedAt: 10,
stats: { downloads: 10, installs: 0, stars: 0, versions: 1 },
},
],
nextCursor: null,
@@ -858,9 +1103,40 @@ describe("plugins route", () => {
render(<Component />);
expect(screen.getByRole("radio", { name: "Relevance" }).getAttribute("aria-checked")).toBe(
expect(screen.getByRole("radio", { name: "Recommended" }).getAttribute("aria-checked")).toBe(
"true",
);
expect(screen.queryByRole("radio", { name: "Featured" })).toBeNull();
expect(screen.queryByRole("radio", { name: "Relevance" })).toBeNull();
});
it("keeps plugin sort options stable while searching", async () => {
searchMock = { q: "security" };
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
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(screen.queryByRole("radio", { name: "Newest" })).toBeNull();
expect(screen.queryByRole("radio", { name: "Name" })).toBeNull();
});
it("puts the default plugin sort first", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
const sortOptions = Array.from(
screen.getByRole("radiogroup", { name: "Sort order" }).querySelectorAll('[role="radio"]'),
).map((option) => option.textContent);
expect(sortOptions[0]).toBe("Recommended");
expect(screen.getByRole("radio", { name: "Recommended" }).getAttribute("aria-checked")).toBe(
"true",
);
});
});
@@ -0,0 +1,155 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import type { PublicPublisherListItem } from "../lib/publicUser";
const { actionMock, mutationMock } = vi.hoisted(() => ({
actionMock: vi.fn(),
mutationMock: vi.fn(),
}));
vi.mock("convex/react", () => ({
useAction: () => actionMock,
useMutation: () => mutationMock,
}));
vi.mock("@tanstack/react-router", () => ({
createFileRoute: () => (config: unknown) => ({ __config: config }),
Link: ({ children, to }: { children: React.ReactNode; to?: string }) => (
<a href={to}>{children}</a>
),
}));
vi.mock("sonner", () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}));
describe("GitHubSyncPanel", () => {
beforeEach(() => {
actionMock.mockReset();
mutationMock.mockReset();
actionMock.mockResolvedValue({ url: "https://github.com/apps/clawhub/installations/new" });
mutationMock.mockResolvedValue({ ok: true });
});
it("renders repository controls and submits sync settings", async () => {
const { GitHubSyncPanel } = await import("../routes/user/$handle");
render(<GitHubSyncPanel publisher={publisher} repositories={[repository]} />);
expect(screen.getByRole("heading", { name: "GitHub Sync" })).toBeTruthy();
expect(screen.getByText("openclaw/skills")).toBeTruthy();
expect(screen.getByLabelText("GitHub account ID")).toBeTruthy();
fireEvent.change(screen.getByDisplayValue("main"), { target: { value: "stable" } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => {
expect(mutationMock).toHaveBeenCalledWith({
repositoryId: "publisherGitHubRepositories:repo",
syncRef: "stable",
syncRoots: ["skills"],
mode: "discover",
enabled: true,
});
});
});
it("queues a repository sync", async () => {
const { GitHubSyncPanel } = await import("../routes/user/$handle");
render(<GitHubSyncPanel publisher={publisher} repositories={[repository]} />);
fireEvent.click(screen.getByRole("button", { name: /sync/i }));
await waitFor(() => {
expect(mutationMock).toHaveBeenCalledWith({
repositoryId: "publisherGitHubRepositories:repo",
});
});
});
});
describe("canManagePublisherGitHubSync", () => {
it("allows target org admins who are openclaw members", async () => {
const { canManagePublisherGitHubSync } = await import("../routes/user/$handle");
expect(
canManagePublisherGitHubSync(targetOrgPublisher, [
{ publisher: targetOrgPublisher, role: "admin" },
{ publisher: openclawPublisher, role: "publisher" },
]),
).toBe(true);
});
it("hides sync from target org admins outside the openclaw rollout", async () => {
const { canManagePublisherGitHubSync } = await import("../routes/user/$handle");
expect(
canManagePublisherGitHubSync(targetOrgPublisher, [
{ publisher: targetOrgPublisher, role: "admin" },
]),
).toBe(false);
});
it("hides sync from openclaw members without target org admin rights", async () => {
const { canManagePublisherGitHubSync } = await import("../routes/user/$handle");
expect(
canManagePublisherGitHubSync(targetOrgPublisher, [
{ publisher: targetOrgPublisher, role: "publisher" },
{ publisher: openclawPublisher, role: "owner" },
]),
).toBe(false);
});
});
const publisher: PublicPublisherListItem = {
_id: "publishers:org" as Id<"publishers">,
_creationTime: 1,
kind: "org",
handle: "openclaw",
displayName: "OpenClaw",
image: undefined,
bio: undefined,
linkedUserId: undefined,
stats: { skills: 0, packages: 0, installs: 0, downloads: 0, stars: 0 },
publishedItems: [],
};
const repository: Doc<"publisherGitHubRepositories"> & { sourceLinkCount: number } = {
_id: "publisherGitHubRepositories:repo" as Id<"publisherGitHubRepositories">,
_creationTime: 1,
publisherId: "publishers:org" as Id<"publishers">,
githubLinkId: "publisherGitHubLinks:link" as Id<"publisherGitHubLinks">,
installationId: "123",
repoFullName: "openclaw/skills",
repoId: "456",
defaultBranch: "main",
syncRef: "main",
syncRoots: ["skills"],
mode: "discover",
enabled: true,
lastSyncStatus: "idle",
createdAt: 1,
updatedAt: 1,
sourceLinkCount: 2,
};
const targetOrgPublisher: PublicPublisherListItem = {
...publisher,
_id: "publishers:target" as Id<"publishers">,
handle: "target-org",
displayName: "Target Org",
};
const openclawPublisher: PublicPublisherListItem = {
...publisher,
_id: "publishers:openclaw" as Id<"publishers">,
handle: "openclaw",
displayName: "OpenClaw",
};
+112 -6
View File
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentType, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -30,8 +30,20 @@ vi.mock("@tanstack/react-router", () => ({
useNavigate: () => navigateMock,
useSearch: () => searchMock(),
}),
Link: ({ children, className, to }: { children: ReactNode; className?: string; to?: string }) => (
<a className={className} href={to}>
Link: ({
children,
className,
resetScroll: _resetScroll,
to,
...props
}: {
children: ReactNode;
className?: string;
resetScroll?: boolean;
to?: string;
"aria-label"?: string;
}) => (
<a className={className} href={to} {...props}>
{children}
</a>
),
@@ -54,7 +66,8 @@ async function loadRoute() {
links?: Array<{ rel: string; href: string }>;
meta?: Array<Record<string, string>>;
};
loader?: (args: { deps: { kind?: "orgs" | "builders"; q?: string } }) => Promise<unknown>;
loader?: (args: { deps: { kind?: "orgs" | "people"; q?: string } }) => Promise<unknown>;
validateSearch?: (search: Record<string, unknown>) => Record<string, unknown>;
};
};
}
@@ -109,9 +122,24 @@ describe("publishers route", () => {
});
});
it("passes the builders filter and query to the public publishers query", async () => {
it("normalizes legacy builder URLs to people", async () => {
const route = await loadRoute();
await route.__config.loader?.({ deps: { kind: "builders", q: "openclaw" } });
expect(route.__config.validateSearch?.({ kind: "builders" })).toEqual({
kind: "people",
q: undefined,
view: undefined,
});
expect(route.__config.validateSearch?.({ kind: "individuals" })).toEqual({
kind: "people",
q: undefined,
view: undefined,
});
});
it("passes the people filter and query to the public publishers query", async () => {
const route = await loadRoute();
await route.__config.loader?.({ deps: { kind: "people", q: "openclaw" } });
expect(queryMock.mock.calls[0]?.[1]).toEqual({
paginationOpts: { cursor: null, numItems: 25 },
@@ -130,6 +158,84 @@ describe("publishers route", () => {
expect(screen.getByText("No publishers found")).toBeTruthy();
});
it("renders the total publisher count in the unfiltered page title", async () => {
loaderDataMock.mockReturnValue({
page: [],
counts: { all: 17, organizations: 6, individuals: 11 },
globalCounts: { all: 17, organizations: 6, individuals: 11 },
continueCursor: "",
isDone: true,
});
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("heading", { name: "Publishers 17" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "All" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Organizations" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "People" })).toBeTruthy();
expect(screen.queryByText("Builders")).toBeNull();
expect(screen.queryByText(/Showing/i)).toBeNull();
});
it("hides the total publisher count when filters are active", async () => {
searchMock.mockReturnValue({ kind: "orgs" });
loaderDataMock.mockReturnValue({
page: [],
counts: { all: 6, organizations: 6, individuals: 0 },
globalCounts: { all: 17, organizations: 6, individuals: 11 },
continueCursor: "",
isDone: true,
});
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("heading", { name: "Publishers" })).toBeTruthy();
expect(screen.queryByText("17")).toBeNull();
});
it("keeps publisher view controls in the page header and type filters in the sidebar", async () => {
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
const allTab = screen.getByRole("radio", { name: "All" });
const listView = screen.getByRole("button", { name: "List" });
const searchInput = screen.getByPlaceholderText("Search publishers...");
expect(allTab.closest(".browse-sidebar")).not.toBeNull();
expect(listView.closest(".browse-page-header")).not.toBeNull();
expect(searchInput.compareDocumentPosition(listView) & Node.DOCUMENT_POSITION_PRECEDING).toBe(
Node.DOCUMENT_POSITION_PRECEDING,
);
});
it("clears publisher search from the search field", async () => {
searchMock.mockReturnValue({ q: "ope", kind: "orgs" });
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
fireEvent.click(screen.getByRole("button", { name: "Clear publisher search" }));
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace?: boolean;
};
expect(lastCall.search({ q: "ope", kind: "orgs" })).toEqual({
q: undefined,
kind: "orgs",
});
expect(lastCall.replace).toBe(true);
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
});
it("sets publisher-specific sharing metadata", async () => {
const route = await loadRoute();
const head = route.__config.head?.();
+13 -10
View File
@@ -108,7 +108,7 @@ describe("search route", () => {
expect(screen.queryByText("Searching...")).toBeNull();
});
it("shows zero counts consistently for active search tabs", async () => {
it("does not render count chips in active search tabs", async () => {
useUnifiedSearchMock.mockReturnValue({
results: [],
skillResults: [],
@@ -124,9 +124,11 @@ describe("search route", () => {
render(<Component />);
expect(screen.getByRole("button", { name: "All 3" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Skills 0" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Plugins 3" })).toBeTruthy();
expect(screen.getByRole("button", { name: "All" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Skills" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Plugins" })).toBeTruthy();
expect(screen.queryByText("0")).toBeNull();
expect(screen.queryByText("3")).toBeNull();
});
it("clears the active search query from the input", async () => {
@@ -186,7 +188,7 @@ describe("search route", () => {
});
});
it("keeps inactive tab counts honest while rendering the active tab", async () => {
it("keeps inactive tab labels visible while rendering the active tab", async () => {
searchMock = { q: "weather", type: "skills" };
useUnifiedSearchMock.mockReturnValue({
results: [
@@ -234,13 +236,13 @@ describe("search route", () => {
render(<Component />);
expect(screen.getByRole("button", { name: "All 2" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Plugins 1" })).toBeTruthy();
expect(screen.getByRole("button", { name: "All" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Plugins" })).toBeTruthy();
expect(screen.getByText("weather")).toBeTruthy();
expect(screen.queryByText("weather-plugin")).toBeNull();
});
it("marks tab counts as partial when more results are available", async () => {
it("does not render partial count labels when more results are available", async () => {
useUnifiedSearchMock.mockReturnValue({
results: [],
skillResults: [],
@@ -256,8 +258,9 @@ describe("search route", () => {
render(<Component />);
expect(screen.getByRole("button", { name: "All 25+" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Plugins 25+" })).toBeTruthy();
expect(screen.getByRole("button", { name: "All" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Plugins" })).toBeTruthy();
expect(screen.queryByText("25+")).toBeNull();
});
it("does not show load more only because the current page is full", async () => {
+9
View File
@@ -91,6 +91,11 @@ describe("skill route loader", () => {
expect(() => runBeforeLoad({ owner: "123abc", slug: "weather" })).not.toThrow();
});
it("allows npm-compatible dotted and underscored owner handles in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "example.tools", slug: "weather" })).not.toThrow();
expect(() => runBeforeLoad({ owner: "studio_tools", slug: "weather" })).not.toThrow();
});
it("allows raw owner ids in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "users:abc123", slug: "weather" })).not.toThrow();
});
@@ -103,6 +108,10 @@ describe("skill route loader", () => {
expect(() => runBeforeLoad({ owner: "@openclaw", slug: "codex" })).not.toThrow();
});
it("allows npm-style scopes with dotted owners in beforeLoad", () => {
expect(() => runBeforeLoad({ owner: "@example.tools", slug: "demo-plugin" })).not.toThrow();
});
beforeEach(() => {
fetchSkillPageDataMock.mockReset();
resolveOpenClawPluginSlugMock.mockReset();
+190 -2
View File
@@ -64,6 +64,10 @@ describe("SkillsIndex", () => {
expect(screen.getByRole("radio", { name: "Recommended" }).getAttribute("aria-checked")).toBe(
"true",
);
const sortOptions = Array.from(
screen.getByRole("radiogroup", { name: "Sort order" }).querySelectorAll('[role="radio"]'),
).map((option) => option.textContent);
expect(sortOptions.slice(0, 2)).toEqual(["Recommended", "Featured"]);
});
it("keeps downloads as an explicit browse sort", async () => {
@@ -86,6 +90,97 @@ describe("SkillsIndex", () => {
render(<SkillsIndex />);
await act(async () => {});
expect(screen.getByText("No skills found")).toBeTruthy();
expect(screen.queryByText(/\d+ loaded/)).toBeNull();
});
it("renders the total skills count in the unfiltered page title", async () => {
convexReactMocks.useQuery.mockReturnValue(70_300);
render(<SkillsIndex />);
await act(async () => {});
expect(screen.getByRole("heading", { name: "Skills 70.3K" })).toBeTruthy();
});
it("hides the total skills count when filters are active", async () => {
searchMock = { category: "dev-tools" };
convexReactMocks.useQuery.mockReturnValue(70_300);
render(<SkillsIndex />);
await act(async () => {});
expect(screen.getByRole("heading", { name: "Skills" })).toBeTruthy();
expect(screen.queryByText("70.3K")).toBeNull();
});
it("clears the skill search from the search field", async () => {
searchMock = { q: "agent", sort: "relevance", category: "dev-tools" };
render(<SkillsIndex />);
await act(async () => {});
fireEvent.click(screen.getByRole("button", { name: "Clear skill search" }));
expect(navigateMock).toHaveBeenCalled();
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
search: (prev: Record<string, unknown>) => Record<string, unknown>;
replace?: boolean;
};
expect(
lastCall.search({
q: "agent",
sort: "relevance",
category: "dev-tools",
}),
).toEqual({
q: undefined,
sort: undefined,
category: "dev-tools",
});
expect(lastCall.replace).toBe(true);
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
});
it("does not render a browse count when more pages exist", async () => {
convexHttpMock.query.mockResolvedValue({
page: [makeListResult("skill-0", "Skill 0")],
hasMore: true,
nextCursor: "cursor-1",
});
render(<SkillsIndex />);
await act(async () => {});
expect(screen.getByText("Skill 0")).toBeTruthy();
expect(screen.queryByText(/\d+ loaded/)).toBeNull();
});
it("keeps browse counts hidden after loading another page", async () => {
vi.stubGlobal("IntersectionObserver", undefined);
convexHttpMock.query
.mockResolvedValueOnce({
page: [makeListResult("skill-0", "Skill 0")],
hasMore: true,
nextCursor: "cursor-1",
})
.mockResolvedValueOnce({
page: [makeListResult("skill-1", "Skill 1")],
hasMore: false,
nextCursor: null,
});
render(<SkillsIndex />);
await act(async () => {});
expect(screen.getByText("Skill 0")).toBeTruthy();
expect(screen.queryByText(/\d+ loaded/)).toBeNull();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
});
expect(screen.getByText("Skill 1")).toBeTruthy();
expect(screen.queryByText(/\d+ loaded/)).toBeNull();
});
it("does not render the publish CTA on the skills browse page", async () => {
@@ -101,8 +196,8 @@ describe("SkillsIndex", () => {
convexHttpMock.query.mockReturnValue(new Promise(() => {}));
render(<SkillsIndex />);
await act(async () => {});
// Results area shows skeleton or dash while loading
expect(screen.getByText("\u2014")).toBeTruthy();
// Results area shows skeletons while loading, without count copy.
expect(screen.queryByText(/\d+ loaded/)).toBeNull();
expect(screen.getByRole("status", { name: "Loading results" })).toBeTruthy();
expect(screen.queryByText("No skills found")).toBeNull();
});
@@ -120,6 +215,19 @@ describe("SkillsIndex", () => {
expect(lastCall.search({})).toEqual({ view: "grid" });
});
it("renders the view toggle above the skills search input", async () => {
render(<SkillsIndex />);
await act(async () => {});
const listButton = screen.getByRole("button", { name: "List" });
const searchInput = screen.getByPlaceholderText("Search skills...");
expect(listButton.closest(".browse-page-header")).not.toBeNull();
expect(
Boolean(listButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING),
).toBe(true);
});
it("keeps legacy cards URLs compatible with the grid view", async () => {
searchMock = { view: "cards" };
render(<SkillsIndex />);
@@ -150,6 +258,7 @@ describe("SkillsIndex", () => {
// Should show empty state, not loading
expect(screen.getByText("No skills found")).toBeTruthy();
expect(screen.queryByText(/\d+ loaded/)).toBeNull();
expect(screen.queryByText(/Loading skills/)).toBeNull();
});
@@ -186,6 +295,80 @@ describe("SkillsIndex", () => {
});
});
it("keeps recommended as the visible default search sort", async () => {
searchMock = { q: "notion" };
const actionFn = vi.fn().mockResolvedValue([]);
convexReactMocks.useAction.mockReturnValue(actionFn);
vi.useFakeTimers();
render(<SkillsIndex />);
expect(screen.getByRole("radio", { name: "Recommended" }).getAttribute("aria-checked")).toBe(
"true",
);
expect(screen.queryByRole("radio", { name: "Relevance" })).toBeNull();
const sortOptions = Array.from(
screen.getByRole("radiogroup", { name: "Sort order" }).querySelectorAll('[role="radio"]'),
).map((option) => option.textContent);
expect(sortOptions[0]).toBe("Recommended");
});
it("keeps recommended sort stable while typing a search", async () => {
vi.useFakeTimers();
render(<SkillsIndex />);
const input = screen.getByPlaceholderText("Search skills...");
fireEvent.change(input, { target: { value: "agent" } });
expect(screen.getByRole("radio", { name: "Recommended" }).getAttribute("aria-checked")).toBe(
"true",
);
expect(screen.queryByRole("radio", { name: "Relevance" })).toBeNull();
});
it("keeps the skills sort option list stable while typing a search", async () => {
vi.useFakeTimers();
render(<SkillsIndex />);
const beforeTyping = Array.from(
screen.getByRole("radiogroup", { name: "Sort order" }).querySelectorAll('[role="radio"]'),
).map((option) => option.textContent);
const input = screen.getByPlaceholderText("Search skills...");
fireEvent.change(input, { target: { value: "agent" } });
const whileTyping = Array.from(
screen.getByRole("radiogroup", { name: "Sort order" }).querySelectorAll('[role="radio"]'),
).map((option) => option.textContent);
expect(whileTyping).toEqual(beforeTyping);
expect(screen.getByRole("radio", { name: "Featured" })).toBeTruthy();
});
it("does not treat category keywords typed in search as category filters", async () => {
const actionFn = vi.fn().mockResolvedValue([]);
convexReactMocks.useAction.mockReturnValue(actionFn);
vi.useFakeTimers();
render(<SkillsIndex />);
const input = screen.getByPlaceholderText("Search skills...");
await act(async () => {
fireEvent.change(input, { target: { value: "test" } });
await vi.runAllTimersAsync();
});
expect(screen.getByRole("radio", { name: "All" }).getAttribute("aria-checked")).toBe("true");
expect(screen.getByRole("radio", { name: "Dev Tools" }).getAttribute("aria-checked")).toBe(
"false",
);
expect(actionFn).toHaveBeenCalledWith(
expect.objectContaining({
query: "test",
}),
);
});
it("switches implicit recommended sorting back to relevance when entering search", async () => {
searchMock = { sort: "downloads" };
vi.useFakeTimers();
@@ -315,6 +498,8 @@ describe("SkillsIndex", () => {
await vi.runAllTimersAsync();
});
expect(screen.queryByText(/\d+ loaded/)).toBeNull();
const loadMoreButton = screen.getByRole("button", { name: "Load more" });
await act(async () => {
fireEvent.click(loadMoreButton);
@@ -326,6 +511,7 @@ describe("SkillsIndex", () => {
highlightedOnly: false,
limit: 50,
});
expect(screen.queryByText(/\d+ loaded/)).toBeNull();
});
it("sorts search results by stars and breaks ties by updatedAt", async () => {
@@ -407,6 +593,8 @@ describe("SkillsIndex", () => {
);
expect(screen.queryByText("Blockscout for Web3 Dev")).toBeNull();
expect(screen.getByText("Developer Utils")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
expect(screen.queryByText(/\d+ loaded/)).toBeNull();
});
it("does not render the warning filter", async () => {
+58 -18
View File
@@ -25,13 +25,27 @@ type SortOption = {
label: string;
};
type RadioGroupOption = {
value: string | undefined;
label: string;
};
type RadioGroup = {
title: string;
ariaLabel: string;
options: RadioGroupOption[];
activeValue: string | undefined;
onChange: (value: string | undefined) => void;
};
type BrowseSidebarProps = {
categories?: BrowseCategory[];
activeCategory?: string;
onCategoryChange?: (slug: string | undefined) => void;
sortOptions: SortOption[];
activeSort: string;
onSortChange: (value: string) => void;
sortOptions?: SortOption[];
activeSort?: string;
onSortChange?: (value: string) => void;
radioGroups?: RadioGroup[];
filters?: FilterItem[];
onFilterToggle?: (key: string) => void;
};
@@ -62,6 +76,7 @@ export function BrowseSidebar({
sortOptions,
activeSort,
onSortChange,
radioGroups = [],
filters = [],
onFilterToggle,
}: BrowseSidebarProps) {
@@ -85,21 +100,46 @@ export function BrowseSidebar({
return (
<aside className="browse-sidebar" aria-label="Browse filters">
<fieldset className="sidebar-section" role="radiogroup" aria-label="Sort order">
<legend className="sidebar-title">Sort by</legend>
{sortOptions.map((opt) => (
<button
key={opt.value}
className={`sidebar-option${activeSort === opt.value ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={activeSort === opt.value}
onClick={() => onSortChange(opt.value)}
>
{opt.label}
</button>
))}
</fieldset>
{sortOptions?.length && activeSort && onSortChange ? (
<fieldset className="sidebar-section" role="radiogroup" aria-label="Sort order">
<legend className="sidebar-title">Sort by</legend>
{sortOptions.map((opt) => (
<button
key={opt.value}
className={`sidebar-option${activeSort === opt.value ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={activeSort === opt.value}
onClick={() => onSortChange(opt.value)}
>
{opt.label}
</button>
))}
</fieldset>
) : null}
{radioGroups.map((group) => (
<fieldset
key={group.title}
className="sidebar-section"
role="radiogroup"
aria-label={group.ariaLabel}
>
<legend className="sidebar-title">{group.title}</legend>
{group.options.map((opt) => (
<button
key={opt.value ?? "all"}
className={`sidebar-option${group.activeValue === opt.value ? " is-active" : ""}`}
type="button"
role="radio"
aria-checked={group.activeValue === opt.value}
onClick={() => group.onChange(opt.value)}
>
{opt.label}
</button>
))}
</fieldset>
))}
{categories && onCategoryChange ? (
<fieldset className="sidebar-section" role="radiogroup" aria-label="Category filter">
+20
View File
@@ -26,6 +26,26 @@ describe("SkillCard", () => {
expect(screen.queryByText("Official")).toBeNull();
expect(container.querySelector(".official-badge")).toBeTruthy();
});
it("keeps badges after the title and summary", () => {
const { container } = render(
<SkillCard
skill={makeSkill()}
badge="Official"
summaryFallback="Fallback summary"
meta={<span>meta</span>}
/>,
);
const title = container.querySelector(".skill-card-title");
const summary = container.querySelector(".skill-card-summary");
const tags = container.querySelector(".skill-card-tags");
expect(title).toBeTruthy();
expect(summary).toBeTruthy();
expect(tags).toBeTruthy();
expect(title!.compareDocumentPosition(tags!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(summary!.compareDocumentPosition(tags!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});
function makeSkill(): PublicSkill {
+7
View File
@@ -0,0 +1,7 @@
export function formatBrowseCount(value: number | null | undefined) {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
return new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: 1,
}).format(Math.round(value));
}
-6
View File
@@ -152,12 +152,6 @@ export function getSkillCategoryBySlug(slug: string | null | undefined) {
return SKILL_CATEGORIES.find((category) => category.slug === slug) ?? null;
}
export function getSkillCategoryByKeyword(keyword: string | null | undefined) {
const normalizedKeyword = keyword?.trim().toLowerCase();
if (!normalizedKeyword) return null;
return SKILL_CATEGORIES.find((category) => category.keywords.includes(normalizedKeyword)) ?? null;
}
export function buildSkillCategoryBrowseHref(category: SkillCategory) {
const params = new URLSearchParams({ category: category.slug });
return `/skills?${params.toString()}`;
+1
View File
@@ -14,6 +14,7 @@ export const MARKETPLACE_KIND_ICONS = {
export const NAV_ICONS = {
wrench: Wrench,
plug: MARKETPLACE_KIND_ICONS.plugin,
user: MARKETPLACE_KIND_ICONS.user,
} as const satisfies Record<string, MarketplaceIconComponent>;
export const SKILL_NAV_ICON = Wrench;
+8 -7
View File
@@ -4,7 +4,7 @@
*/
/** Lucide icon name used as a key to look up the component at render time. */
type NavIconName = "wrench" | "plug";
type NavIconName = "wrench" | "plug" | "user";
interface NavItemBase {
/** Visible link text */
@@ -50,7 +50,7 @@ const PUBLISHERS_SEARCH = { q: undefined } as const;
// ---------------------------------------------------------------------------
// Primary nav items (desktop tabs row + mobile dropdown top section)
// These map to the "content-type" tabs: Skills | Plugins
// These map to the content-type tabs: Skills | Plugins | Publishers
// ---------------------------------------------------------------------------
export const PRIMARY_NAV_ITEMS: NavItem[] = [
@@ -67,6 +67,12 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
icon: "plug",
activePathPrefixes: ["/plugin/"],
},
{
label: "Publishers",
to: "/publishers",
search: PUBLISHERS_SEARCH,
icon: "user",
},
];
// ---------------------------------------------------------------------------
@@ -74,11 +80,6 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
// ---------------------------------------------------------------------------
export const SECONDARY_NAV_ITEMS: NavItem[] = [
{
label: "Publishers",
to: "/publishers",
search: PUBLISHERS_SEARCH,
},
{
label: "Docs",
href: "https://docs.openclaw.ai/clawhub/",
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
isOwnerRouteHandleOrIdSegment,
isOwnerRouteHandleSegment,
isOwnerRouteScopeSegment,
} from "./ownerRoute";
describe("owner route segments", () => {
it("accepts npm-compatible publisher handle characters", () => {
expect(isOwnerRouteHandleSegment("example.tools")).toBe(true);
expect(isOwnerRouteHandleSegment("lab_1")).toBe(true);
expect(isOwnerRouteHandleSegment("studio_tools")).toBe(true);
expect(isOwnerRouteHandleSegment("market_square")).toBe(true);
});
it("keeps route segments bounded by alphanumeric characters", () => {
expect(isOwnerRouteHandleSegment(".example")).toBe(false);
expect(isOwnerRouteHandleSegment("example.")).toBe(false);
expect(isOwnerRouteHandleSegment("_glin")).toBe(false);
expect(isOwnerRouteHandleSegment("glin_")).toBe(false);
});
it("keeps route handles within the publisher handle length limit", () => {
expect(isOwnerRouteHandleSegment("a".repeat(40))).toBe(true);
expect(isOwnerRouteHandleSegment("a".repeat(41))).toBe(false);
expect(isOwnerRouteScopeSegment(`@${"a".repeat(40)}`)).toBe(true);
expect(isOwnerRouteScopeSegment(`@${"a".repeat(41)}`)).toBe(false);
});
it("accepts raw owner id route segments", () => {
expect(isOwnerRouteHandleOrIdSegment("users:abc123")).toBe(true);
expect(isOwnerRouteHandleOrIdSegment("publishers:abc123")).toBe(true);
});
it("accepts npm scope route aliases for the main skill route", () => {
expect(isOwnerRouteScopeSegment("@openclaw")).toBe(true);
expect(isOwnerRouteScopeSegment("@example.tools")).toBe(true);
expect(isOwnerRouteScopeSegment("@lab_1")).toBe(true);
});
});
+18
View File
@@ -0,0 +1,18 @@
const OWNER_ROUTE_HANDLE_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,38}[a-zA-Z0-9])?$/;
const OWNER_ROUTE_SCOPE_PATTERN = /^@[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,38}[a-zA-Z0-9])?$/;
function isOwnerRouteIdSegment(owner: string) {
return owner.startsWith("users:") || owner.startsWith("publishers:");
}
export function isOwnerRouteHandleSegment(owner: string) {
return OWNER_ROUTE_HANDLE_PATTERN.test(owner);
}
export function isOwnerRouteScopeSegment(owner: string) {
return OWNER_ROUTE_SCOPE_PATTERN.test(owner);
}
export function isOwnerRouteHandleOrIdSegment(owner: string) {
return isOwnerRouteHandleSegment(owner) || isOwnerRouteIdSegment(owner);
}
+6 -5
View File
@@ -470,11 +470,11 @@ describe("fetchPluginCatalog", () => {
it("uses the dedicated plugins endpoint for browse mode without touching the unified catalog", async () => {
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(
new Response(JSON.stringify({ items: [], nextCursor: "plugins:next" }), { status: 200 }),
);
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ items: [], nextCursor: "plugins:next", totalCount: 42 }), {
status: 200,
}),
);
const result = await fetchPluginCatalog({
isOfficial: true,
@@ -482,6 +482,7 @@ describe("fetchPluginCatalog", () => {
});
expect(result.nextCursor).toBe("plugins:next");
expect(result.totalCount).toBe(42);
expect(fetchMock).toHaveBeenCalledTimes(1);
const url = new URL(fetchMock.mock.calls[0]?.[0] as string);
expect(url.pathname).toBe("/api/v1/plugins");
+5 -1
View File
@@ -167,16 +167,18 @@ export type PackageVersionDetail = {
};
type PluginFamily = "code-plugin" | "bundle-plugin";
type PackageCatalogSort = "updated" | "downloads";
type PackageCatalogSort = "updated" | "downloads" | "recommended";
type PluginCatalogResult = {
items: PackageListItem[];
nextCursor: string | null;
totalCount?: number | null;
};
type PackageCatalogBrowseResponse = {
items: PackageListItem[];
nextCursor: string | null;
totalCount?: number | null;
};
type PackageApiErrorOptions = {
@@ -454,6 +456,7 @@ export async function fetchPluginCatalog(params: {
return {
items: browseResponse?.items ?? [],
nextCursor: browseResponse?.nextCursor ?? null,
totalCount: browseResponse?.totalCount ?? null,
};
}
@@ -497,6 +500,7 @@ export async function fetchPluginCatalog(params: {
return {
items: result?.items ?? [],
nextCursor: result?.nextCursor ?? null,
totalCount: result?.totalCount ?? null,
};
}
+2 -4
View File
@@ -7,15 +7,13 @@ import {
} from "@tanstack/react-router";
import { SkillDetailPage } from "../../components/SkillDetailPage";
import { buildSkillMeta } from "../../lib/og";
import { isOwnerRouteHandleOrIdSegment, isOwnerRouteScopeSegment } from "../../lib/ownerRoute";
import { fetchSkillPageData } from "../../lib/skillPage";
import { resolveOpenClawPluginSlug } from "../../lib/slugRoute";
export const Route = createFileRoute("/$owner/$slug")({
beforeLoad: ({ params }) => {
const isHandle = /^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
const isScope = /^@[a-zA-Z0-9_][a-zA-Z0-9_-]*$/.test(params.owner);
const isOwnerId = params.owner.startsWith("users:") || params.owner.startsWith("publishers:");
if (!isHandle && !isScope && !isOwnerId) {
if (!isOwnerRouteHandleOrIdSegment(params.owner) && !isOwnerRouteScopeSegment(params.owner)) {
throw notFound();
}
},
+2 -3
View File
@@ -6,15 +6,14 @@ import {
SecurityAuditPageSkeleton,
} from "../../../components/SecurityAuditPage";
import { buildSkillMeta } from "../../../lib/og";
import { isOwnerRouteHandleOrIdSegment } from "../../../lib/ownerRoute";
import { isModerator } from "../../../lib/roles";
import { fetchSkillPageData } from "../../../lib/skillPage";
import { useAuthStatus } from "../../../lib/useAuthStatus";
export const Route = createFileRoute("/$owner/$slug/security-audit")({
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) throw notFound();
if (!isOwnerRouteHandleOrIdSegment(params.owner)) throw notFound();
},
loader: async ({ params }) => {
const data = await fetchSkillPageData(params.slug);
@@ -1,10 +1,9 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { isOwnerRouteHandleOrIdSegment } from "../../../../lib/ownerRoute";
export const Route = createFileRoute("/$owner/$slug/security/$scanner")({
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) throw notFound();
if (!isOwnerRouteHandleOrIdSegment(params.owner)) throw notFound();
throw redirect({
to: "/$owner/$slug/security-audit",
params: {
+2 -3
View File
@@ -1,13 +1,12 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { SkillDetailPage } from "../../../components/SkillDetailPage";
import { buildSkillMeta } from "../../../lib/og";
import { isOwnerRouteHandleOrIdSegment } from "../../../lib/ownerRoute";
import { fetchSkillPageData } from "../../../lib/skillPage";
export const Route = createFileRoute("/$owner/$slug/settings")({
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) {
if (!isOwnerRouteHandleOrIdSegment(params.owner)) {
throw notFound();
}
},
+1 -1
View File
@@ -654,7 +654,7 @@ function SkillsHome() {
</div>
<div className="home-v2-cat-text">
<div className="home-v2-cat-name">Publishers</div>
<div className="home-v2-cat-desc">Builders and orgs</div>
<div className="home-v2-cat-desc">People and organizations</div>
</div>
<span className="home-v2-cat-arrow">
<ChevronRight size={16} />
+292 -218
View File
@@ -1,11 +1,14 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { isPluginCategorySlug } from "clawhub-schema";
import { PackageSearch, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "convex/react";
import { PackageSearch, Search, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "../../../convex/_generated/api";
import { BrowseSidebar } from "../../components/BrowseSidebar";
import { PluginListItem } from "../../components/PluginListItem";
import { BrowseResultsSkeleton } from "../../components/skeletons/BrowseResultsSkeleton";
import { Button } from "../../components/ui/button";
import { formatBrowseCount } from "../../lib/browseCount";
import { PLUGIN_CATEGORIES } from "../../lib/categories";
import {
fetchPluginCatalog,
@@ -13,9 +16,11 @@ import {
type PackageListItem,
} from "../../lib/packageApi";
type PluginSort = "relevance" | "updated" | "downloads" | "newest" | "name";
type VisiblePluginSort = "recommended" | "updated" | "downloads";
type PluginSort = VisiblePluginSort | "relevance";
type LegacyPluginSort = PluginSort | "newest" | "name";
const PLUGINS_PAGE_SIZE = 100;
const PLUGINS_PAGE_SIZE = 25;
type PluginSearchState = {
q?: string;
@@ -25,13 +30,19 @@ type PluginSearchState = {
featured?: boolean;
official?: boolean;
executesCode?: boolean;
sort?: PluginSort;
sort?: LegacyPluginSort;
view?: LegacyPluginView;
};
type PluginView = "list" | "grid";
type LegacyPluginView = PluginView | "cards";
const PLUGIN_SORT_OPTIONS = [
{ value: "recommended", label: "Recommended" },
{ value: "downloads", label: "Most downloaded" },
{ value: "updated", label: "Recently updated" },
];
function normalizePluginView(value: unknown): PluginView | undefined {
if (value === "list") return "list";
if (value === "grid" || value === "cards") return "grid";
@@ -43,9 +54,34 @@ type PluginsLoaderData = {
nextCursor: string | null;
rateLimited: boolean;
retryAfterSeconds: number | null;
totalCount?: number | null;
isLoading?: boolean;
apiError?: boolean;
};
type PluginsPageDataRequest = {
q?: string;
category?: string;
cursor?: string;
featured?: boolean;
official?: boolean;
executesCode?: boolean;
sort?: PluginSort;
signal?: AbortSignal;
};
function createPluginsLoadingData(): PluginsLoaderData {
return {
items: [],
nextCursor: null,
rateLimited: false,
retryAfterSeconds: null,
totalCount: null,
isLoading: true,
apiError: false,
};
}
function formatRetryDelay(retryAfterSeconds: number | null) {
if (!retryAfterSeconds || retryAfterSeconds <= 0) return "in a moment";
if (retryAfterSeconds < 60) {
@@ -55,8 +91,9 @@ function formatRetryDelay(retryAfterSeconds: number | null) {
return `in about ${minutes} minute${minutes === 1 ? "" : "s"}`;
}
function parsePluginSort(value: unknown): PluginSort | undefined {
function parsePluginSort(value: unknown): LegacyPluginSort | undefined {
if (
value === "recommended" ||
value === "relevance" ||
value === "updated" ||
value === "downloads" ||
@@ -69,7 +106,7 @@ function parsePluginSort(value: unknown): PluginSort | undefined {
}
function sortPluginSearchItems(items: PackageListItem[], sort: PluginSort) {
if (sort === "relevance") return items;
if (sort === "recommended" || sort === "relevance") return items;
const sorted = [...items];
sorted.sort((a, b) => {
const tieBreak = () =>
@@ -78,23 +115,6 @@ function sortPluginSearchItems(items: PackageListItem[], sort: PluginSort) {
a.family.localeCompare(b.family) ||
a.name.localeCompare(b.name);
if (sort === "name") {
return (
a.displayName.localeCompare(b.displayName) ||
a.name.localeCompare(b.name) ||
a.family.localeCompare(b.family)
);
}
if (sort === "newest") {
return (
b.createdAt - a.createdAt ||
b.updatedAt - a.updatedAt ||
a.family.localeCompare(b.family) ||
a.name.localeCompare(b.name)
);
}
if (sort === "downloads") {
return (b.stats?.downloads ?? 0) - (a.stats?.downloads ?? 0) || tieBreak();
}
@@ -104,16 +124,67 @@ function sortPluginSearchItems(items: PackageListItem[], sort: PluginSort) {
return sorted;
}
function formatPluginHeadingCount(count: number, hasNextPage: boolean, hasPreviousPage: boolean) {
if (hasPreviousPage) return `${count} shown`;
if (hasNextPage) return `${count}+`;
return String(count);
function normalizeActivePluginSort(sort: LegacyPluginSort | undefined): PluginSort | undefined {
if (sort === "newest" || sort === "name") return undefined;
return sort;
}
function formatPluginResultsCount(count: number, hasNextPage: boolean, hasPreviousPage: boolean) {
if (hasPreviousPage) return `${count} result${count === 1 ? "" : "s"} shown`;
if (hasNextPage) return `${count}+ results`;
return `${count} result${count === 1 ? "" : "s"}`;
function isNavigationAbortError(err: unknown, signal?: AbortSignal) {
if (signal?.aborted) return true;
return err instanceof Error && err.name === "AbortError";
}
export async function loadPluginsPageData(
args: PluginsPageDataRequest,
): Promise<PluginsLoaderData> {
try {
const data = await fetchPluginCatalog({
q: args.q,
category: args.category,
cursor: args.q ? undefined : args.cursor,
featured: args.featured,
isOfficial: args.official,
executesCode: args.executesCode,
...(!args.q && (args.sort === "downloads" || !args.sort || args.sort === "recommended")
? { sort: args.sort ?? "recommended" }
: {}),
limit: PLUGINS_PAGE_SIZE,
signal: args.signal,
});
return {
items: data?.items ?? [],
nextCursor: data?.nextCursor ?? null,
totalCount: data?.totalCount ?? null,
rateLimited: false,
retryAfterSeconds: null,
isLoading: false,
apiError: false,
};
} catch (error) {
if (isNavigationAbortError(error, args.signal)) throw error;
if (isRateLimitedPackageApiError(error)) {
return {
items: [],
nextCursor: null,
rateLimited: true,
retryAfterSeconds: error.retryAfterSeconds,
totalCount: null,
isLoading: false,
apiError: false,
};
}
return {
items: [],
nextCursor: null,
rateLimited: false,
retryAfterSeconds: null,
totalCount: null,
isLoading: false,
apiError: true,
};
}
}
export const Route = createFileRoute("/plugins/")({
@@ -148,71 +219,27 @@ export const Route = createFileRoute("/plugins/")({
beforeLoad: ({ search }) => {
const hasQuery = Boolean(search.q?.trim());
const incompatibleSort =
!hasQuery && search.sort && search.sort !== "updated" && search.sort !== "downloads";
const browseOnlyFeatured = hasQuery && search.featured;
search.sort &&
search.sort !== "recommended" &&
search.sort !== "updated" &&
search.sort !== "downloads" &&
!(hasQuery && search.sort === "relevance");
const staleFeatured = Boolean(search.featured);
const invalidCategory = Boolean(search.category && !isPluginCategorySlug(search.category));
if (incompatibleSort || browseOnlyFeatured || invalidCategory) {
if (incompatibleSort || staleFeatured || invalidCategory) {
throw redirect({
to: "/plugins",
search: {
...search,
category: invalidCategory ? undefined : search.category,
featured: browseOnlyFeatured ? undefined : search.featured,
featured: staleFeatured ? undefined : search.featured,
sort: incompatibleSort ? undefined : search.sort,
},
replace: true,
});
}
},
loaderDeps: ({ search }) => ({
q: search.q,
category: search.category,
cursor: search.cursor,
featured: search.featured,
official: search.official,
executesCode: search.executesCode,
sort: search.sort,
}),
loader: async ({ deps }): Promise<PluginsLoaderData> => {
try {
const data = await fetchPluginCatalog({
q: deps.q,
category: deps.category,
cursor: deps.q ? undefined : deps.cursor,
featured: deps.featured,
isOfficial: deps.official,
executesCode: deps.executesCode,
...(!deps.q && deps.sort === "downloads" ? { sort: deps.sort } : {}),
limit: PLUGINS_PAGE_SIZE,
});
return {
items: data?.items ?? [],
nextCursor: data?.nextCursor ?? null,
rateLimited: false,
retryAfterSeconds: null,
apiError: false,
};
} catch (error) {
if (isRateLimitedPackageApiError(error)) {
return {
items: [],
nextCursor: null,
rateLimited: true,
retryAfterSeconds: error.retryAfterSeconds,
apiError: false,
};
}
return {
items: [],
nextCursor: null,
rateLimited: false,
retryAfterSeconds: null,
apiError: true,
};
}
},
loader: (): PluginsLoaderData => createPluginsLoadingData(),
component: PluginsIndex,
});
@@ -224,22 +251,31 @@ function PluginsIndexPending() {
Filters
</button>
<h1 className="browse-title">Plugins</h1>
<div className="browse-view-toggle">
<button className="browse-view-btn is-active" type="button" disabled>
List
</button>
<button className="browse-view-btn" type="button" disabled>
Grid
</button>
</div>
</div>
<div className="browse-page-search">
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
<input className="browse-search-input" placeholder="Search plugins..." disabled />
<input
className="browse-search-input"
aria-label="Search plugins"
placeholder="Search plugins..."
disabled
/>
</div>
<div className="browse-layout">
<BrowseSidebar
categories={PLUGIN_CATEGORIES}
activeCategory={undefined}
onCategoryChange={() => {}}
sortOptions={[
{ value: "featured", label: "Featured" },
{ value: "downloads", label: "Most downloaded" },
{ value: "updated", label: "Recently updated" },
]}
activeSort="updated"
sortOptions={PLUGIN_SORT_OPTIONS}
activeSort="recommended"
onSortChange={() => {}}
filters={[
{ key: "official", label: "Official only", active: false },
@@ -250,14 +286,6 @@ function PluginsIndexPending() {
<div className="browse-results">
<div className="browse-results-toolbar">
<span className="browse-results-count">Loading results</span>
<div className="browse-view-toggle">
<button className="browse-view-btn is-active" type="button" disabled>
List
</button>
<button className="browse-view-btn" type="button" disabled>
Grid
</button>
</div>
</div>
<BrowseResultsSkeleton />
</div>
@@ -269,57 +297,94 @@ function PluginsIndexPending() {
function PluginsIndex() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const loaderData = Route.useLoaderData() as PluginsLoaderData | undefined;
const initialLoaderData = Route.useLoaderData() as PluginsLoaderData | undefined;
const [catalogData, setCatalogData] = useState<PluginsLoaderData>(
() => initialLoaderData ?? createPluginsLoadingData(),
);
const shouldKeepInitialDataRef = useRef(
Boolean(initialLoaderData && !initialLoaderData.isLoading),
);
// Defensive handling for when loader data is unavailable (SSR errors, etc.)
const items = loaderData?.items ?? [];
const nextCursor = loaderData?.nextCursor ?? null;
const rateLimited = loaderData?.rateLimited ?? false;
const retryAfterSeconds = loaderData?.retryAfterSeconds ?? null;
const apiError = loaderData?.apiError ?? !loaderData;
const items = catalogData.items;
const nextCursor = catalogData.nextCursor;
const rateLimited = catalogData.rateLimited;
const retryAfterSeconds = catalogData.retryAfterSeconds;
const totalPluginsCount = useQuery(api.packages.countPublicPlugins, {});
const totalCount = catalogData.totalCount ?? totalPluginsCount ?? null;
const isLoading = catalogData.isLoading ?? false;
const apiError = catalogData.apiError ?? false;
const view = normalizePluginView(search.view) ?? "list";
const [query, setQuery] = useState(search.q ?? "");
const [sidebarOpen, setSidebarOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchNavigateTimer = useRef<number>(0);
useEffect(() => {
setQuery(search.q ?? "");
}, [search.q]);
const hasQuery = Boolean(search.q?.trim());
const hasActiveFilters =
hasQuery ||
Boolean(search.category) ||
Boolean(search.official) ||
Boolean(search.executesCode) ||
Boolean(search.featured);
const formattedCount = !hasActiveFilters ? formatBrowseCount(totalCount) : null;
useEffect(() => {
if (shouldKeepInitialDataRef.current) {
shouldKeepInitialDataRef.current = false;
return () => {};
}
const controller = new AbortController();
setCatalogData(createPluginsLoadingData());
void loadPluginsPageData({
q: search.q,
category: search.category,
cursor: search.cursor,
featured: search.featured,
official: search.official,
executesCode: search.executesCode,
sort: normalizeActivePluginSort(search.sort),
signal: controller.signal,
})
.then((data) => setCatalogData(data))
.catch((error) => {
if (isNavigationAbortError(error, controller.signal)) return;
setCatalogData({
items: [],
nextCursor: null,
rateLimited: false,
retryAfterSeconds: null,
totalCount: null,
isLoading: false,
apiError: true,
});
});
return () => controller.abort();
}, [
search.category,
search.cursor,
search.executesCode,
search.featured,
search.official,
search.q,
search.sort,
]);
const activeCategory = search.category;
const activeSort = hasQuery
? (search.sort ?? "relevance")
: search.featured
? "featured"
: (search.sort ?? "updated");
const activeSort: PluginSort =
search.sort === "relevance" || search.sort === "newest" || search.sort === "name"
? "recommended"
: (search.sort ?? "recommended");
const visibleItems = useMemo(
() => (hasQuery ? sortPluginSearchItems(items, activeSort as PluginSort) : items),
() => (hasQuery ? sortPluginSearchItems(items, activeSort) : items),
[activeSort, hasQuery, items],
);
const hasPreviousPage = Boolean(!hasQuery && search.cursor);
const hasNextPage = Boolean(!hasQuery && nextCursor);
const headingCount = formatPluginHeadingCount(visibleItems.length, hasNextPage, hasPreviousPage);
const resultsCount = formatPluginResultsCount(visibleItems.length, hasNextPage, hasPreviousPage);
const sortOptions = useMemo(() => {
if (hasQuery) {
return [
{ value: "relevance", label: "Relevance" },
{ value: "downloads", label: "Most downloaded" },
{ value: "updated", label: "Recently updated" },
{ value: "newest", label: "Newest" },
{ value: "name", label: "Name" },
];
}
return [
{ value: "featured", label: "Featured" },
{ value: "downloads", label: "Most downloaded" },
{ value: "updated", label: "Recently updated" },
];
}, [hasQuery]);
const handleFilterToggle = (key: string) => {
if (key === "official") {
@@ -342,33 +407,14 @@ function PluginsIndex() {
};
const handleSortChange = (value: string) => {
if (value === "featured") {
void navigate({
search: (prev: PluginSearchState) => ({
...prev,
cursor: undefined,
featured: true,
family: undefined,
q: undefined,
sort: undefined,
}),
});
return;
}
if (hasQuery) {
void navigate({
search: (prev: PluginSearchState) => ({
...prev,
cursor: undefined,
family: undefined,
featured: undefined,
sort: parsePluginSort(value) === "relevance" ? undefined : parsePluginSort(value),
}),
replace: true,
});
return;
}
const nextSort = parsePluginSort(value);
const sort =
nextSort === "recommended" ||
nextSort === "relevance" ||
nextSort === "newest" ||
nextSort === "name"
? undefined
: nextSort;
void navigate({
search: (prev: PluginSearchState) => ({
@@ -376,7 +422,7 @@ function PluginsIndex() {
cursor: undefined,
family: undefined,
featured: undefined,
sort: parsePluginSort(value) === "updated" ? undefined : parsePluginSort(value),
sort,
}),
replace: true,
});
@@ -397,17 +443,58 @@ function PluginsIndex() {
});
};
useEffect(() => {
return () => window.clearTimeout(searchNavigateTimer.current);
}, []);
const navigateToPluginSearch = useCallback(
(next: string, replace: boolean) => {
const trimmed = next.trim();
void navigate({
search: (prev: PluginSearchState) => ({
...prev,
cursor: undefined,
family: undefined,
q: trimmed ? next : undefined,
featured: undefined,
sort: undefined,
}),
replace,
});
},
[navigate],
);
const handleQueryChange = useCallback(
(next: string) => {
setQuery(next);
window.clearTimeout(searchNavigateTimer.current);
searchNavigateTimer.current = window.setTimeout(() => {
navigateToPluginSearch(next, true);
}, 220);
},
[navigateToPluginSearch],
);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
window.clearTimeout(searchNavigateTimer.current);
navigateToPluginSearch(query, false);
};
const handleClearSearch = () => {
window.clearTimeout(searchNavigateTimer.current);
setQuery("");
searchInputRef.current?.focus();
void navigate({
search: (prev: PluginSearchState) => ({
...prev,
q: undefined,
cursor: undefined,
family: undefined,
q: query.trim() || undefined,
featured: undefined,
sort: undefined,
featured: undefined,
}),
replace: true,
});
};
@@ -421,24 +508,6 @@ function PluginsIndex() {
});
};
const handleClear = () => {
void navigate({
search: (prev: PluginSearchState) => ({
...prev,
cursor: undefined,
family: undefined,
q: undefined,
category: undefined,
official: undefined,
executesCode: undefined,
featured: undefined,
sort: undefined,
}),
replace: true,
});
setQuery("");
};
return (
<main className="browse-page">
<div className="browse-page-header">
@@ -451,24 +520,58 @@ function PluginsIndex() {
Filters
</button>
<h1 className="browse-title">
Plugins <span className="browse-count">{headingCount}</span>
Plugins
{formattedCount ? (
<>
{" "}
<span className="browse-count">{formattedCount}</span>
</>
) : null}
</h1>
<div className="browse-view-toggle">
<button
className={`browse-view-btn${view === "list" ? " is-active" : ""}`}
type="button"
onClick={view === "grid" ? handleToggleView : undefined}
>
List
</button>
<button
className={`browse-view-btn${view === "grid" ? " is-active" : ""}`}
type="button"
onClick={view === "list" ? handleToggleView : undefined}
>
Grid
</button>
</div>
</div>
<form className="browse-page-search" onSubmit={handleSearch}>
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
<input
ref={searchInputRef}
className="browse-search-input"
aria-label="Search plugins"
placeholder="Search plugins..."
value={query}
onChange={(event) => setQuery(event.target.value)}
onChange={(event) => handleQueryChange(event.target.value)}
/>
{query ? (
<button
type="button"
className="browse-search-clear"
aria-label="Clear plugin search"
onClick={handleClearSearch}
>
<X size={14} aria-hidden="true" />
</button>
) : null}
</form>
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
<BrowseSidebar
categories={PLUGIN_CATEGORIES}
activeCategory={activeCategory}
onCategoryChange={handleCategoryChange}
sortOptions={sortOptions}
sortOptions={PLUGIN_SORT_OPTIONS}
activeSort={activeSort}
onSortChange={handleSortChange}
filters={[
@@ -478,38 +581,9 @@ function PluginsIndex() {
onFilterToggle={handleFilterToggle}
/>
<div className="browse-results">
<div className="browse-results-toolbar">
<span className="browse-results-count">
{resultsCount}
{hasQuery ||
search.category ||
search.official ||
search.executesCode ||
search.featured ? (
<button className="browse-clear-btn" type="button" onClick={handleClear}>
Clear
</button>
) : null}
</span>
<div className="browse-view-toggle">
<button
className={`browse-view-btn${view === "list" ? " is-active" : ""}`}
type="button"
onClick={view === "grid" ? handleToggleView : undefined}
>
List
</button>
<button
className={`browse-view-btn${view === "grid" ? " is-active" : ""}`}
type="button"
onClick={view === "list" ? handleToggleView : undefined}
>
Grid
</button>
</div>
</div>
{apiError ? (
{isLoading ? (
<BrowseResultsSkeleton variant={view} />
) : apiError ? (
<div className="empty-state">
<PackageSearch size={22} className="empty-state-icon" aria-hidden="true" />
<p className="empty-state-title">Unable to load plugins</p>
@@ -540,7 +614,7 @@ function PluginsIndex() {
</div>
)}
{!hasQuery && (search.cursor || nextCursor) ? (
{!isLoading && !hasQuery && (search.cursor || nextCursor) ? (
<div className="mt-5 flex justify-center gap-3">
{search.cursor ? (
<Button
+139 -129
View File
@@ -1,14 +1,16 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { LayoutGrid, List, Search } from "lucide-react";
import { createFileRoute } from "@tanstack/react-router";
import { Search, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../../convex/_generated/api";
import { BrowseSidebar } from "../../components/BrowseSidebar";
import { PublisherListItem } from "../../components/PublisherListItem";
import { Button } from "../../components/ui/button";
import { convexHttp } from "../../convex/client";
import { formatBrowseCount } from "../../lib/browseCount";
import type { PublicPublisherListItem } from "../../lib/publicUser";
import { getClawHubSiteUrl, SITE_NAME } from "../../lib/site";
type PublisherKindSearch = "orgs" | "builders";
type PublisherKindSearch = "orgs" | "people";
type PublisherViewSearch = "list" | "grid";
type PublishersSearchState = {
@@ -35,31 +37,16 @@ type PublishersLoaderResult = {
const PUBLISHER_PAGE_SIZE = 25;
function listedCountLabel(value: number, total: number, kind?: PublisherKindSearch) {
const label =
kind === "orgs"
? total === 1
? "org"
: "orgs"
: kind === "builders"
? total === 1
? "builder"
: "builders"
: total === 1
? "publisher"
: "publishers";
return total > value ? `Showing ${value} of ${total} ${label}` : `Showing all ${total} ${label}`;
function normalizePublisherKind(value: unknown): PublisherKindSearch | undefined {
if (value === "orgs") return "orgs";
if (value === "people" || value === "builders" || value === "individuals") return "people";
return undefined;
}
export const Route = createFileRoute("/publishers/")({
validateSearch: (search): PublishersSearchState => ({
kind:
search.kind === "orgs" || search.kind === "builders" || search.kind === "individuals"
? search.kind === "individuals"
? "builders"
: search.kind
: undefined,
q: typeof search.q === "string" && search.q.trim() ? search.q : undefined,
kind: normalizePublisherKind(search.kind),
q: typeof search.q === "string" && search.q.trim() ? search.q.trim() : undefined,
view: search.view === "grid" ? "grid" : undefined,
}),
loaderDeps: ({ search }) => search,
@@ -90,7 +77,7 @@ export const Route = createFileRoute("/publishers/")({
},
loader: async ({ deps }): Promise<PublishersLoaderResult> =>
(await convexHttp.query(api.publishers.listPublicPage, {
kind: deps.kind === "orgs" ? "org" : deps.kind === "builders" ? "user" : undefined,
kind: deps.kind === "orgs" ? "org" : deps.kind === "people" ? "user" : undefined,
query: deps.q,
paginationOpts: { cursor: null, numItems: PUBLISHER_PAGE_SIZE },
})) as PublishersLoaderResult,
@@ -107,24 +94,18 @@ function PublishersIndex() {
result.isDone ? null : result.continueCursor,
);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const loadMoreInFlightRef = useRef(false);
const counts = result.counts ?? {
all: publishers.length,
organizations: publishers.filter((publisher) => publisher.kind === "org").length,
individuals: publishers.filter((publisher) => publisher.kind === "user").length,
};
const globalCounts = result.globalCounts ?? counts;
const searchInputRef = useRef<HTMLInputElement>(null);
const activeKind = search.kind;
const activeView = search.view ?? "list";
const activeTotal =
activeKind === "orgs"
? counts.organizations
: activeKind === "builders"
? counts.individuals
: counts.all;
const canLoadMore = Boolean(nextCursor);
const hasQuery = Boolean(search.q?.trim());
const hasActiveFilters = hasQuery || Boolean(activeKind);
const formattedCount = !hasActiveFilters
? formatBrowseCount(result.globalCounts?.all ?? result.counts.all)
: null;
const showHighlights = !hasQuery && !activeKind;
const highlightedPublishers = showHighlights ? publishers.slice(0, 3) : [];
const directoryPublishers = showHighlights ? publishers.slice(3) : publishers;
@@ -152,13 +133,36 @@ function PublishersIndex() {
[navigate],
);
const handleClear = useCallback(() => {
const handleClearQuery = useCallback(() => {
setQuery("");
searchInputRef.current?.focus();
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
q: undefined,
kind: undefined,
}),
replace: true,
});
}, [navigate]);
const handleKindChange = useCallback(
(kind: string | undefined) => {
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
kind: normalizePublisherKind(kind),
}),
replace: true,
});
},
[navigate],
);
const handleToggleView = useCallback(() => {
void navigate({
search: (prev: PublishersSearchState) => ({
...prev,
view: prev.view === "grid" ? undefined : "grid",
}),
replace: true,
});
@@ -170,7 +174,7 @@ function PublishersIndex() {
setIsLoadingMore(true);
try {
const page = (await convexHttp.query(api.publishers.listPublicPage, {
kind: activeKind === "orgs" ? "org" : activeKind === "builders" ? "user" : undefined,
kind: activeKind === "orgs" ? "org" : activeKind === "people" ? "user" : undefined,
query: search.q,
paginationOpts: { cursor: nextCursor, numItems: PUBLISHER_PAGE_SIZE },
})) as PublishersLoaderResult;
@@ -202,113 +206,119 @@ function PublishersIndex() {
return (
<main className="browse-page">
<div className="browse-page-header">
<button
className="browse-sidebar-toggle"
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle filters"
>
Filters
</button>
<h1 className="browse-title">
Publishers
<span className="browse-count">{globalCounts.all}</span>
{formattedCount ? (
<>
{" "}
<span className="browse-count">{formattedCount}</span>
</>
) : null}
</h1>
<div className="browse-view-toggle">
<button
className={`browse-view-btn${activeView === "list" ? " is-active" : ""}`}
type="button"
onClick={activeView === "grid" ? handleToggleView : undefined}
>
List
</button>
<button
className={`browse-view-btn${activeView === "grid" ? " is-active" : ""}`}
type="button"
onClick={activeView === "list" ? handleToggleView : undefined}
>
Grid
</button>
</div>
</div>
<div className="browse-page-search">
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
<input
ref={searchInputRef}
className="browse-search-input"
aria-label="Search publishers"
value={query}
onChange={(event) => handleQueryChange(event.target.value)}
placeholder="Search publishers..."
/>
{query ? (
<button
type="button"
className="browse-search-clear"
aria-label="Clear publisher search"
onClick={handleClearQuery}
>
<X size={14} aria-hidden="true" />
</button>
) : null}
</div>
<div className="browse-results publishers-results">
{highlightedPublishers.length > 0 ? (
<section className="publisher-highlights" aria-labelledby="publisher-highlights-title">
<div className="publisher-section-heading">
<h2 id="publisher-highlights-title">Popular publishers</h2>
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
<BrowseSidebar
radioGroups={[
{
title: "Type",
ariaLabel: "Publisher type",
activeValue: activeKind,
onChange: handleKindChange,
options: [
{ value: undefined, label: "All" },
{ value: "orgs", label: "Organizations" },
{ value: "people", label: "People" },
],
},
]}
/>
<div className="browse-results">
{highlightedPublishers.length > 0 ? (
<section className="publisher-highlights" aria-labelledby="publisher-highlights-title">
<div className="publisher-section-heading">
<h2 id="publisher-highlights-title">Popular publishers</h2>
</div>
<div className="publisher-highlight-grid">
{highlightedPublishers.map((publisher) => (
<PublisherListItem
key={publisher._id}
publisher={publisher}
variant="highlight"
/>
))}
</div>
</section>
) : null}
{publishers.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No publishers found</p>
</div>
<div className="publisher-highlight-grid">
{highlightedPublishers.map((publisher) => (
<PublisherListItem key={publisher._id} publisher={publisher} variant="highlight" />
) : (
<div className={`publisher-directory-list publisher-directory-${activeView}`}>
{directoryPublishers.map((publisher) => (
<PublisherListItem
key={publisher._id}
publisher={publisher}
variant={activeView === "grid" ? "grid" : "list"}
/>
))}
</div>
</section>
) : null}
<div className="browse-results-toolbar publishers-toolbar">
<span className="browse-results-count publisher-listed-count">
{listedCountLabel(publishers.length, activeTotal, activeKind)}
{hasQuery || activeKind ? (
<button className="browse-clear-btn" type="button" onClick={handleClear}>
Clear
</button>
) : null}
</span>
<div className="publisher-toolbar-controls">
<nav className="publisher-filter-tabs" aria-label="Publisher type">
<Link
to="/publishers"
search={{ q: search.q, view: search.view }}
className={`publisher-filter-tab${!activeKind ? " is-active" : ""}`}
>
All <span>{counts.all}</span>
</Link>
<Link
to="/publishers"
search={{ q: search.q, kind: "orgs", view: search.view }}
className={`publisher-filter-tab${activeKind === "orgs" ? " is-active" : ""}`}
>
Orgs <span>{counts.organizations}</span>
</Link>
<Link
to="/publishers"
search={{ q: search.q, kind: "builders", view: search.view }}
className={`publisher-filter-tab${activeKind === "builders" ? " is-active" : ""}`}
>
Builders <span>{counts.individuals}</span>
</Link>
</nav>
<nav className="publisher-filter-tabs publisher-view-tabs" aria-label="Publisher view">
<Link
to="/publishers"
search={{ q: search.q, kind: search.kind }}
resetScroll={false}
aria-label="List view"
className={`publisher-filter-tab${activeView === "list" ? " is-active" : ""}`}
>
<List size={14} aria-hidden="true" />
</Link>
<Link
to="/publishers"
search={{ q: search.q, kind: search.kind, view: "grid" }}
resetScroll={false}
aria-label="Grid view"
className={`publisher-filter-tab${activeView === "grid" ? " is-active" : ""}`}
>
<LayoutGrid size={14} aria-hidden="true" />
</Link>
</nav>
</div>
)}
{canLoadMore || isLoadingMore ? (
<div ref={loadMoreRef} className="card mt-4 flex justify-center">
<Button type="button" onClick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : "Load more"}
</Button>
</div>
) : null}
</div>
{publishers.length === 0 ? (
<div className="empty-state">
<p className="empty-state-title">No publishers found</p>
</div>
) : (
<div className={`publisher-directory-list publisher-directory-${activeView}`}>
{directoryPublishers.map((publisher) => (
<PublisherListItem
key={publisher._id}
publisher={publisher}
variant={activeView === "grid" ? "grid" : "list"}
/>
))}
</div>
)}
{canLoadMore || isLoadingMore ? (
<div ref={loadMoreRef} className="card mt-4 flex justify-center">
<Button type="button" onClick={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : "Load more"}
</Button>
</div>
) : null}
</div>
</main>
);
+6 -37
View File
@@ -60,7 +60,6 @@ function UnifiedSearchPage() {
});
const results: Array<UnifiedSkillResult | UnifiedPluginResult> =
activeType === "all" ? allResults : activeType === "skills" ? skillResults : pluginResults;
const showSearchCounts = Boolean(search.q);
const allCount = skillCount + pluginCount;
const allHasMore = skillHasMore || pluginHasMore;
const canLoadMore =
@@ -144,32 +143,21 @@ function UnifiedSearchPage() {
type="button"
onClick={() => setType("all")}
>
All{" "}
{showSearchCounts ? (
<span className="search-tab-count">{formatSearchCount(allCount, allHasMore)}</span>
) : null}
All
</button>
<button
className={`search-tab${activeType === "skills" ? " is-active" : ""}`}
type="button"
onClick={() => setType("skills")}
>
Skills{" "}
{showSearchCounts ? (
<span className="search-tab-count">{formatSearchCount(skillCount, skillHasMore)}</span>
) : null}
Skills
</button>
<button
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
type="button"
onClick={() => setType("plugins")}
>
Plugins{" "}
{showSearchCounts ? (
<span className="search-tab-count">
{formatSearchCount(pluginCount, pluginHasMore)}
</span>
) : null}
Plugins
</button>
</div>
@@ -191,20 +179,14 @@ function UnifiedSearchPage() {
{activeType === "all" ? (
<div className="search-results-sections">
{skillResults.length > 0 ? (
<SearchResultSection
countLabel={formatSearchCount(skillCount, skillHasMore)}
title="Skills"
>
<SearchResultSection title="Skills">
{skillResults.map((item) => (
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
))}
</SearchResultSection>
) : null}
{pluginResults.length > 0 ? (
<SearchResultSection
countLabel={formatSearchCount(pluginCount, pluginHasMore)}
title="Plugins"
>
<SearchResultSection title="Plugins">
{pluginResults.map((item) => (
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
))}
@@ -270,24 +252,11 @@ function SearchEmptyState({
);
}
function formatSearchCount(count: number, hasMore: boolean) {
return hasMore ? `${count}+` : String(count);
}
function SearchResultSection({
children,
countLabel,
title,
}: {
children: React.ReactNode;
countLabel: string;
title: string;
}) {
function SearchResultSection({ children, title }: { children: React.ReactNode; title: string }) {
return (
<section className="search-results-section" aria-label={title}>
<div className="search-results-section-header">
<h2 className="search-results-section-title">{title}</h2>
<span className="search-results-section-count">{countLabel}</span>
</div>
<div className="results-list">{children}</div>
</section>
+2 -2
View File
@@ -755,7 +755,7 @@ export function Settings() {
setOrgHandle(event.target.value);
setCreateOrgError(null);
}}
placeholder="openclaw"
placeholder="example.tools"
/>
</Field>
<Field label="Display name" htmlFor="settings-org-display-name">
@@ -1151,7 +1151,7 @@ export function Settings() {
setOrgHandle(event.target.value);
setCreateOrgError(null);
}}
placeholder="openclaw"
placeholder="example.tools"
/>
</Field>
<Field label="Display name" htmlFor="settings-org-display-name-empty">
+25 -10
View File
@@ -4,7 +4,6 @@ import { api } from "../../../convex/_generated/api";
import { convexHttp } from "../../convex/client";
import {
ALL_CATEGORY_KEYWORDS,
getSkillCategoryByKeyword,
getSkillCategoryBySlug,
getSkillCategoryForSkill,
} from "../../lib/categories";
@@ -84,17 +83,13 @@ export function useSkillsBrowseModel({
const searchSkills = useAction(api.search.searchSkills);
const trimmedQuery = useMemo(() => query.trim(), [query]);
const legacyQueryCategory = useMemo(() => {
if (query === "__other__") return getSkillCategoryBySlug("other");
return getSkillCategoryByKeyword(trimmedQuery);
}, [query, trimmedQuery]);
const urlCategory = useMemo(() => getSkillCategoryBySlug(search.category), [search.category]);
const activeCategory = urlCategory ?? legacyQueryCategory;
const activeCategory = urlCategory;
const categoryKeywords =
activeCategory && activeCategory.slug !== "other" ? activeCategory.keywords : undefined;
const excludeCategoryKeywords =
activeCategory?.slug === "other" ? ALL_CATEGORY_KEYWORDS : undefined;
const hasQuery = trimmedQuery.length > 0 && (Boolean(urlCategory) || !legacyQueryCategory);
const hasQuery = trimmedQuery.length > 0;
const requestedSort = search.sort === "default" ? "recommended" : search.sort;
const sort: SortKey =
requestedSort === "relevance" && !hasQuery
@@ -408,11 +403,30 @@ export function useSkillsBrowseModel({
});
}, [navigate]);
const onClearQuery = useCallback(() => {
window.clearTimeout(navigateTimer.current);
setQuery("");
searchInputRef.current?.focus();
void navigate({
search: (prev) => {
const clearsSearchOnlySort = parseSort(prev.sort) === "relevance";
return {
...prev,
q: undefined,
sort: clearsSearchOnlySort ? undefined : prev.sort,
dir: clearsSearchOnlySort ? undefined : prev.dir,
};
},
replace: true,
});
}, [navigate, searchInputRef]);
const onSortChange = useCallback(
(value: string) => {
const nextSort = parseSort(value);
void navigate({
search: (prev) => {
const clearsDefaultSearchSort = hasQuery && nextSort === "recommended";
const reusePreviousDir =
prev.sort !== undefined &&
prev.sort !== "recommended" &&
@@ -420,9 +434,9 @@ export function useSkillsBrowseModel({
prev.sort !== "relevance";
return {
...prev,
sort: nextSort,
sort: clearsDefaultSearchSort ? undefined : nextSort,
dir:
nextSort === "recommended" || nextSort === "default"
clearsDefaultSearchSort || nextSort === "recommended" || nextSort === "default"
? undefined
: parseDir(reusePreviousDir ? prev.dir : undefined, nextSort),
};
@@ -430,7 +444,7 @@ export function useSkillsBrowseModel({
replace: true,
});
},
[navigate],
[hasQuery, navigate],
);
const onToggleDir = useCallback(() => {
@@ -485,6 +499,7 @@ export function useSkillsBrowseModel({
loadMoreRef,
onCapabilityTagChange,
onClearFilters,
onClearQuery,
onQueryChange,
onSortChange,
onToggleDir,
+51 -49
View File
@@ -1,11 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { Search } from "lucide-react";
import { Search, X } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { api } from "../../../convex/_generated/api";
import { BrowseSidebar } from "../../components/BrowseSidebar";
import { formatBrowseCount } from "../../lib/browseCount";
import { SKILL_CATEGORIES } from "../../lib/categories";
import { formatCompactStat } from "../../lib/numberFormat";
import { parseDir, parseSort } from "./-params";
import { SkillsResults } from "./-SkillsResults";
import {
@@ -24,13 +24,11 @@ const BROWSE_SORT_OPTIONS = [
{ value: "name", label: "Name" },
];
const SEARCH_SORT_OPTIONS = [
{ value: "downloads", label: "Most downloaded" },
{ value: "stars", label: "Most starred" },
{ value: "installs", label: "Most installed" },
{ value: "updated", label: "Recently updated" },
{ value: "newest", label: "Newest" },
{ value: "name", label: "Name" },
const FEATURED_SORT_OPTION = { value: "featured", label: "Featured" };
const SKILLS_SORT_OPTIONS = [
BROWSE_SORT_OPTIONS[0],
FEATURED_SORT_OPTION,
...BROWSE_SORT_OPTIONS.slice(1),
];
const SKILL_CATEGORY_SLUGS = new Set(SKILL_CATEGORIES.map((category) => category.slug));
@@ -65,8 +63,6 @@ export function SkillsIndex() {
const navigate = Route.useNavigate();
const search = Route.useSearch();
const searchInputRef = useRef<HTMLInputElement>(null);
const totalSkills = useQuery(api.skills.countPublicSkills);
const totalSkillsText = typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
const [sidebarOpen, setSidebarOpen] = useState(false);
const model = useSkillsBrowseModel({
@@ -75,9 +71,15 @@ export function SkillsIndex() {
searchInputRef,
});
const sortOptionsWithRelevance = model.hasQuery
? [{ value: "relevance", label: "Relevance" }, ...SEARCH_SORT_OPTIONS]
: BROWSE_SORT_OPTIONS;
const activeSort = model.featuredOnly
? "featured"
: model.sort === "relevance"
? "recommended"
: model.sort;
const hasActiveFilters =
model.hasQuery || Boolean(model.activeCategory) || model.featuredOnly || Boolean(search.tag);
const totalSkillsCount = useQuery(api.skills.countPublicSkills, {});
const formattedCount = !hasActiveFilters ? formatBrowseCount(totalSkillsCount) : null;
const handleSortChange = useCallback(
(value: string) => {
@@ -116,10 +118,6 @@ export function SkillsIndex() {
[model.featuredOnly, model.onSortChange, model.onToggleFeatured, navigate],
);
const handleClear = useCallback(() => {
model.onClearFilters();
}, [model.onClearFilters]);
const handleCategoryChange = useCallback(
(slug: string | undefined) => {
const category = parseSkillCategorySlug(slug);
@@ -149,57 +147,61 @@ export function SkillsIndex() {
</button>
<h1 className="browse-title">
Skills
{totalSkillsText ? <span className="browse-count">{totalSkillsText}</span> : null}
{formattedCount ? (
<>
{" "}
<span className="browse-count">{formattedCount}</span>
</>
) : null}
</h1>
<div className="browse-view-toggle">
<button
className={`browse-view-btn${model.view === "list" ? " is-active" : ""}`}
type="button"
onClick={model.view === "grid" ? model.onToggleView : undefined}
>
List
</button>
<button
className={`browse-view-btn${model.view === "grid" ? " is-active" : ""}`}
type="button"
onClick={model.view === "list" ? model.onToggleView : undefined}
>
Grid
</button>
</div>
</div>
<div className="browse-page-search">
<Search size={15} className="navbar-search-icon" aria-hidden="true" />
<input
ref={searchInputRef}
className="browse-search-input"
aria-label="Search skills"
value={model.query}
onChange={(event) => model.onQueryChange(event.target.value)}
placeholder="Search skills..."
/>
{model.query ? (
<button
type="button"
className="browse-search-clear"
aria-label="Clear skill search"
onClick={model.onClearQuery}
>
<X size={14} aria-hidden="true" />
</button>
) : null}
</div>
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
<BrowseSidebar
categories={SKILL_CATEGORIES}
activeCategory={model.activeCategory}
onCategoryChange={handleCategoryChange}
sortOptions={[{ value: "featured", label: "Featured" }, ...sortOptionsWithRelevance]}
activeSort={model.featuredOnly ? "featured" : model.sort}
sortOptions={SKILLS_SORT_OPTIONS}
activeSort={activeSort}
onSortChange={handleSortChange}
/>
<div className="browse-results">
<div className="browse-results-toolbar">
<span className="browse-results-count">
{model.isLoadingSkills ? "\u2014" : `${model.sorted.length} results`}
{model.hasQuery || model.activeCategory || model.featuredOnly ? (
<button className="browse-clear-btn" type="button" onClick={handleClear}>
Clear
</button>
) : null}
</span>
<div className="browse-results-actions">
<div className="browse-view-toggle">
<button
className={`browse-view-btn${model.view === "list" ? " is-active" : ""}`}
type="button"
onClick={model.view === "grid" ? model.onToggleView : undefined}
>
List
</button>
<button
className={`browse-view-btn${model.view === "grid" ? " is-active" : ""}`}
type="button"
onClick={model.view === "list" ? model.onToggleView : undefined}
>
Grid
</button>
</div>
</div>
</div>
<SkillsResults
isLoadingSkills={model.isLoadingSkills}
sorted={model.sorted}
+281 -2
View File
@@ -1,16 +1,22 @@
import { createFileRoute, Link, notFound } from "@tanstack/react-router";
import { usePaginatedQuery, useQuery } from "convex/react";
import { useAction, useMutation, usePaginatedQuery, useQuery } from "convex/react";
import {
ArrowDownToLine,
Building2,
GitBranch,
Package,
PlugZap,
RefreshCw,
Save,
Star,
Users,
Wrench,
type LucideIcon,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import { type FormEvent, type ReactNode, useEffect, useState } from "react";
import { toast } from "sonner";
import { api } from "../../../convex/_generated/api";
import type { Doc } from "../../../convex/_generated/dataModel";
import { EmptyState } from "../../components/EmptyState";
import { Container } from "../../components/layout/Container";
import { MarketplaceIcon } from "../../components/MarketplaceIcon";
@@ -18,7 +24,10 @@ import { OfficialBadge, OfficialTag } from "../../components/OfficialBadge";
import { BrowseResultsSkeleton } from "../../components/skeletons/BrowseResultsSkeleton";
import { Button } from "../../components/ui/button";
import { Card, CardContent } from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Skeleton } from "../../components/ui/skeleton";
import { Textarea } from "../../components/ui/textarea";
import { getUserFacingConvexError } from "../../lib/convexError";
import { formatCompactStat } from "../../lib/numberFormat";
import { buildPublisherMeta } from "../../lib/og";
import type {
@@ -77,6 +86,16 @@ type PublisherMemberResult = {
type PublishedView = "list" | "grid";
type ProfileCatalogTab = "skills" | "plugins" | "stars";
type PublisherMembership = {
publisher: PublicPublisher;
role: "owner" | "admin" | "publisher";
};
type GitHubRepositoryLink = Doc<"publisherGitHubRepositories"> & {
sourceLinkCount: number;
};
const INTERNAL_GITHUB_SYNC_PUBLISHER_HANDLE = "openclaw";
const GITHUB_INSTALL_RETRY_DELAY_MS = 3_000;
const GITHUB_INSTALL_MAX_RETRIES = 20;
const roleColor: Record<string, "accent" | "default" | "compact"> = {
owner: "accent",
@@ -84,6 +103,25 @@ const roleColor: Record<string, "accent" | "default" | "compact"> = {
publisher: "compact",
};
export function canManagePublisherGitHubSync(
publisher: PublicPublisherListItem | null | undefined,
memberships: PublisherMembership[] | undefined,
) {
const canManageViewedOrg =
publisher?.kind === "org" &&
Boolean(
memberships?.some(
(entry) =>
entry.publisher._id === publisher._id &&
(entry.role === "owner" || entry.role === "admin"),
),
);
const isInternalRolloutUser = Boolean(
memberships?.some((entry) => entry.publisher.handle === INTERNAL_GITHUB_SYNC_PUBLISHER_HANDLE),
);
return canManageViewedOrg && isInternalRolloutUser;
}
function GitHubIcon({ size = 14 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" width={size} height={size} aria-hidden="true">
@@ -113,6 +151,7 @@ function PublisherProfile() {
api.publishers.getPublishedDisplayManifest,
publishedQueryArgs,
) as PublicPublisherCatalogDisplay | null | undefined;
const myPublishers = useQuery(api.publishers.listMine, {}) as PublisherMembership[] | undefined;
const members = useQuery(api.publishers.listMembers, { publisherHandle: handle }) as
| PublisherMemberResult
| null
@@ -137,6 +176,60 @@ function PublisherProfile() {
);
const publishedItems = (publishedResults ?? []) as PublicPublisherCatalogItem[];
const starredItems = (starredResults ?? []) as PublicPublisherCatalogItem[];
const canManageGitHubSync = canManagePublisherGitHubSync(publisher, myPublishers);
const githubRepositories = useQuery(
api.githubApp.listPublisherRepositories,
canManageGitHubSync && publisher ? { publisherId: publisher._id } : "skip",
) as GitHubRepositoryLink[] | undefined;
const completePublisherInstall = useAction(api.githubApp.completePublisherInstall);
useEffect(() => {
if (!canManageGitHubSync) return undefined;
const params = new URLSearchParams(window.location.search);
const state = params.get("state");
const installationId = params.get("installation_id");
if (!state || !installationId) return undefined;
let cancelled = false;
let attempts = 0;
let showedPendingToast = false;
let retryTimer: number | undefined;
const tryCompleteInstall = () => {
attempts += 1;
completePublisherInstall({ state, installationId })
.then((result) => {
if (cancelled) return;
toast.success(`Connected ${result.repositories.length} GitHub repositories`);
const next = new URL(window.location.href);
next.searchParams.delete("state");
next.searchParams.delete("installation_id");
next.searchParams.delete("setup_action");
window.history.replaceState(null, "", `${next.pathname}${next.search}${next.hash}`);
})
.catch((error: unknown) => {
if (cancelled) return;
const message = getUserFacingConvexError(error, "GitHub App connection failed");
const shouldRetry =
message.includes("GitHub installation confirmation is still pending") &&
attempts < GITHUB_INSTALL_MAX_RETRIES;
if (shouldRetry) {
if (!showedPendingToast) {
toast.error(`${message} Retrying...`);
showedPendingToast = true;
}
retryTimer = window.setTimeout(tryCompleteInstall, GITHUB_INSTALL_RETRY_DELAY_MS);
return;
}
toast.error(message);
});
};
tryCompleteInstall();
return () => {
cancelled = true;
if (retryTimer) window.clearTimeout(retryTimer);
};
}, [canManageGitHubSync, completePublisherInstall]);
if (publisher === undefined) {
return (
@@ -353,6 +446,10 @@ function PublisherProfile() {
</aside>
<section className="publisher-profile-main" aria-labelledby="publisher-published-title">
{canManageGitHubSync ? (
<GitHubSyncPanel publisher={publisher} repositories={githubRepositories} />
) : null}
<div className="publisher-profile-section-header">
<div>
<h2 id="publisher-published-title" className="sr-only">
@@ -430,6 +527,188 @@ function PublisherProfile() {
);
}
export function GitHubSyncPanel({
publisher,
repositories,
}: {
publisher: PublicPublisherListItem;
repositories: GitHubRepositoryLink[] | undefined;
}) {
const beginPublisherInstall = useAction(api.githubApp.beginPublisherInstall);
const [targetId, setTargetId] = useState("");
const [connecting, setConnecting] = useState(false);
const connect = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedTargetId = targetId.trim();
if (!trimmedTargetId) {
toast.error("GitHub account ID is required");
return;
}
setConnecting(true);
try {
const result = await beginPublisherInstall({
publisherId: publisher._id,
targetId: trimmedTargetId,
});
window.location.assign(result.url);
} catch (error) {
setConnecting(false);
toast.error(getUserFacingConvexError(error, "GitHub App setup failed"));
}
};
return (
<section className="publisher-profile-github-panel" aria-labelledby="publisher-github-title">
<div className="publisher-profile-github-header">
<div>
<h2 id="publisher-github-title">GitHub Sync</h2>
<span>{repositories === undefined ? "Loading" : `${repositories.length} repos`}</span>
</div>
<form className="publisher-profile-github-connect" onSubmit={connect}>
<Input
value={targetId}
onChange={(event) => setTargetId(event.target.value)}
inputMode="numeric"
placeholder="GitHub account ID"
aria-label="GitHub account ID"
/>
<Button type="submit" size="sm" loading={connecting}>
<PlugZap size={14} aria-hidden="true" />
Connect
</Button>
</form>
</div>
{repositories === undefined ? (
<div className="publisher-profile-github-loading" role="status">
Loading GitHub repositories...
</div>
) : repositories.length > 0 ? (
<div className="publisher-profile-github-repos">
{repositories.map((repo) => (
<GitHubRepositoryRow key={repo._id} repo={repo} />
))}
</div>
) : (
<p className="publisher-profile-empty-copy">No GitHub repositories connected.</p>
)}
</section>
);
}
function GitHubRepositoryRow({ repo }: { repo: GitHubRepositoryLink }) {
const updateRepositorySyncSettings = useMutation(api.githubApp.updateRepositorySyncSettings);
const queueRepositorySync = useMutation(api.githubApp.queueRepositorySync);
const [syncRef, setSyncRef] = useState(repo.syncRef);
const [syncRoots, setSyncRoots] = useState(repo.syncRoots.join("\n"));
const [mode, setMode] = useState<Doc<"publisherGitHubRepositories">["mode"]>(repo.mode);
const [enabled, setEnabled] = useState(repo.enabled);
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
useEffect(() => {
setSyncRef(repo.syncRef);
setSyncRoots(repo.syncRoots.join("\n"));
setMode(repo.mode);
setEnabled(repo.enabled);
}, [repo._id, repo.enabled, repo.mode, repo.syncRef, repo.syncRoots]);
const save = async () => {
setSaving(true);
try {
await updateRepositorySyncSettings({
repositoryId: repo._id,
syncRef,
syncRoots: syncRoots
.split("\n")
.map((root) => root.trim())
.filter(Boolean),
mode,
enabled,
});
toast.success("GitHub repository settings saved");
} catch (error) {
toast.error(getUserFacingConvexError(error, "Repository settings failed"));
} finally {
setSaving(false);
}
};
const syncNow = async () => {
setSyncing(true);
try {
await queueRepositorySync({ repositoryId: repo._id });
toast.success("GitHub sync queued");
} catch (error) {
toast.error(getUserFacingConvexError(error, "GitHub sync failed"));
} finally {
setSyncing(false);
}
};
return (
<article className="publisher-profile-github-repo">
<div className="publisher-profile-github-repo-heading">
<div>
<h3>{repo.repoFullName}</h3>
<span>
{repo.sourceLinkCount} linked · {repo.lastSyncStatus}
</span>
</div>
<label className="publisher-profile-github-toggle">
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
/>
Enabled
</label>
</div>
<div className="publisher-profile-github-grid">
<label>
<span>Branch</span>
<Input value={syncRef} onChange={(event) => setSyncRef(event.target.value)} />
</label>
<label>
<span>Mode</span>
<select
className="publisher-profile-github-select"
value={mode}
onChange={(event) => setMode(event.target.value === "mapped" ? "mapped" : "discover")}
>
<option value="discover">Discover</option>
<option value="mapped">Mapped</option>
</select>
</label>
</div>
<label className="publisher-profile-github-roots">
<span>Roots</span>
<Textarea value={syncRoots} onChange={(event) => setSyncRoots(event.target.value)} />
</label>
<div className="publisher-profile-github-repo-footer">
<span>
<GitBranch size={14} aria-hidden="true" />
{repo.lastSyncedCommit ? repo.lastSyncedCommit.slice(0, 7) : repo.defaultBranch}
</span>
<div>
<Button type="button" size="sm" variant="outline" onClick={syncNow} loading={syncing}>
<RefreshCw size={14} aria-hidden="true" />
Sync
</Button>
<Button type="button" size="sm" onClick={save} loading={saving}>
<Save size={14} aria-hidden="true" />
Save
</Button>
</div>
</div>
</article>
);
}
function PublisherStat({
icon: Icon,
value,
+228 -74
View File
@@ -10462,37 +10462,10 @@ a.agentic-risk-finding-title:focus-visible {
font-size: 0.82rem;
}
.publishers-results {
gap: 44px;
}
.browse-page-search + .publishers-results {
margin-top: 18px;
}
.publishers-toolbar {
align-items: center;
margin-top: 6px;
}
.publisher-listed-count {
display: inline-flex;
align-items: center;
gap: 10px;
}
.publisher-toolbar-controls {
display: inline-flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.publisher-highlights {
display: grid;
gap: 18px;
margin-top: 22px;
margin-bottom: 26px;
margin-bottom: 14px;
}
.publisher-section-heading {
@@ -10882,43 +10855,6 @@ a.agentic-risk-finding-title:focus-visible {
grid-template-columns: 1fr;
}
.publishers-toolbar {
display: grid;
grid-template-columns: 1fr auto;
align-items: end;
}
.publisher-listed-count {
display: none;
}
.publisher-toolbar-controls {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-column: 1 / -1;
gap: 8px;
width: 100%;
margin-left: 0;
}
.publisher-filter-tabs {
min-width: 0;
}
.publisher-filter-tabs:not(.publisher-view-tabs) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.publisher-filter-tab {
justify-content: center;
padding-inline: 8px;
}
.publisher-view-tabs {
display: none;
}
.publisher-card {
grid-template-columns: minmax(0, 1fr);
align-items: start;
@@ -11233,12 +11169,6 @@ a.agentic-risk-finding-title:focus-visible {
margin-bottom: var(--space-3);
}
.browse-page-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.browse-page-search {
display: flex;
align-items: center;
@@ -11257,6 +11187,33 @@ a.agentic-risk-finding-title:focus-visible {
border-color: var(--border-ui-active);
}
.browse-search-clear {
display: inline-flex;
width: 28px;
height: 28px;
flex: 0 0 28px;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--r-sm);
color: var(--ink-soft);
background: transparent;
cursor: pointer;
transition:
color 150ms ease,
background-color 150ms ease;
}
.browse-search-clear:hover {
color: var(--ink);
background: var(--hover-bg);
}
.browse-search-clear:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);
outline-offset: 2px;
}
.audits-page {
max-width: 1180px;
}
@@ -11768,9 +11725,15 @@ a.agentic-risk-finding-title:focus-visible {
overflow: hidden;
}
.browse-page-header .browse-view-toggle {
margin-left: auto;
}
.browse-view-btn {
all: unset;
padding: var(--space-1) var(--space-3);
box-sizing: border-box;
height: 36px;
padding: 0 var(--space-3);
font-size: var(--fs-sm);
color: var(--ink-soft);
cursor: pointer;
@@ -11831,8 +11794,8 @@ a.agentic-risk-finding-title:focus-visible {
}
.browse-view-btn {
min-height: 44px;
padding: var(--space-2) var(--space-3);
height: 44px;
padding: 0 var(--space-3);
}
.browse-layout {
@@ -12764,6 +12727,156 @@ a.publisher-profile-detail:hover {
font-size: var(--fs-xs);
}
.publisher-profile-github-panel {
display: grid;
gap: 14px;
padding-bottom: 22px;
border-bottom: 1px solid var(--line);
}
.publisher-profile-github-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
}
.publisher-profile-github-header > div {
min-width: 0;
display: grid;
gap: 3px;
}
.publisher-profile-github-header h2 {
margin: 0;
color: var(--ink);
font-size: var(--fs-md);
font-weight: 750;
letter-spacing: 0;
}
.publisher-profile-github-header span,
.publisher-profile-github-loading,
.publisher-profile-github-repo-heading span,
.publisher-profile-github-repo-footer span {
color: var(--ink-soft);
font-size: var(--fs-xs);
}
.publisher-profile-github-connect {
display: grid;
grid-template-columns: minmax(150px, 210px) auto;
align-items: center;
gap: 8px;
}
.publisher-profile-github-connect input {
min-height: 34px;
font-size: var(--fs-xs);
}
.publisher-profile-github-repos {
display: grid;
gap: 10px;
}
.publisher-profile-github-repo {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
}
.publisher-profile-github-repo-heading,
.publisher-profile-github-repo-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.publisher-profile-github-repo-heading > div {
min-width: 0;
display: grid;
gap: 3px;
}
.publisher-profile-github-repo h3 {
margin: 0;
overflow: hidden;
color: var(--ink);
font-size: var(--fs-sm);
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.publisher-profile-github-toggle {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 7px;
color: var(--ink);
font-size: var(--fs-xs);
font-weight: 700;
}
.publisher-profile-github-toggle input {
width: 16px;
height: 16px;
accent-color: var(--accent);
}
.publisher-profile-github-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px;
gap: 10px;
}
.publisher-profile-github-grid label,
.publisher-profile-github-roots {
min-width: 0;
display: grid;
gap: 5px;
}
.publisher-profile-github-grid label span,
.publisher-profile-github-roots span {
color: var(--ink-soft);
font-size: var(--fs-xs);
font-weight: 700;
}
.publisher-profile-github-grid input,
.publisher-profile-github-select {
min-height: 36px;
font-size: var(--fs-xs);
}
.publisher-profile-github-select {
width: 100%;
border: 1px solid var(--input-border);
border-radius: var(--radius-sm);
background: var(--input-bg);
color: var(--ink);
padding: 0 10px;
}
.publisher-profile-github-roots textarea {
min-height: 76px;
font-family: var(--font-mono);
font-size: var(--fs-xs);
}
.publisher-profile-github-repo-footer span,
.publisher-profile-github-repo-footer div {
display: inline-flex;
align-items: center;
gap: 8px;
}
.publisher-profile-section-header {
display: flex;
align-items: center;
@@ -13374,6 +13487,25 @@ a.publisher-profile-detail:hover {
flex-direction: column;
gap: 12px;
}
.publisher-profile-section-controls {
width: 100%;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
}
.publisher-profile-github-header,
.publisher-profile-github-repo-heading,
.publisher-profile-github-repo-footer {
align-items: flex-start;
flex-direction: column;
}
.publisher-profile-github-connect {
width: 100%;
grid-template-columns: minmax(0, 1fr) auto;
}
}
@media (max-width: 640px) {
@@ -13419,6 +13551,28 @@ a.publisher-profile-detail:hover {
flex-direction: column;
}
.publisher-profile-section-controls {
width: 100%;
align-items: flex-start;
justify-content: flex-start;
flex-wrap: wrap;
}
.publisher-profile-github-connect,
.publisher-profile-github-grid {
grid-template-columns: 1fr;
}
.publisher-profile-github-connect button,
.publisher-profile-github-repo-footer div,
.publisher-profile-github-repo-footer button {
width: 100%;
}
.publisher-profile-github-repo-footer div {
justify-content: stretch;
}
.publisher-published-row {
grid-template-columns: auto minmax(0, 1fr);
}