mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 01:12:11 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2ea75c921 | ||
|
|
1e3bc9b02a | ||
|
|
af9cfce93b | ||
|
|
be29a89a72 | ||
|
|
273d11c5a9 | ||
|
|
cf6441add5 | ||
|
|
869e340ecc | ||
|
|
2a2e6c538c | ||
|
|
9a2342d627 | ||
|
|
85be25bccb | ||
|
|
e953810d8b | ||
|
|
bdeb8d0120 | ||
|
|
47454585be | ||
|
|
bbc5e2c385 | ||
|
|
8683f18d3a | ||
|
|
a01323e835 | ||
|
|
b2447d750a | ||
|
|
8ce11888aa | ||
|
|
3e66b50065 | ||
|
|
4f2d20e1e0 | ||
|
|
1f66c9cde6 | ||
|
|
dfac8d83ce |
Vendored
+6
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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("")}`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}]`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -314,7 +314,7 @@ function createMigrationFixture(params: {
|
||||
if (table === "authAccounts") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => null,
|
||||
take: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }] : [];
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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?",
|
||||
|
||||
Vendored
+18
@@ -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;
|
||||
|
||||
Vendored
+22
@@ -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?",
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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?",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user