mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-16 18:02:09 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef8d53fa5f | ||
|
|
f6661a7a4c | ||
|
|
d2c2e7b509 | ||
|
|
70f6a435e2 | ||
|
|
929c9de50e | ||
|
|
ea67295794 | ||
|
|
f5a3cba962 | ||
|
|
b2a173ab75 | ||
|
|
2e74c9bad2 |
@@ -73,9 +73,6 @@ on:
|
||||
description: Published release id when dry_run is false.
|
||||
value: ${{ jobs.publish.outputs.release_id }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -337,7 +334,7 @@ jobs:
|
||||
PY
|
||||
|
||||
- name: Upload publish JSON artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: clawhub-package-publish-json
|
||||
path: ${{ runner.temp }}/package-publish.json
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
- `bun run preview` — preview built app.
|
||||
- `bunx convex dev` — Convex dev deployment + function watcher.
|
||||
- `bunx convex codegen` — regenerate `convex/_generated`.
|
||||
- `bun run format:check` — formatting check.
|
||||
- `bun run lint` — Biome + oxlint (type-aware).
|
||||
- `bun run test` — Vitest (unit tests).
|
||||
- `bun run coverage` — coverage run; keep global >= 80%.
|
||||
@@ -38,7 +37,6 @@
|
||||
|
||||
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`…).
|
||||
- Keep changes scoped; avoid repo-wide search/replace.
|
||||
- Before commit/PR handoff, run `bun run format:check` and `bun run lint`; include commands run in the PR summary.
|
||||
- PRs: include summary + test commands run. Add screenshots for UI changes.
|
||||
- Before merging any PR, verify TypeScript cleanly with `bunx tsc -p packages/schema/tsconfig.json --noEmit` and `bunx tsc -p packages/clawhub/tsconfig.json --noEmit`; if Convex code changed, also run the repo typecheck path used by deploy so `bunx convex deploy` will not fail on `tsc`.
|
||||
- GitHub comments: for multiline `gh` comments/close messages, use `--body-file`, `--input`, or stdin/heredoc with real newlines; never pass literal `\\n` in shell strings.
|
||||
@@ -92,13 +90,11 @@
|
||||
- **Before writing or reviewing Convex queries, check deployment health.** Run `bunx convex insights` to check for OCC conflicts, `bytesReadLimit`, and `documentsReadLimit` errors. Run `bunx convex logs --failure` to see individual error messages and stack traces. This helps identify which functions are causing bandwidth issues so you can prioritize fixes.
|
||||
|
||||
<!-- convex-ai-start -->
|
||||
|
||||
This project uses [Convex](https://convex.dev) as its backend.
|
||||
|
||||
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
|
||||
|
||||
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
|
||||
|
||||
<!-- convex-ai-end -->
|
||||
|
||||
## Stat Field Migration Rules
|
||||
@@ -106,11 +102,11 @@ Convex agent skills for common tasks can be installed by running `npx convex ai-
|
||||
The `skills` table maintains two parallel sets of stat fields as part of an in-progress field migration:
|
||||
|
||||
| Legacy (nested, `@deprecated`) | Top-level (source of truth, indexable) |
|
||||
| ------------------------------ | -------------------------------------- |
|
||||
| `stats.downloads` | `statsDownloads` |
|
||||
| `stats.stars` | `statsStars` |
|
||||
| `stats.installsCurrent` | `statsInstallsCurrent` |
|
||||
| `stats.installsAllTime` | `statsInstallsAllTime` |
|
||||
|---|---|
|
||||
| `stats.downloads` | `statsDownloads` |
|
||||
| `stats.stars` | `statsStars` |
|
||||
| `stats.installsCurrent` | `statsInstallsCurrent` |
|
||||
| `stats.installsAllTime` | `statsInstallsAllTime` |
|
||||
|
||||
**Rules:**
|
||||
|
||||
|
||||
+15
-393
@@ -20,19 +20,6 @@ type SeedSkillSpec = {
|
||||
rawSkillMd: string;
|
||||
};
|
||||
|
||||
type SeedPluginSpec = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
summary: string;
|
||||
version: string;
|
||||
runtimeId: string;
|
||||
sourceRepo: string;
|
||||
isOfficial: boolean;
|
||||
capabilityTags: string[];
|
||||
stats: { downloads: number; installs: number; stars: number; versions: number };
|
||||
readme: string;
|
||||
};
|
||||
|
||||
type SeedActionArgs = {
|
||||
reset?: boolean;
|
||||
};
|
||||
@@ -69,134 +56,6 @@ This seeded plugin is public and intentionally has completed scan results so loc
|
||||
preview plugin scanner detail pages without owner-only visibility.
|
||||
`;
|
||||
|
||||
const FEATURED_PLUGIN_SEEDS: SeedPluginSpec[] = [
|
||||
{
|
||||
name: "@apify/apify-openclaw-plugin",
|
||||
displayName: "Apify",
|
||||
summary:
|
||||
"Scrape websites through Apify actors and make structured web data available to agents.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "apify",
|
||||
sourceRepo: "apify/apify-openclaw-plugin",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["web", "scraping", "automation"],
|
||||
stats: { downloads: 1200, installs: 320, stars: 45, versions: 1 },
|
||||
readme: "# Apify\n\nScrape websites through Apify actors from OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "openclaw-codex-app-server",
|
||||
displayName: "Codex App Server Bridge",
|
||||
summary: "Bind OpenClaw chats to Codex App Server conversations and control threads from chat.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "codex-app-server",
|
||||
sourceRepo: "pwrdrvr/openclaw-codex-app-server",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["codex", "chat", "bridge"],
|
||||
stats: { downloads: 980, installs: 280, stars: 37, versions: 1 },
|
||||
readme: "# Codex App Server Bridge\n\nBridge OpenClaw chat sessions to Codex App Server.",
|
||||
},
|
||||
{
|
||||
name: "@largezhou/ddingtalk",
|
||||
displayName: "DingTalk",
|
||||
summary: "Connect OpenClaw to DingTalk enterprise robots with text, image, and file messages.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "dingtalk",
|
||||
sourceRepo: "largezhou/openclaw-dingtalk",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["channel", "dingtalk", "enterprise"],
|
||||
stats: { downloads: 930, installs: 250, stars: 32, versions: 1 },
|
||||
readme: "# DingTalk\n\nDingTalk enterprise robot plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "kudosity-openclaw-sms",
|
||||
displayName: "Kudosity SMS",
|
||||
summary: "Send and receive SMS through Kudosity as an OpenClaw plugin.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "kudosity-sms",
|
||||
sourceRepo: "kudosity/openclaw-sms",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["channel", "sms", "kudosity"],
|
||||
stats: { downloads: 860, installs: 210, stars: 29, versions: 1 },
|
||||
readme: "# Kudosity SMS\n\nKudosity SMS channel plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "@martian-engineering/lossless-claw",
|
||||
displayName: "Lossless Claw",
|
||||
summary:
|
||||
"Preserve conversation context with DAG-based summarization and incremental compaction.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "lossless-claw",
|
||||
sourceRepo: "Martian-Engineering/lossless-claw",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["memory", "context", "summarization"],
|
||||
stats: { downloads: 820, installs: 190, stars: 28, versions: 1 },
|
||||
readme: "# Lossless Claw\n\nLossless context management plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "@opik/opik-openclaw",
|
||||
displayName: "Opik",
|
||||
summary: "Export OpenClaw traces to Opik for monitoring, costs, token usage, and debugging.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "opik",
|
||||
sourceRepo: "comet-ml/opik-openclaw",
|
||||
isOfficial: true,
|
||||
capabilityTags: ["observability", "tracing", "monitoring"],
|
||||
stats: { downloads: 760, installs: 180, stars: 25, versions: 1 },
|
||||
readme: "# Opik\n\nTrace OpenClaw agents with Opik.",
|
||||
},
|
||||
{
|
||||
name: "@prometheusavatar/openclaw-plugin",
|
||||
displayName: "Prometheus Avatar",
|
||||
summary: "Give OpenClaw agents a Live2D avatar with lip-sync, expressions, and speech.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "prometheus-avatar",
|
||||
sourceRepo: "myths-labs/prometheus-avatar",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["avatar", "tts", "live2d"],
|
||||
stats: { downloads: 690, installs: 150, stars: 22, versions: 1 },
|
||||
readme: "# Prometheus Avatar\n\nLive2D avatar plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "@tencent-connect/openclaw-qqbot",
|
||||
displayName: "QQbot",
|
||||
summary:
|
||||
"Connect OpenClaw to QQ private chats, group mentions, channel messages, and rich media.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "qqbot",
|
||||
sourceRepo: "tencent-connect/openclaw-qqbot",
|
||||
isOfficial: true,
|
||||
capabilityTags: ["channel", "qq", "messaging"],
|
||||
stats: { downloads: 640, installs: 140, stars: 20, versions: 1 },
|
||||
readme: "# QQbot\n\nQQ Bot plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "@wecom/wecom-openclaw-plugin",
|
||||
displayName: "wecom",
|
||||
summary:
|
||||
"Use WeCom Bot WebSocket connections for direct messages, group chats, and proactive messaging.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "wecom",
|
||||
sourceRepo: "WecomTeam/wecom-openclaw-plugin",
|
||||
isOfficial: true,
|
||||
capabilityTags: ["channel", "wecom", "enterprise"],
|
||||
stats: { downloads: 610, installs: 130, stars: 18, versions: 1 },
|
||||
readme: "# wecom\n\nWeCom channel plugin for OpenClaw.",
|
||||
},
|
||||
{
|
||||
name: "openclaw-plugin-yuanbao",
|
||||
displayName: "Yuanbao",
|
||||
summary:
|
||||
"Connect OpenClaw to Yuanbao with direct messages, group chats, media, and slash commands.",
|
||||
version: "1.0.0",
|
||||
runtimeId: "yuanbao",
|
||||
sourceRepo: "yb-claw/openclaw-plugin-yuanbao",
|
||||
isOfficial: false,
|
||||
capabilityTags: ["channel", "yuanbao", "messaging"],
|
||||
stats: { downloads: 580, installs: 125, stars: 17, versions: 1 },
|
||||
readme: "# Yuanbao\n\nYuanbao channel plugin for OpenClaw.",
|
||||
},
|
||||
];
|
||||
|
||||
type RoleHelpFixtureUser = {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
@@ -523,13 +382,12 @@ async function seedNixSkillsHandler(
|
||||
results.push({ slug: spec.slug, ...result });
|
||||
}
|
||||
|
||||
const [flaggedSkillStorageId, flaggedPluginStorageId, scannedPluginStorageId] = await Promise.all(
|
||||
[
|
||||
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([SCANNED_PLUGIN_README], { type: "text/markdown" })),
|
||||
],
|
||||
);
|
||||
const [flaggedSkillStorageId, flaggedPluginStorageId, scannedPluginStorageId] =
|
||||
await Promise.all([
|
||||
ctx.storage.store(new Blob([FLAGGED_SKILL_MD], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([FLAGGED_PLUGIN_README], { type: "text/markdown" })),
|
||||
ctx.storage.store(new Blob([SCANNED_PLUGIN_README], { type: "text/markdown" })),
|
||||
]);
|
||||
const fixtureResult: SeedMutationResult = await ctx.runMutation(
|
||||
internal.devSeed.seedRescanUxFixturesMutation,
|
||||
{
|
||||
@@ -544,32 +402,6 @@ async function seedNixSkillsHandler(
|
||||
);
|
||||
results.push({ slug: FLAGGED_SKILL_SLUG, ...fixtureResult });
|
||||
|
||||
const featuredPluginStorageIds = await Promise.all(
|
||||
FEATURED_PLUGIN_SEEDS.map(async (spec) =>
|
||||
ctx.storage.store(new Blob([spec.readme], { type: "text/markdown" })),
|
||||
),
|
||||
);
|
||||
const featuredResult: SeedMutationResult = await ctx.runMutation(
|
||||
internal.devSeed.seedFeaturedPluginPackagesMutation,
|
||||
{
|
||||
reset: args.reset,
|
||||
packages: FEATURED_PLUGIN_SEEDS.map((spec, index) => ({
|
||||
name: spec.name,
|
||||
displayName: spec.displayName,
|
||||
summary: spec.summary,
|
||||
version: spec.version,
|
||||
runtimeId: spec.runtimeId,
|
||||
sourceRepo: spec.sourceRepo,
|
||||
isOfficial: spec.isOfficial,
|
||||
capabilityTags: spec.capabilityTags,
|
||||
stats: spec.stats,
|
||||
storageId: featuredPluginStorageIds[index],
|
||||
readmeSize: spec.readme.length,
|
||||
})),
|
||||
},
|
||||
);
|
||||
results.push({ slug: "featured-plugins", ...featuredResult });
|
||||
|
||||
return { ok: true, results };
|
||||
}
|
||||
|
||||
@@ -730,59 +562,6 @@ async function findScannedPluginFixture(ctx: MutationCtx) {
|
||||
return await findSeedPluginFixtureByName(ctx, SCANNED_PLUGIN_NAME);
|
||||
}
|
||||
|
||||
async function ensureHighlightedSkillBadge(
|
||||
ctx: MutationCtx,
|
||||
skillId: Id<"skills">,
|
||||
userId: Id<"users">,
|
||||
at: number,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("skillBadges")
|
||||
.withIndex("by_skill_kind", (q) => q.eq("skillId", skillId).eq("kind", "highlighted"))
|
||||
.unique();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { byUserId: userId, at });
|
||||
} else {
|
||||
await ctx.db.insert("skillBadges", {
|
||||
skillId,
|
||||
kind: "highlighted",
|
||||
byUserId: userId,
|
||||
at,
|
||||
});
|
||||
}
|
||||
const skill = await ctx.db.get(skillId);
|
||||
if (skill) {
|
||||
await ctx.db.patch(skillId, {
|
||||
badges: {
|
||||
...(skill.badges as Record<string, unknown> | undefined),
|
||||
highlighted: { byUserId: userId, at },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureHighlightedPackageBadge(
|
||||
ctx: MutationCtx,
|
||||
packageId: Id<"packages">,
|
||||
userId: Id<"users">,
|
||||
at: number,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", "highlighted"))
|
||||
.unique();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { byUserId: userId, at });
|
||||
} else {
|
||||
await ctx.db.insert("packageBadges", {
|
||||
packageId,
|
||||
kind: "highlighted",
|
||||
byUserId: userId,
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function staticMaliciousScan(now: number) {
|
||||
return {
|
||||
status: "malicious" as const,
|
||||
@@ -1304,166 +1083,6 @@ export const seedRescanUxFixturesMutation = internalMutation({
|
||||
handler: seedRescanUxFixturesHandler,
|
||||
});
|
||||
|
||||
export const seedFeaturedPluginPackagesMutation = internalMutation({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
packages: v.array(
|
||||
v.object({
|
||||
name: v.string(),
|
||||
displayName: v.string(),
|
||||
summary: v.string(),
|
||||
version: v.string(),
|
||||
runtimeId: v.string(),
|
||||
sourceRepo: v.string(),
|
||||
isOfficial: v.boolean(),
|
||||
capabilityTags: v.array(v.string()),
|
||||
stats: v.object({
|
||||
downloads: v.number(),
|
||||
installs: v.number(),
|
||||
stars: v.number(),
|
||||
versions: v.number(),
|
||||
}),
|
||||
storageId: v.id("_storage"),
|
||||
readmeSize: v.number(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
|
||||
const seeded: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const spec of args.packages) {
|
||||
const existing = await findSeedPluginFixtureByName(ctx, spec.name);
|
||||
if (existing && !args.reset) {
|
||||
await ensureHighlightedPackageBadge(ctx, existing._id, userId, now);
|
||||
skipped.push(spec.name);
|
||||
continue;
|
||||
}
|
||||
if (existing && args.reset) {
|
||||
await deleteSeedPluginFixtureByName(ctx, spec.name);
|
||||
}
|
||||
|
||||
const compatibility = { pluginApiRange: ">=0.1.0" };
|
||||
const capabilities = {
|
||||
executesCode: true,
|
||||
runtimeId: spec.runtimeId,
|
||||
pluginKind: "runtime" as const,
|
||||
capabilityTags: spec.capabilityTags,
|
||||
};
|
||||
const verification = {
|
||||
tier: "source-linked" as const,
|
||||
scope: "artifact-only" as const,
|
||||
summary: "Local dev featured plugin fixture linked to source metadata.",
|
||||
sourceRepo: spec.sourceRepo,
|
||||
scanStatus: "clean" as const,
|
||||
};
|
||||
const normalizedName = normalizePackageName(spec.name);
|
||||
|
||||
const packageId = await ctx.db.insert("packages", {
|
||||
name: spec.name,
|
||||
normalizedName,
|
||||
displayName: spec.displayName,
|
||||
summary: spec.summary,
|
||||
ownerUserId: userId,
|
||||
ownerPublisherId: publisherId,
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: spec.isOfficial,
|
||||
runtimeId: spec.runtimeId,
|
||||
sourceRepo: spec.sourceRepo,
|
||||
latestReleaseId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
capabilityTags: spec.capabilityTags,
|
||||
executesCode: true,
|
||||
compatibility,
|
||||
capabilities,
|
||||
verification,
|
||||
scanStatus: "clean",
|
||||
stats: { ...spec.stats, versions: 0 },
|
||||
softDeletedAt: undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const releaseId = await ctx.db.insert("packageReleases", {
|
||||
packageId,
|
||||
version: spec.version,
|
||||
changelog: "Seeded local featured plugin release.",
|
||||
summary: spec.summary,
|
||||
distTags: ["latest"],
|
||||
files: [
|
||||
{
|
||||
path: "README.md",
|
||||
size: spec.readmeSize,
|
||||
storageId: spec.storageId,
|
||||
sha256: `seeded-featured-plugin-${normalizedName}`,
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
integritySha256: `seeded-featured-plugin-integrity-${normalizedName}`,
|
||||
extractedPackageJson: {
|
||||
name: spec.name,
|
||||
version: spec.version,
|
||||
description: spec.summary,
|
||||
},
|
||||
compatibility,
|
||||
capabilities,
|
||||
verification,
|
||||
sha256hash: `seeded-featured-plugin-hash-${normalizedName}`,
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
analysis: "Local featured plugin fixture scanned clean.",
|
||||
source: "local-dev-seed",
|
||||
checkedAt: now,
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
confidence: "high",
|
||||
summary: "Local featured plugin fixture is safe sample content.",
|
||||
model: "local-dev-seed",
|
||||
checkedAt: now,
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "Local featured plugin fixture static scan clean.",
|
||||
engineVersion: "local-dev-fixture",
|
||||
checkedAt: now,
|
||||
},
|
||||
source: { kind: "github", repo: spec.sourceRepo, path: "." },
|
||||
createdBy: userId,
|
||||
publishActor: { kind: "user", userId },
|
||||
createdAt: now,
|
||||
softDeletedAt: undefined,
|
||||
});
|
||||
|
||||
await ctx.db.patch(packageId, {
|
||||
latestReleaseId: releaseId,
|
||||
latestVersionSummary: {
|
||||
version: spec.version,
|
||||
createdAt: now,
|
||||
changelog: "Seeded local featured plugin release.",
|
||||
compatibility,
|
||||
capabilities,
|
||||
verification,
|
||||
},
|
||||
tags: { latest: releaseId },
|
||||
stats: { ...spec.stats, versions: 1 },
|
||||
updatedAt: now,
|
||||
});
|
||||
await ensureHighlightedPackageBadge(ctx, packageId, userId, now);
|
||||
seeded.push(spec.name);
|
||||
}
|
||||
|
||||
return { ok: true, seeded, skipped };
|
||||
},
|
||||
});
|
||||
|
||||
export const seedCliRoleHelpFixtures = rawInternalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
@@ -1516,7 +1135,11 @@ async function upsertRoleHelpFixtureUser(ctx: MutationCtx, user: RoleHelpFixture
|
||||
return created;
|
||||
}
|
||||
|
||||
async function replaceRoleHelpFixtureToken(ctx: MutationCtx, userId: Id<"users">, now: number) {
|
||||
async function replaceRoleHelpFixtureToken(
|
||||
ctx: MutationCtx,
|
||||
userId: Id<"users">,
|
||||
now: number,
|
||||
) {
|
||||
const existingTokens = await ctx.db
|
||||
.query("apiTokens")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
@@ -1554,15 +1177,12 @@ export const seedSkillMutation = internalMutation({
|
||||
version: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
||||
.unique();
|
||||
|
||||
if (existing && !args.reset) {
|
||||
await ensureHighlightedSkillBadge(ctx, existing._id, userId, now);
|
||||
return { ok: true, skipped: true, skillId: existing._id };
|
||||
}
|
||||
|
||||
@@ -1584,6 +1204,9 @@ export const seedSkillMutation = internalMutation({
|
||||
await ctx.db.delete(existing._id);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const { userId, publisherId } = await ensureLocalSeedOwner(ctx);
|
||||
|
||||
const skillId = await ctx.db.insert("skills", {
|
||||
slug: args.slug,
|
||||
displayName: args.displayName,
|
||||
@@ -1593,7 +1216,7 @@ export const seedSkillMutation = internalMutation({
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
softDeletedAt: undefined,
|
||||
badges: { highlighted: { byUserId: userId, at: now }, redactionApproved: undefined },
|
||||
badges: { redactionApproved: undefined },
|
||||
statsDownloads: 0,
|
||||
statsStars: 0,
|
||||
statsInstallsCurrent: 0,
|
||||
@@ -1609,7 +1232,6 @@ export const seedSkillMutation = internalMutation({
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ensureHighlightedSkillBadge(ctx, skillId, userId, now);
|
||||
const versionId = await ctx.db.insert("skillVersions", {
|
||||
skillId,
|
||||
version: args.version,
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
|
||||
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
|
||||
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
|
||||
import {
|
||||
fetchGitHubRepositoryIdentity,
|
||||
verifyGitHubActionsTrustedPublishJwt,
|
||||
@@ -104,7 +104,6 @@ type PackageListQueryArgs = {
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
@@ -459,7 +458,6 @@ async function searchPackageCatalogByListing(
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
@@ -484,7 +482,6 @@ async function searchPackageCatalogByListing(
|
||||
family: args.family,
|
||||
channel: args.channel,
|
||||
isOfficial: args.isOfficial,
|
||||
highlightedOnly: args.highlightedOnly,
|
||||
executesCode: args.executesCode,
|
||||
capabilityTag: args.capabilityTag,
|
||||
viewerUserId: args.viewerUserId,
|
||||
@@ -653,11 +650,6 @@ async function listPackages(
|
||||
const channelRaw = url.searchParams.get("channel")?.trim();
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const highlightedOnly =
|
||||
url.searchParams.get("featured") === "true" ||
|
||||
url.searchParams.get("featured") === "1" ||
|
||||
url.searchParams.get("highlightedOnly") === "true" ||
|
||||
url.searchParams.get("highlightedOnly") === "1";
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const effectiveFamily =
|
||||
family ??
|
||||
@@ -682,7 +674,6 @@ async function listPackages(
|
||||
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
paginationOpts: { cursor, numItems: limit },
|
||||
@@ -710,7 +701,6 @@ async function listPackages(
|
||||
}>(ctx, internalRefs.packages.listPageForViewerInternal, {
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -730,7 +720,6 @@ async function listPackages(
|
||||
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
paginationOpts: { cursor: pageCursor, numItems },
|
||||
@@ -794,7 +783,6 @@ async function listPackages(
|
||||
family: pluginFamily,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -862,7 +850,6 @@ async function listPackages(
|
||||
family: effectiveFamily,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -1292,11 +1279,6 @@ async function searchPackages(
|
||||
const familyRaw = url.searchParams.get("family");
|
||||
const channelRaw = url.searchParams.get("channel");
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const highlightedOnly =
|
||||
url.searchParams.get("featured") === "true" ||
|
||||
url.searchParams.get("featured") === "1" ||
|
||||
url.searchParams.get("highlightedOnly") === "true" ||
|
||||
url.searchParams.get("highlightedOnly") === "1";
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const family =
|
||||
@@ -1323,7 +1305,6 @@ async function searchPackages(
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
},
|
||||
@@ -1338,7 +1319,6 @@ async function searchPackages(
|
||||
family: pluginFamily,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -1363,7 +1343,6 @@ async function searchPackages(
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -1376,7 +1355,6 @@ async function searchPackages(
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -1386,7 +1364,6 @@ async function searchPackages(
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import { ConvexError } from "convex/values";
|
||||
import semver from "semver";
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
parseFrontmatter,
|
||||
sanitizePath,
|
||||
} from "./skills";
|
||||
import { assertValidSkillSlug, normalizeSkillSlug } from "./skillSlugValidator";
|
||||
import { generateSkillSummary } from "./skillSummary";
|
||||
import { runStaticPublishScan } from "./staticPublishScan";
|
||||
import type { WebhookSkillPayload } from "./webhooks";
|
||||
@@ -90,12 +91,16 @@ export async function publishVersionForUser(
|
||||
options: PublishOptions = {},
|
||||
): Promise<PublishResult> {
|
||||
const version = args.version.trim();
|
||||
const slug = args.slug.trim().toLowerCase();
|
||||
// Normalize first so we can look up the existing skill before deciding
|
||||
// how strictly to validate. The reserved-word blocklist and length floor
|
||||
// are only enforced for brand-new skills; owners of grandfathered slugs
|
||||
// (reserved, <3 chars, or >48 chars) must still be able to publish new
|
||||
// versions without being blocked by the write-path validator.
|
||||
const normalizedSlug = normalizeSkillSlug(args.slug);
|
||||
if (!normalizedSlug) throw new ConvexError("Slug is required.");
|
||||
|
||||
const displayName = args.displayName.trim();
|
||||
if (!slug || !displayName) throw new ConvexError("Slug and display name required");
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
||||
throw new ConvexError("Slug must be lowercase and url-safe");
|
||||
}
|
||||
if (!displayName) throw new ConvexError("Display name required");
|
||||
if (!semver.valid(version)) {
|
||||
throw new ConvexError("Version must be valid semver");
|
||||
}
|
||||
@@ -104,10 +109,19 @@ export async function publishVersionForUser(
|
||||
await requireGitHubAccountAge(ctx, userId);
|
||||
}
|
||||
const existingSkill = (await ctx.runQuery(internal.skills.getSkillBySlugInternal, {
|
||||
slug,
|
||||
slug: normalizedSlug,
|
||||
})) as Doc<"skills"> | null;
|
||||
const isNewSkill = !existingSkill;
|
||||
|
||||
// For new skills, enforce the full write-path rules (length, pattern,
|
||||
// reserved-word blocklist). For existing skills the slug is already
|
||||
// persisted and grandfathered — re-validating it would block legitimate
|
||||
// version publishes on legacy rows.
|
||||
if (isNewSkill) {
|
||||
assertValidSkillSlug(normalizedSlug);
|
||||
}
|
||||
const slug = normalizedSlug;
|
||||
|
||||
const suppliedChangelog = args.changelog.trim();
|
||||
const changelogSource = suppliedChangelog ? ("user" as const) : ("auto" as const);
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertValidSkillSlug,
|
||||
isReservedSkillSlug,
|
||||
isSearchableSkillSlugShape,
|
||||
isValidSkillSlugShape,
|
||||
normalizeSkillSlug,
|
||||
normalizeSkillSlugOrNull,
|
||||
SKILL_SLUG_CONSTRAINTS,
|
||||
} from "./skillSlugValidator";
|
||||
|
||||
describe("normalizeSkillSlug", () => {
|
||||
it("trims and lowercases", () => {
|
||||
expect(normalizeSkillSlug(" Hello-World ")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("returns empty string for nullish", () => {
|
||||
expect(normalizeSkillSlug(undefined)).toBe("");
|
||||
expect(normalizeSkillSlug(null)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeSkillSlugOrNull", () => {
|
||||
it("returns null for empty input", () => {
|
||||
expect(normalizeSkillSlugOrNull(" ")).toBeNull();
|
||||
expect(normalizeSkillSlugOrNull(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns normalized slug for non-empty input", () => {
|
||||
expect(normalizeSkillSlugOrNull(" MySkill ")).toBe("myskill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertValidSkillSlug", () => {
|
||||
it.each([
|
||||
"abc",
|
||||
"my-cool-skill",
|
||||
"skill-123",
|
||||
"a1b",
|
||||
"123",
|
||||
"abc-def-ghi",
|
||||
"z".repeat(SKILL_SLUG_CONSTRAINTS.maxLength),
|
||||
])("accepts valid slug %s", (slug) => {
|
||||
expect(() => assertValidSkillSlug(slug)).not.toThrow();
|
||||
expect(assertValidSkillSlug(slug)).toBe(slug.toLowerCase());
|
||||
});
|
||||
|
||||
it("normalizes mixed case and whitespace before validating", () => {
|
||||
expect(assertValidSkillSlug(" My-Cool-Skill ")).toBe("my-cool-skill");
|
||||
});
|
||||
|
||||
it("silently lowercases uppercase input (legacy-compatible)", () => {
|
||||
// Historically, write paths did `args.slug.trim().toLowerCase()` before
|
||||
// validating. We preserve that behaviour: uppercase input is normalized,
|
||||
// not rejected outright.
|
||||
expect(assertValidSkillSlug("A-B-C")).toBe("a-b-c");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["", "required"],
|
||||
[" ", "required"],
|
||||
["ab", "at least"],
|
||||
["a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1), "at most"],
|
||||
["-abc", "start and end"],
|
||||
["abc-", "start and end"],
|
||||
["a--b", "start and end"],
|
||||
["a---b", "start and end"],
|
||||
["a_b", "start and end"],
|
||||
["a.b", "start and end"],
|
||||
["a b", "start and end"],
|
||||
["a/b", "start and end"],
|
||||
])("rejects invalid slug %s", (slug, hint) => {
|
||||
expect(() => assertValidSkillSlug(slug)).toThrow(new RegExp(hint, "i"));
|
||||
});
|
||||
|
||||
it.each(["admin", "settings", "api", "openclaw", "clawhub", "souls", "packages"])(
|
||||
"rejects reserved slug %s",
|
||||
(slug) => {
|
||||
// Some short reserved entries (e.g. "u") are also blocked by the
|
||||
// length rule; we only assert that a throw happens for every entry.
|
||||
expect(() => assertValidSkillSlug(slug)).toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("emits the reserved-specific error for long reserved slugs", () => {
|
||||
expect(() => assertValidSkillSlug("openclaw")).toThrow(/reserved/i);
|
||||
});
|
||||
|
||||
it("allows reserved slugs when allowReserved is set", () => {
|
||||
expect(() => assertValidSkillSlug("admin", { allowReserved: true })).not.toThrow();
|
||||
expect(assertValidSkillSlug("admin", { allowReserved: true })).toBe("admin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidSkillSlugShape", () => {
|
||||
it("returns true for well-formed slugs", () => {
|
||||
expect(isValidSkillSlugShape("abc")).toBe(true);
|
||||
expect(isValidSkillSlugShape("my-skill-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for reserved slugs (shape only)", () => {
|
||||
// The reserved-word blocklist is intentionally NOT consulted here so
|
||||
// that legacy rows carrying reserved slugs remain lookup-able.
|
||||
expect(isValidSkillSlugShape("admin")).toBe(true);
|
||||
});
|
||||
|
||||
it("is case-insensitive (normalizes before checking)", () => {
|
||||
// Matches legacy read-path behaviour: search queries like "My-Skill"
|
||||
// should still resolve to the slug row.
|
||||
expect(isValidSkillSlugShape("A-B")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for malformed slugs", () => {
|
||||
expect(isValidSkillSlugShape("a")).toBe(false);
|
||||
expect(isValidSkillSlugShape("ab")).toBe(false);
|
||||
expect(isValidSkillSlugShape("a--b")).toBe(false);
|
||||
expect(isValidSkillSlugShape("-abc")).toBe(false);
|
||||
expect(isValidSkillSlugShape("abc-")).toBe(false);
|
||||
expect(isValidSkillSlugShape("a_b")).toBe(false);
|
||||
expect(isValidSkillSlugShape("")).toBe(false);
|
||||
expect(isValidSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReservedSkillSlug", () => {
|
||||
it("identifies reserved slugs case-insensitively", () => {
|
||||
expect(isReservedSkillSlug("admin")).toBe(true);
|
||||
expect(isReservedSkillSlug(" ADMIN ")).toBe(true);
|
||||
expect(isReservedSkillSlug("openclaw")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-reserved slugs", () => {
|
||||
expect(isReservedSkillSlug("my-skill")).toBe(false);
|
||||
expect(isReservedSkillSlug("")).toBe(false);
|
||||
expect(isReservedSkillSlug(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSearchableSkillSlugShape", () => {
|
||||
it("accepts slugs shorter than the write-path minimum (legacy rows)", () => {
|
||||
// Single- and two-character slugs predate the min-length floor but may
|
||||
// still exist in the skills table. Search must surface them via the
|
||||
// exact-slug fast path.
|
||||
expect(isSearchableSkillSlugShape("a")).toBe(true);
|
||||
expect(isSearchableSkillSlugShape("ab")).toBe(true);
|
||||
expect(isSearchableSkillSlugShape("a1")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts regular well-formed slugs", () => {
|
||||
expect(isSearchableSkillSlugShape("abc")).toBe(true);
|
||||
expect(isSearchableSkillSlugShape("my-skill-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts reserved slugs (read path ignores the blocklist)", () => {
|
||||
// Legacy data may still carry reserved slugs; they must remain
|
||||
// searchable even though the write path would now reject them.
|
||||
expect(isSearchableSkillSlugShape("admin")).toBe(true);
|
||||
expect(isSearchableSkillSlugShape("u")).toBe(true);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(isSearchableSkillSlugShape("A-B")).toBe(true);
|
||||
expect(isSearchableSkillSlugShape(" My-Skill ")).toBe(true);
|
||||
});
|
||||
|
||||
it("still rejects malformed shapes", () => {
|
||||
expect(isSearchableSkillSlugShape("")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape(" ")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape("-abc")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape("abc-")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape("a--b")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape("a_b")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape("a b")).toBe(false);
|
||||
expect(isSearchableSkillSlugShape("-")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts slugs longer than the write-path upper bound (legacy rows)", () => {
|
||||
// Legacy rows predate MAX_SLUG_LENGTH and may exceed 48 chars. The read
|
||||
// path must still resolve them via the by_slug fast path; otherwise
|
||||
// searchSkills falls back to scanning only the most recent digests and
|
||||
// can miss older records entirely.
|
||||
expect(isSearchableSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength))).toBe(true);
|
||||
expect(isSearchableSkillSlugShape("a".repeat(SKILL_SLUG_CONSTRAINTS.maxLength + 1))).toBe(true);
|
||||
expect(isSearchableSkillSlugShape("a".repeat(200))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
|
||||
// Slug shape rules:
|
||||
// - Lowercase letters, digits, and single hyphens only.
|
||||
// - Must start and end with a letter or digit.
|
||||
// - No consecutive hyphens ("--", "---", ...).
|
||||
// - Length 3..48 (URL/SEO friendly, aligned with publisher handle).
|
||||
//
|
||||
// The pattern enforces first/last char class and forbids consecutive hyphens
|
||||
// via a negative lookahead. Length bounds are checked separately so we can
|
||||
// emit precise error messages.
|
||||
const SLUG_PATTERN = /^[a-z0-9](?:(?!--)[a-z0-9-])*[a-z0-9]$/;
|
||||
|
||||
const MIN_SLUG_LENGTH = 3;
|
||||
const MAX_SLUG_LENGTH = 48;
|
||||
|
||||
// Reserved slugs. These are blocked because they would:
|
||||
// 1. Clash semantically with top-level routes under src/routes/*.
|
||||
// 2. Allow brand/role impersonation (e.g. "official", "clawhub").
|
||||
// 3. Lock future route expansion (e.g. "api", "auth", "oauth").
|
||||
//
|
||||
// Keep this list in sync with:
|
||||
// - src/routes/*.tsx top-level segments
|
||||
// - brand names shipped in README.md
|
||||
const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
|
||||
// Current top-level route segments under src/routes/.
|
||||
"about",
|
||||
"admin",
|
||||
"cli",
|
||||
"dashboard",
|
||||
"import",
|
||||
"management",
|
||||
"orgs",
|
||||
"packages",
|
||||
"plugins",
|
||||
"publish",
|
||||
"publish-plugin",
|
||||
"publish-skill",
|
||||
"search",
|
||||
"settings",
|
||||
"skills",
|
||||
"souls",
|
||||
"stars",
|
||||
"u",
|
||||
"upload",
|
||||
"users",
|
||||
// Reserved for likely future additions.
|
||||
"api",
|
||||
"auth",
|
||||
"oauth",
|
||||
"callback",
|
||||
"login",
|
||||
"logout",
|
||||
"signin",
|
||||
"signout",
|
||||
"signup",
|
||||
"register",
|
||||
"docs",
|
||||
"doc",
|
||||
"help",
|
||||
"support",
|
||||
"status",
|
||||
"health",
|
||||
"blog",
|
||||
"news",
|
||||
"pricing",
|
||||
"terms",
|
||||
"privacy",
|
||||
"legal",
|
||||
"contact",
|
||||
"home",
|
||||
"explore",
|
||||
// Brand and project names.
|
||||
"openclaw",
|
||||
"clawhub",
|
||||
"clawd",
|
||||
"clawdbot",
|
||||
"onlycrabs",
|
||||
"soulhub",
|
||||
// Generic identity / role words.
|
||||
"me",
|
||||
"self",
|
||||
"system",
|
||||
"root",
|
||||
"owner",
|
||||
"official",
|
||||
"staff",
|
||||
"team",
|
||||
"mod",
|
||||
"moderator",
|
||||
// Reserved CRUD/action words that would make URLs ambiguous.
|
||||
"new",
|
||||
"edit",
|
||||
"delete",
|
||||
"create",
|
||||
"update",
|
||||
"remove",
|
||||
"public",
|
||||
"private",
|
||||
"internal",
|
||||
// Literals that would be confusing in URLs.
|
||||
"null",
|
||||
"undefined",
|
||||
"true",
|
||||
"false",
|
||||
]);
|
||||
|
||||
export interface ValidateSlugOptions {
|
||||
/**
|
||||
* Bypass the reserved-word blocklist.
|
||||
* Intended for admin migrations / internal seeding only.
|
||||
*/
|
||||
allowReserved?: boolean;
|
||||
}
|
||||
|
||||
export const SKILL_SLUG_CONSTRAINTS = {
|
||||
minLength: MIN_SLUG_LENGTH,
|
||||
maxLength: MAX_SLUG_LENGTH,
|
||||
pattern: SLUG_PATTERN,
|
||||
reserved: RESERVED_SKILL_SLUGS,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Lowercase and trim a slug. Does not throw.
|
||||
*
|
||||
* Safe to call on any read-path input (query by slug, redirect lookup, ...)
|
||||
* without rejecting legacy data.
|
||||
*/
|
||||
export function normalizeSkillSlug(raw: string | undefined | null): string {
|
||||
return (raw ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant that returns null when the input normalizes to an empty string.
|
||||
* Useful at read-path call sites that want to short-circuit lookup.
|
||||
*/
|
||||
export function normalizeSkillSlugOrNull(raw: string | undefined | null): string | null {
|
||||
const normalized = normalizeSkillSlug(raw);
|
||||
return normalized.length ? normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a string already matches the full slug shape rules.
|
||||
* Returns true only when the value is a plausible slug (length, pattern).
|
||||
*
|
||||
* Note: this intentionally does NOT consult the reserved-word blocklist
|
||||
* because legacy rows may still carry reserved slugs and we want to
|
||||
* keep them readable. It DOES enforce the current min-length floor
|
||||
* (MIN_SLUG_LENGTH) and is therefore only appropriate for call sites
|
||||
* that treat a value as a "newly-shaped" slug. For read-only lookups
|
||||
* (search, redirect) that must stay discoverable for pre-existing
|
||||
* short slugs, use isSearchableSkillSlugShape instead.
|
||||
*/
|
||||
export function isValidSkillSlugShape(value: string | undefined | null): boolean {
|
||||
const normalized = normalizeSkillSlug(value);
|
||||
if (normalized.length < MIN_SLUG_LENGTH || normalized.length > MAX_SLUG_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
return SLUG_PATTERN.test(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient shape check used by read paths (search exact-slug optimization,
|
||||
* redirect lookups, etc.).
|
||||
*
|
||||
* Unlike isValidSkillSlugShape, this predicate intentionally omits:
|
||||
* - the min-length floor (legacy rows with 1- or 2-char slugs must stay
|
||||
* retrievable via the by_slug fast path),
|
||||
* - the max-length cap (rows persisted before MAX_SLUG_LENGTH was
|
||||
* introduced may exceed 48 chars and must remain lookup-able; the
|
||||
* indexed point lookup for a missing key is cheap, and upstream
|
||||
* request-body limits bound the practical query length),
|
||||
* - the reserved-word blocklist (grandfathered data must stay readable).
|
||||
*
|
||||
* Write paths MUST continue to use assertValidSkillSlug, which enforces
|
||||
* the full validation surface (length floor + length cap + pattern +
|
||||
* reserved blocklist).
|
||||
*/
|
||||
export function isSearchableSkillSlugShape(value: string | undefined | null): boolean {
|
||||
const normalized = normalizeSkillSlug(value);
|
||||
if (normalized.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// Single-character legacy slug: a bare [a-z0-9] is searchable. The full
|
||||
// SLUG_PATTERN requires >=2 chars (separate first/last classes), so we
|
||||
// handle the single-char case explicitly before delegating to it.
|
||||
if (normalized.length === 1) {
|
||||
return /^[a-z0-9]$/.test(normalized);
|
||||
}
|
||||
return SLUG_PATTERN.test(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a normalized slug or throws ConvexError describing the first
|
||||
* violation encountered. Use this on every write path (publish/rename).
|
||||
*/
|
||||
export function assertValidSkillSlug(
|
||||
rawSlug: string | undefined | null,
|
||||
options: ValidateSlugOptions = {},
|
||||
): string {
|
||||
const normalized = normalizeSkillSlug(rawSlug);
|
||||
|
||||
if (!normalized) {
|
||||
throw new ConvexError("Slug is required.");
|
||||
}
|
||||
if (normalized.length < MIN_SLUG_LENGTH) {
|
||||
throw new ConvexError(`Slug must be at least ${MIN_SLUG_LENGTH} characters.`);
|
||||
}
|
||||
if (normalized.length > MAX_SLUG_LENGTH) {
|
||||
throw new ConvexError(`Slug must be at most ${MAX_SLUG_LENGTH} characters.`);
|
||||
}
|
||||
if (!SLUG_PATTERN.test(normalized)) {
|
||||
throw new ConvexError(
|
||||
"Slug must start and end with a letter or digit, contain only lowercase letters, " +
|
||||
"digits, and single hyphens, and not contain consecutive hyphens.",
|
||||
);
|
||||
}
|
||||
if (!options.allowReserved && RESERVED_SKILL_SLUGS.has(normalized)) {
|
||||
throw new ConvexError(`"${normalized}" is reserved and cannot be used as a slug.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience predicate: is the slug on the reserved blocklist?
|
||||
* Exposed so callers (e.g. admin tooling) can pre-check without a throw.
|
||||
*/
|
||||
export function isReservedSkillSlug(slug: string | undefined | null): boolean {
|
||||
const normalized = normalizeSkillSlug(slug);
|
||||
return RESERVED_SKILL_SLUGS.has(normalized);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import { ConvexError } from "convex/values";
|
||||
import semver from "semver";
|
||||
import { internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
parseFrontmatter,
|
||||
sanitizePath,
|
||||
} from "./skills";
|
||||
import { assertValidSkillSlug, normalizeSkillSlug } from "./skillSlugValidator";
|
||||
import { generateSoulChangelogForPublish } from "./soulChangelog";
|
||||
|
||||
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
|
||||
@@ -84,18 +85,31 @@ export async function publishSoulVersionForUser(
|
||||
args: PublishVersionArgs,
|
||||
): Promise<PublishResult> {
|
||||
const version = args.version.trim();
|
||||
const slug = args.slug.trim().toLowerCase();
|
||||
// Normalize first so we can look up the existing soul before deciding how
|
||||
// strictly to validate. Owners of grandfathered slugs (reserved, <3 chars,
|
||||
// or >48 chars) must still be able to publish new versions; the strict
|
||||
// write-path rules only apply when creating a brand-new soul.
|
||||
const normalizedSlug = normalizeSkillSlug(args.slug);
|
||||
if (!normalizedSlug) throw new ConvexError("Slug is required.");
|
||||
|
||||
const displayName = args.displayName.trim();
|
||||
if (!slug || !displayName) throw new ConvexError("Slug and display name required");
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
||||
throw new ConvexError("Slug must be lowercase and url-safe");
|
||||
}
|
||||
if (!displayName) throw new ConvexError("Display name required");
|
||||
if (!semver.valid(version)) {
|
||||
throw new ConvexError("Version must be valid semver");
|
||||
}
|
||||
|
||||
await requireGitHubAccountAge(ctx, userId);
|
||||
|
||||
// Resolve existing soul before enforcing slug rules so grandfathered rows
|
||||
// are not blocked. Full validation is only applied on the create path.
|
||||
const existingSoul = (await ctx.runQuery(internal.souls.getSoulBySlugInternal, {
|
||||
slug: normalizedSlug,
|
||||
})) as Doc<"souls"> | null;
|
||||
if (!existingSoul) {
|
||||
assertValidSkillSlug(normalizedSlug);
|
||||
}
|
||||
const slug = normalizedSlug;
|
||||
|
||||
const suppliedChangelog = args.changelog.trim();
|
||||
const changelogSource = suppliedChangelog ? ("user" as const) : ("auto" as const);
|
||||
|
||||
|
||||
@@ -167,8 +167,6 @@ type PublicPackageListItem = {
|
||||
executesCode: boolean;
|
||||
verificationTier: PackageVerificationTier | null;
|
||||
};
|
||||
|
||||
type PackageBadgeKind = Doc<"packageBadges">["kind"];
|
||||
type PackageDigestLike = Pick<
|
||||
Doc<"packageSearchDigest">,
|
||||
| "packageId"
|
||||
@@ -411,36 +409,6 @@ function digestMatchesSearchFilters(
|
||||
return digestMatchesFilters(digest, args);
|
||||
}
|
||||
|
||||
async function upsertPackageBadge(
|
||||
ctx: MutationCtx,
|
||||
packageId: Id<"packages">,
|
||||
kind: PackageBadgeKind,
|
||||
userId: Id<"users">,
|
||||
at: number,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", kind))
|
||||
.unique();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { byUserId: userId, at });
|
||||
return;
|
||||
}
|
||||
await ctx.db.insert("packageBadges", { packageId, kind, byUserId: userId, at });
|
||||
}
|
||||
|
||||
async function removePackageBadge(
|
||||
ctx: MutationCtx,
|
||||
packageId: Id<"packages">,
|
||||
kind: PackageBadgeKind,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", kind))
|
||||
.unique();
|
||||
if (existing) await ctx.db.delete(existing._id);
|
||||
}
|
||||
|
||||
function toPublicPackageListItem(digest: PackageDigestLike): PublicPackageListItem {
|
||||
return {
|
||||
name: digest.name,
|
||||
@@ -967,62 +935,6 @@ function buildPackageCapabilityDigestQuery(
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchHighlightedPackageDigests(
|
||||
ctx: DbReaderCtx,
|
||||
args: {
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
},
|
||||
) {
|
||||
const viewerUserId = args.viewerUserId;
|
||||
const membershipCache = new Map<string, Promise<boolean>>();
|
||||
const badges = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_kind_at", (q) => q.eq("kind", "highlighted"))
|
||||
.order("desc")
|
||||
.take(MAX_PUBLIC_LIST_PAGE_SIZE);
|
||||
const digests: PackageDigestLike[] = [];
|
||||
for (const badge of badges) {
|
||||
const digest = await ctx.db
|
||||
.query("packageSearchDigest")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", badge.packageId))
|
||||
.unique();
|
||||
if (!digest || digest.softDeletedAt) continue;
|
||||
if (!(await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache))) continue;
|
||||
if (!digestMatchesSearchFilters(digest, args)) continue;
|
||||
digests.push(digest);
|
||||
}
|
||||
return digests;
|
||||
}
|
||||
|
||||
async function fetchHighlightedPackagePage(
|
||||
ctx: DbReaderCtx,
|
||||
args: {
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
numItems: number;
|
||||
},
|
||||
) {
|
||||
const digests = await fetchHighlightedPackageDigests(ctx, args);
|
||||
return digests
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.isOfficial) - Number(a.isOfficial) ||
|
||||
b.updatedAt - a.updatedAt ||
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
.slice(0, args.numItems)
|
||||
.map(toPublicPackageListItem);
|
||||
}
|
||||
|
||||
async function getPackageByNormalizedName(ctx: DbReaderCtx, normalizedName: string) {
|
||||
return (await ctx.db
|
||||
.query("packages")
|
||||
@@ -1099,41 +1011,6 @@ export const getByName = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const getByNameForStaff = query({
|
||||
args: { name: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
|
||||
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") return null;
|
||||
|
||||
const highlighted = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package_kind", (q) => q.eq("packageId", pkg._id).eq("kind", "highlighted"))
|
||||
.unique();
|
||||
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
|
||||
const owner = toPublicPublisher(
|
||||
await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
package: pkg,
|
||||
latestRelease: latestRelease && !latestRelease.softDeletedAt ? latestRelease : null,
|
||||
owner,
|
||||
highlighted: highlighted
|
||||
? {
|
||||
byUserId: highlighted.byUserId,
|
||||
at: highlighted.at,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getByNameForViewerInternal = internalQuery({
|
||||
args: {
|
||||
name: v.string(),
|
||||
@@ -1281,7 +1158,6 @@ export const listPublicPage = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
@@ -1300,7 +1176,6 @@ export const listPageForViewerInternal = internalQuery({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
viewerUserId: v.optional(v.id("users")),
|
||||
@@ -1317,7 +1192,6 @@ async function listPackagePageImpl(
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
@@ -1332,15 +1206,6 @@ async function listPackagePageImpl(
|
||||
const canViewPackage = async (digest: PackageDigestLike) =>
|
||||
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
|
||||
const targetCount = args.paginationOpts.numItems;
|
||||
|
||||
if (args.highlightedOnly) {
|
||||
const page = await fetchHighlightedPackagePage(ctx, {
|
||||
...args,
|
||||
numItems: targetCount,
|
||||
});
|
||||
return { page, isDone: true, continueCursor: "" };
|
||||
}
|
||||
|
||||
const collected: PublicPackageListItem[] = [];
|
||||
const decodedCursor = decodePublicPageCursor(args.paginationOpts.cursor);
|
||||
if (decodedCursor.done && decodedCursor.offset === 0) {
|
||||
@@ -1435,7 +1300,6 @@ export const searchPublic = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
},
|
||||
@@ -1455,7 +1319,6 @@ export const searchForViewerInternal = internalQuery({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
viewerUserId: v.optional(v.id("users")),
|
||||
@@ -1473,7 +1336,6 @@ async function searchPackagesImpl(
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
@@ -1487,21 +1349,6 @@ async function searchPackagesImpl(
|
||||
const membershipCache = new Map<string, Promise<boolean>>();
|
||||
const canViewPackage = async (digest: PackageDigestLike) =>
|
||||
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
|
||||
if (args.highlightedOnly) {
|
||||
const digests = await fetchHighlightedPackageDigests(ctx, args);
|
||||
return digests
|
||||
.map((digest) => ({ score: packageSearchScore(digest, queryText), package: digest }))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
b.package.updatedAt - a.package.updatedAt,
|
||||
)
|
||||
.slice(0, targetCount)
|
||||
.map((entry) => ({ score: entry.score, package: toPublicPackageListItem(entry.package) }));
|
||||
}
|
||||
|
||||
const builder = args.capabilityTag
|
||||
? buildPackageCapabilityDigestQuery(ctx, {
|
||||
capabilityTag: args.capabilityTag,
|
||||
@@ -2950,36 +2797,6 @@ export const requestRescan = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const setBatch = mutation({
|
||||
args: { packageId: v.id("packages"), batch: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
const pkg = await ctx.db.get(args.packageId);
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
||||
throw new ConvexError("Plugin not found");
|
||||
}
|
||||
const nextBatch = args.batch?.trim() || undefined;
|
||||
const nextHighlighted = nextBatch === "highlighted";
|
||||
const now = Date.now();
|
||||
|
||||
if (nextHighlighted) {
|
||||
await upsertPackageBadge(ctx, pkg._id, "highlighted", user._id, now);
|
||||
} else {
|
||||
await removePackageBadge(ctx, pkg._id, "highlighted");
|
||||
}
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: user._id,
|
||||
action: "package.badge.highlighted",
|
||||
targetType: "package",
|
||||
targetId: pkg._id,
|
||||
metadata: { highlighted: nextHighlighted },
|
||||
createdAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const requestRescanForApiTokenInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
|
||||
+6
-12
@@ -547,16 +547,6 @@ const skillBadges = defineTable({
|
||||
.index("by_skill_kind", ["skillId", "kind"])
|
||||
.index("by_kind_at", ["kind", "at"]);
|
||||
|
||||
const packageBadges = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
kind: v.union(v.literal("highlighted")),
|
||||
byUserId: v.id("users"),
|
||||
at: v.number(),
|
||||
})
|
||||
.index("by_package", ["packageId"])
|
||||
.index("by_package_kind", ["packageId", "kind"])
|
||||
.index("by_kind_at", ["kind", "at"]);
|
||||
|
||||
const soulVersionFingerprints = defineTable({
|
||||
soulId: v.id("souls"),
|
||||
versionId: v.id("soulVersions"),
|
||||
@@ -1229,7 +1219,12 @@ const rescanRequests = defineTable({
|
||||
.index("by_skill_version", ["targetKind", "skillVersionId", "createdAt"])
|
||||
.index("by_skill_version_status", ["targetKind", "skillVersionId", "status", "createdAt"])
|
||||
.index("by_package_release", ["targetKind", "packageReleaseId", "createdAt"])
|
||||
.index("by_package_release_status", ["targetKind", "packageReleaseId", "status", "createdAt"])
|
||||
.index("by_package_release_status", [
|
||||
"targetKind",
|
||||
"packageReleaseId",
|
||||
"status",
|
||||
"createdAt",
|
||||
])
|
||||
.index("by_requester", ["requestedByUserId", "createdAt"]);
|
||||
|
||||
const apiTokens = defineTable({
|
||||
@@ -1367,7 +1362,6 @@ export default defineSchema({
|
||||
packageReleases,
|
||||
packageTrustedPublishers,
|
||||
packagePublishTokens,
|
||||
packageBadges,
|
||||
packageSearchDigest,
|
||||
packageCapabilitySearchDigest,
|
||||
souls,
|
||||
|
||||
+12
-5
@@ -12,6 +12,7 @@ import { matchesExactTokens, tokenize } from "./lib/searchText";
|
||||
import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags";
|
||||
import { isSkillSuspicious } from "./lib/skillSafety";
|
||||
import { digestToHydratableSkill, digestToOwnerInfo } from "./lib/skillSearchDigest";
|
||||
import { isSearchableSkillSlugShape, normalizeSkillSlug } from "./lib/skillSlugValidator";
|
||||
|
||||
type OwnerInfo = { ownerHandle: string | null; owner: PublicPublisher | null };
|
||||
|
||||
@@ -123,7 +124,11 @@ function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearch
|
||||
}
|
||||
|
||||
function isSlugLikeQuery(query: string) {
|
||||
return /^[a-z0-9][a-z0-9-]*$/.test(query.trim().toLowerCase());
|
||||
// Lenient shape check used by the read path: pattern + upper length cap only.
|
||||
// The min-length floor and reserved-word blocklist are intentionally omitted
|
||||
// so legacy rows (grandfathered short/reserved slugs) remain discoverable via
|
||||
// the exact-slug fast path. Write paths still go through assertValidSkillSlug.
|
||||
return isSearchableSkillSlugShape(query);
|
||||
}
|
||||
|
||||
function matchesCapabilityTag(
|
||||
@@ -183,8 +188,7 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
const results = await ctx.vectorSearch("skillEmbeddings", "by_embedding", {
|
||||
vector,
|
||||
limit: candidateLimit,
|
||||
filter: (q) =>
|
||||
q.or(q.eq("visibility", "latest"), q.eq("visibility", "latest-approved")),
|
||||
filter: (q) => q.or(q.eq("visibility", "latest"), q.eq("visibility", "latest-approved")),
|
||||
});
|
||||
|
||||
// Only hydrate embedding IDs we haven't seen yet (incremental).
|
||||
@@ -371,8 +375,11 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
>();
|
||||
|
||||
// Exact slug match via the skills table (only one row, cheap).
|
||||
const slugQuery = args.query.trim().toLowerCase();
|
||||
if (!args.skipExactSlugLookup && /^[a-z0-9][a-z0-9-]*$/.test(slugQuery)) {
|
||||
// Use the lenient shape predicate so legacy rows with sub-min-length
|
||||
// slugs stay discoverable; the caller in searchSkills already passes
|
||||
// skipExactSlugLookup=true after running its own exact-slug lookup.
|
||||
const slugQuery = normalizeSkillSlug(args.query);
|
||||
if (!args.skipExactSlugLookup && isSearchableSkillSlugShape(slugQuery)) {
|
||||
const exactSlugSkill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slugQuery))
|
||||
|
||||
+28
-18
@@ -96,6 +96,7 @@ import {
|
||||
extractDigestFields,
|
||||
upsertSkillSearchDigest,
|
||||
} from "./lib/skillSearchDigest";
|
||||
import { assertValidSkillSlug, normalizeSkillSlug } from "./lib/skillSlugValidator";
|
||||
import { readCanonicalStat } from "./lib/skillStats";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
|
||||
@@ -551,7 +552,10 @@ function buildAliasTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef)
|
||||
}
|
||||
|
||||
function normalizeSkillSlugKey(slug: string) {
|
||||
return slug.trim().toLowerCase();
|
||||
// Read-path normalization: lowercase + trim only. Intentionally lenient so
|
||||
// that legacy rows (pre-validator) remain lookup-able. Write paths must
|
||||
// use `normalizeSkillSlugForWrite` / `assertValidSkillSlug` instead.
|
||||
return normalizeSkillSlug(slug);
|
||||
}
|
||||
|
||||
type SkillOwnerRef =
|
||||
@@ -565,11 +569,9 @@ type SkillOwnerRef =
|
||||
| undefined;
|
||||
|
||||
function normalizeSkillSlugForWrite(slug: string) {
|
||||
const normalized = normalizeSkillSlugKey(slug);
|
||||
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
|
||||
throw new ConvexError("Slug must be lowercase and url-safe");
|
||||
}
|
||||
return normalized;
|
||||
// Write-path: full validation (length, pattern, reserved words,
|
||||
// no consecutive hyphens). See `lib/skillSlugValidator.ts`.
|
||||
return assertValidSkillSlug(slug);
|
||||
}
|
||||
|
||||
async function getSkillSlugAliasBySlug(ctx: Pick<QueryCtx | MutationCtx, "db">, slug: string) {
|
||||
@@ -3055,7 +3057,6 @@ function skillCatalogMatchesFilters(
|
||||
args: {
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
},
|
||||
@@ -3066,7 +3067,6 @@ function skillCatalogMatchesFilters(
|
||||
const isOfficial = isSkillCatalogOfficial(digest);
|
||||
const channel = getSkillCatalogChannel(digest);
|
||||
if (typeof args.isOfficial === "boolean" && isOfficial !== args.isOfficial) return false;
|
||||
if (args.highlightedOnly && !isSkillHighlighted(digest)) return false;
|
||||
if (args.channel && channel !== args.channel) return false;
|
||||
if (args.capabilityTag && !(digest.capabilityTags ?? []).includes(args.capabilityTag))
|
||||
return false;
|
||||
@@ -3122,7 +3122,6 @@ export const listPackageCatalogPage = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
@@ -3214,7 +3213,6 @@ export const searchPackageCatalogPublic = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
},
|
||||
@@ -5793,13 +5791,11 @@ async function renameOwnedSkillByActor(
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const sourceSlug = sourceSlugArg.trim().toLowerCase();
|
||||
const newSlug = newSlugArg.trim().toLowerCase();
|
||||
const sourceSlug = normalizeSkillSlug(sourceSlugArg);
|
||||
if (!sourceSlug) throw new ConvexError("Current slug required");
|
||||
if (!newSlug) throw new ConvexError("New slug required");
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(newSlug)) {
|
||||
throw new ConvexError("Invalid slug. Use lowercase letters, numbers, and hyphens only.");
|
||||
}
|
||||
// Full write-path validation for the new slug: length, pattern,
|
||||
// reserved-word blocklist, no consecutive hyphens.
|
||||
const newSlug = assertValidSkillSlug(newSlugArg);
|
||||
|
||||
const resolved = await resolveSkillBySlugOrAlias(ctx, sourceSlug);
|
||||
const skill = resolved.skill;
|
||||
@@ -6534,7 +6530,16 @@ export const insertVersion = internalMutation({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = args.userId;
|
||||
const slug = normalizeSkillSlugForWrite(args.slug);
|
||||
// Lenient normalization first so we can look up an existing skill row
|
||||
// before deciding whether to enforce the strict write-path validator.
|
||||
// Owners of grandfathered slugs (reserved, <3 chars, >48 chars, or other
|
||||
// pre-validator shapes) must remain able to publish new versions; the
|
||||
// strict reserved/length/pattern rules only apply when creating a brand
|
||||
// new skill. The caller (publishVersionForUser) performs the same split,
|
||||
// but the mutation re-validates defensively because it can be invoked on
|
||||
// its own (e.g. tests, internal schedulers).
|
||||
const normalizedSlug = normalizeSkillSlug(args.slug);
|
||||
if (!normalizedSlug) throw new ConvexError("Slug is required.");
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
|
||||
const personalPublisher = await ensurePersonalPublisherForUser(ctx, user);
|
||||
@@ -6552,9 +6557,14 @@ export const insertVersion = internalMutation({
|
||||
|
||||
let skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.withIndex("by_slug", (q) => q.eq("slug", normalizedSlug))
|
||||
.unique();
|
||||
|
||||
// Only enforce the strict write-path rules when creating a new skill.
|
||||
// For existing rows, keep the already-persisted (possibly grandfathered)
|
||||
// slug as-is so legacy publishers are not locked out of version updates.
|
||||
const slug = skill ? normalizedSlug : normalizeSkillSlugForWrite(args.slug);
|
||||
|
||||
if (!skill) {
|
||||
const alias = await getSkillSlugAliasBySlug(ctx, slug);
|
||||
if (alias) {
|
||||
|
||||
+21
-8
@@ -7,6 +7,7 @@ import { assertModerator, requireUser, requireUserFromAction } from "./lib/acces
|
||||
import { embeddingVisibilityFor } from "./lib/embeddingVisibility";
|
||||
import { toPublicSoul, toPublicUser } from "./lib/public";
|
||||
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
|
||||
import { assertValidSkillSlug, normalizeSkillSlug } from "./lib/skillSlugValidator";
|
||||
import { generateSoulChangelogPreview } from "./lib/soulChangelog";
|
||||
import { fetchText, type PublishResult, publishSoulVersionForUser } from "./lib/soulPublish";
|
||||
|
||||
@@ -70,15 +71,15 @@ function toPublicSoulVersion(
|
||||
}
|
||||
|
||||
function normalizeSoulSlugKey(slug: string) {
|
||||
return slug.trim().toLowerCase();
|
||||
// Read-path normalization: lowercase + trim only. Intentionally lenient so
|
||||
// that legacy rows (pre-validator) remain lookup-able.
|
||||
return normalizeSkillSlug(slug);
|
||||
}
|
||||
|
||||
function normalizeSoulSlugForWrite(slug: string) {
|
||||
const normalized = normalizeSoulSlugKey(slug);
|
||||
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
|
||||
throw new ConvexError("Slug must be lowercase and url-safe");
|
||||
}
|
||||
return normalized;
|
||||
// Write-path: full validation (length, pattern, reserved words,
|
||||
// no consecutive hyphens). Souls share the rules with skills.
|
||||
return assertValidSkillSlug(slug);
|
||||
}
|
||||
|
||||
export const getBySlug = query({
|
||||
@@ -497,17 +498,29 @@ export const insertVersion = internalMutation({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = args.userId;
|
||||
const slug = normalizeSoulSlugForWrite(args.slug);
|
||||
// Lenient normalization first: we must look up the existing soul row
|
||||
// before deciding whether to enforce the strict write-path validator.
|
||||
// Owners of grandfathered slugs (reserved, <3 chars, >48 chars, or other
|
||||
// pre-validator shapes) must remain able to publish new versions; the
|
||||
// strict rules only apply when creating a brand new soul. The caller
|
||||
// (publishSoulVersionForUser) performs the same split, but the mutation
|
||||
// re-validates defensively because it can be invoked on its own.
|
||||
const normalizedSlug = normalizeSkillSlug(args.slug);
|
||||
if (!normalizedSlug) throw new ConvexError("Slug is required.");
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
|
||||
|
||||
const soulMatches = await ctx.db
|
||||
.query("souls")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.withIndex("by_slug", (q) => q.eq("slug", normalizedSlug))
|
||||
.order("desc")
|
||||
.take(2);
|
||||
let soul: Doc<"souls"> | null = soulMatches[0] ?? null;
|
||||
|
||||
// Only enforce the strict write-path rules when creating a new soul; for
|
||||
// existing rows keep the already-persisted (possibly grandfathered) slug.
|
||||
const slug = soul ? normalizedSlug : normalizeSoulSlugForWrite(args.slug);
|
||||
|
||||
if (soul && soul.ownerUserId !== userId) {
|
||||
throw new ConvexError("Only the owner can publish soul updates");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchPluginCatalogMock = vi.fn();
|
||||
const fetchFeaturedPluginsMock = vi.fn();
|
||||
const isRateLimitedPackageApiErrorMock = vi.fn(
|
||||
(error: unknown) =>
|
||||
typeof error === "object" && error !== null && (error as { status?: number }).status === 429,
|
||||
@@ -58,10 +57,6 @@ vi.mock("../lib/packageApi", () => ({
|
||||
isRateLimitedPackageApiError: (error: unknown) => isRateLimitedPackageApiErrorMock(error),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/featuredCatalog", () => ({
|
||||
fetchFeaturedPlugins: (...args: unknown[]) => fetchFeaturedPluginsMock(...args),
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/plugins/index")).Route as unknown as {
|
||||
__config: {
|
||||
@@ -75,7 +70,6 @@ async function loadRoute() {
|
||||
describe("plugins route", () => {
|
||||
beforeEach(() => {
|
||||
fetchPluginCatalogMock.mockReset();
|
||||
fetchFeaturedPluginsMock.mockReset();
|
||||
isRateLimitedPackageApiErrorMock.mockClear();
|
||||
navigateMock.mockReset();
|
||||
searchMock = {};
|
||||
@@ -98,7 +92,6 @@ describe("plugins route", () => {
|
||||
family: undefined,
|
||||
q: "demo",
|
||||
cursor: undefined,
|
||||
featured: undefined,
|
||||
verified: undefined,
|
||||
executesCode: undefined,
|
||||
});
|
||||
@@ -218,29 +211,12 @@ describe("plugins route", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("selects featured from the sort group", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Featured" }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
};
|
||||
expect(lastCall.search({ family: "code-plugin", cursor: "cursor:current" })).toEqual({
|
||||
family: undefined,
|
||||
cursor: undefined,
|
||||
featured: true,
|
||||
});
|
||||
});
|
||||
|
||||
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<{
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
items: Array<{ name: string }>;
|
||||
nextCursor: string | null;
|
||||
rateLimited: boolean;
|
||||
@@ -261,7 +237,9 @@ describe("plugins route", () => {
|
||||
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<{
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
items: Array<{ name: string }>;
|
||||
nextCursor: string | null;
|
||||
rateLimited: boolean;
|
||||
|
||||
@@ -40,7 +40,7 @@ function runBeforeLoad(search: Record<string, unknown>) {
|
||||
|
||||
describe("skills route default sort", () => {
|
||||
it("redirects browse view to downloads when sort is missing", () => {
|
||||
expect(runBeforeLoad({})).toEqual({
|
||||
expect(runBeforeLoad({ nonSuspicious: true })).toEqual({
|
||||
redirect: {
|
||||
to: "/skills",
|
||||
search: {
|
||||
@@ -48,8 +48,7 @@ describe("skills route default sort", () => {
|
||||
sort: "downloads",
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
featured: undefined,
|
||||
nonSuspicious: undefined,
|
||||
nonSuspicious: true,
|
||||
tag: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
@@ -62,10 +61,4 @@ describe("skills route default sort", () => {
|
||||
it("does not redirect when query is present", () => {
|
||||
expect(runBeforeLoad({ q: "notion" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not redirect when filters are present", () => {
|
||||
expect(runBeforeLoad({ nonSuspicious: true })).toBeUndefined();
|
||||
expect(runBeforeLoad({ featured: true })).toBeUndefined();
|
||||
expect(runBeforeLoad({ highlighted: true })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { fetchPluginCatalog } from "./packageApi";
|
||||
|
||||
export async function fetchFeaturedPlugins(limit: number = 50) {
|
||||
const result = await fetchPluginCatalog({ featured: true, limit });
|
||||
return result.items;
|
||||
}
|
||||
+3
-18
@@ -255,7 +255,6 @@ export async function fetchPackages(params: {
|
||||
cursor?: string;
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
isOfficial?: boolean;
|
||||
featured?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
limit?: number;
|
||||
@@ -268,7 +267,6 @@ export async function fetchPackages(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -289,7 +287,6 @@ export async function fetchPackages(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -302,7 +299,6 @@ export async function fetchPluginCatalog(params: {
|
||||
cursor?: string;
|
||||
family?: PluginFamily;
|
||||
isOfficial?: boolean;
|
||||
featured?: boolean;
|
||||
executesCode?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<PluginCatalogResult> {
|
||||
@@ -312,7 +308,6 @@ export async function fetchPluginCatalog(params: {
|
||||
cursor: params.cursor,
|
||||
family: params.family,
|
||||
isOfficial: params.isOfficial,
|
||||
featured: params.featured,
|
||||
executesCode: params.executesCode,
|
||||
limit: params.limit,
|
||||
});
|
||||
@@ -337,7 +332,6 @@ export async function fetchPluginCatalog(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -345,9 +339,7 @@ export async function fetchPluginCatalog(params: {
|
||||
results?: Array<{ score: number; package: PackageListItem }>;
|
||||
}>(url);
|
||||
return {
|
||||
items: (response?.results ?? [])
|
||||
.map((entry) => entry?.package)
|
||||
.filter(Boolean) as PackageListItem[],
|
||||
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
@@ -358,7 +350,6 @@ export async function fetchPluginCatalog(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -379,10 +370,7 @@ export async function fetchPackageDetail(name: string): Promise<PackageDetailRes
|
||||
return (await response.json()) as PackageDetailResponse;
|
||||
}
|
||||
|
||||
export async function fetchPackageVersion(
|
||||
name: string,
|
||||
version: string,
|
||||
): Promise<PackageVersionDetail | null> {
|
||||
export async function fetchPackageVersion(name: string, version: string): Promise<PackageVersionDetail | null> {
|
||||
try {
|
||||
const url = await packageApiUrl(
|
||||
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
|
||||
@@ -394,10 +382,7 @@ export async function fetchPackageVersion(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPackageReadme(
|
||||
name: string,
|
||||
version?: string | null,
|
||||
): Promise<string | null> {
|
||||
export async function fetchPackageReadme(name: string, version?: string | null): Promise<string | null> {
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
|
||||
url.searchParams.set("path", "README.md");
|
||||
if (version) url.searchParams.set("version", version);
|
||||
|
||||
+34
-44
@@ -16,8 +16,6 @@ import { api } from "../../convex/_generated/api";
|
||||
import { SoulCard } from "../components/SoulCard";
|
||||
import { SoulStatsTripletLine } from "../components/SoulStats";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { fetchFeaturedPlugins } from "../lib/featuredCatalog";
|
||||
import type { PackageListItem } from "../lib/packageApi";
|
||||
import type { PublicSkill, PublicSoul, PublicUser } from "../lib/publicUser";
|
||||
import { getSiteMode } from "../lib/site";
|
||||
|
||||
@@ -39,7 +37,7 @@ function SkillsHome() {
|
||||
};
|
||||
|
||||
const [highlighted, setHighlighted] = useState<SkillPageEntry[]>([]);
|
||||
const [featuredPlugins, setFeaturedPlugins] = useState<PackageListItem[]>([]);
|
||||
const [popular, setPopular] = useState<SkillPageEntry[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -51,9 +49,15 @@ function SkillsHome() {
|
||||
if (!cancelled) setHighlighted(r as SkillPageEntry[]);
|
||||
})
|
||||
.catch(() => {});
|
||||
fetchFeaturedPlugins(6)
|
||||
.then((items) => {
|
||||
if (!cancelled) setFeaturedPlugins(items);
|
||||
convexHttp
|
||||
.query(api.skills.listPublicPageV4, {
|
||||
numItems: 12,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
nonSuspiciousOnly: true,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!cancelled) setPopular((r as { page: SkillPageEntry[] }).page);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
@@ -175,24 +179,8 @@ function SkillsHome() {
|
||||
{carouselCards.length > 0 && (
|
||||
<section className="home-v2-carousel-section">
|
||||
<div className="home-v2-carousel-header">
|
||||
<h2>Featured skills</h2>
|
||||
<h2>Featured</h2>
|
||||
<div className="home-v2-carousel-controls">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
featured: true,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-v2-section-link"
|
||||
>
|
||||
View all <ArrowRight size={14} />
|
||||
</Link>
|
||||
<button type="button" className="home-v2-carousel-btn" aria-label="Previous">
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
@@ -357,20 +345,21 @@ function SkillsHome() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ FEATURED PLUGINS ═══ */}
|
||||
{featuredPlugins.length > 0 && (
|
||||
{/* ═══ TRENDING ═══ */}
|
||||
{popular.length > 0 && (
|
||||
<section className="home-v2-trending-section">
|
||||
<div className="home-v2-section-header">
|
||||
<h2>Featured plugins</h2>
|
||||
<h2>Trending Now</h2>
|
||||
<Link
|
||||
to="/plugins"
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
cursor: undefined,
|
||||
family: undefined,
|
||||
featured: true,
|
||||
verified: undefined,
|
||||
executesCode: undefined,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-v2-section-link"
|
||||
>
|
||||
@@ -378,26 +367,27 @@ function SkillsHome() {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="home-v2-trending-grid">
|
||||
{featuredPlugins.slice(0, 6).map((plugin) => (
|
||||
<Link
|
||||
key={plugin.name}
|
||||
to="/plugins/$name"
|
||||
params={{ name: plugin.name }}
|
||||
className="home-v2-trend-card"
|
||||
>
|
||||
{popular.slice(0, 6).map((entry) => (
|
||||
<Link key={entry.skill._id} to={skillLink(entry)} className="home-v2-trend-card">
|
||||
<div className="home-v2-trend-head">
|
||||
<div className="home-v2-trend-title">{plugin.displayName || plugin.name}</div>
|
||||
<div className="home-v2-trend-title">
|
||||
{entry.skill.displayName || entry.skill.slug}
|
||||
</div>
|
||||
<div className="home-v2-trend-creator">
|
||||
{plugin.ownerHandle ? `by @${plugin.ownerHandle}` : "community plugin"}
|
||||
by {entry.ownerHandle || entry.owner?.handle || "unknown"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-v2-trend-desc">
|
||||
{plugin.summary || "Gateway plugin for OpenClaw workflows."}
|
||||
{entry.skill.summary || "Agent-ready skill pack."}
|
||||
</div>
|
||||
<div className="home-v2-trend-bottom">
|
||||
<div className="home-v2-trend-signals">
|
||||
{plugin.isOfficial ? <span>Verified</span> : null}
|
||||
{plugin.latestVersion ? <span>v{plugin.latestVersion}</span> : null}
|
||||
<span>
|
||||
<Star size={12} /> {formatStat(entry.skill.stats?.stars)}
|
||||
</span>
|
||||
<span>
|
||||
<Download size={12} /> {formatStat(entry.skill.stats?.downloads)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="home-v2-trend-install">
|
||||
<Download size={13} /> Install
|
||||
|
||||
+35
-151
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
isSkillHighlighted,
|
||||
isSkillOfficial,
|
||||
} from "../lib/badges";
|
||||
import { familyLabel } from "../lib/packageLabels";
|
||||
import type { PublicPublisher } from "../lib/publicUser";
|
||||
import { isAdmin, isModerator } from "../lib/roles";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
|
||||
@@ -77,13 +75,6 @@ type SkillBySlugResult = {
|
||||
} | null;
|
||||
} | null;
|
||||
|
||||
type PluginByNameResult = {
|
||||
package: Doc<"packages">;
|
||||
latestRelease: Doc<"packageReleases"> | null;
|
||||
owner: PublicPublisher | null;
|
||||
highlighted: { byUserId: Id<"users">; at: number } | null;
|
||||
} | null;
|
||||
|
||||
function resolveOwnerParam(
|
||||
handle: string | null | undefined,
|
||||
ownerId?: Id<"users"> | Id<"publishers">,
|
||||
@@ -108,7 +99,6 @@ function promptUnbanReason(label: string) {
|
||||
export const Route = createFileRoute("/management")({
|
||||
validateSearch: (search) => ({
|
||||
skill: typeof search.skill === "string" && search.skill.trim() ? search.skill : undefined,
|
||||
plugin: typeof search.plugin === "string" && search.plugin.trim() ? search.plugin : undefined,
|
||||
}),
|
||||
component: Management,
|
||||
});
|
||||
@@ -116,20 +106,14 @@ export const Route = createFileRoute("/management")({
|
||||
function Management() {
|
||||
const { me } = useAuthStatus();
|
||||
const search = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
const staff = isModerator(me);
|
||||
const admin = isAdmin(me);
|
||||
|
||||
const selectedSlug = search.skill?.trim();
|
||||
const selectedPluginName = search.plugin?.trim();
|
||||
const selectedSkill = useQuery(
|
||||
api.skills.getBySlugForStaff,
|
||||
staff && selectedSlug ? { slug: selectedSlug, auditLogLimit: SKILL_AUDIT_LOG_LIMIT } : "skip",
|
||||
) as SkillBySlugResult | undefined;
|
||||
const selectedPlugin = useQuery(
|
||||
api.packages.getByNameForStaff,
|
||||
staff && selectedPluginName ? { name: selectedPluginName } : "skip",
|
||||
) as PluginByNameResult | undefined;
|
||||
const selectedSkillId = selectedSkill?.skill?._id ?? null;
|
||||
const recentVersions = useQuery(api.skills.listRecentVersions, staff ? { limit: 20 } : "skip") as
|
||||
| RecentVersionEntry[]
|
||||
@@ -146,7 +130,6 @@ function Management() {
|
||||
const banUser = useMutation(api.users.banUser);
|
||||
const unbanUser = useMutation(api.users.unbanUser);
|
||||
const setBatch = useMutation(api.skills.setBatch);
|
||||
const setPackageBatch = useMutation(api.packages.setBatch);
|
||||
const setSoftDeleted = useMutation(api.skills.setSoftDeleted);
|
||||
const hardDelete = useMutation(api.skills.hardDelete);
|
||||
const changeOwner = useMutation(api.skills.changeOwner);
|
||||
@@ -162,7 +145,6 @@ function Management() {
|
||||
const [reportSearchDebounced, setReportSearchDebounced] = useState("");
|
||||
const [userSearch, setUserSearch] = useState("");
|
||||
const [userSearchDebounced, setUserSearchDebounced] = useState("");
|
||||
const [pluginSearch, setPluginSearch] = useState(selectedPluginName ?? "");
|
||||
const [skillOverrideNote, setSkillOverrideNote] = useState("");
|
||||
|
||||
const userQuery = userSearchDebounced.trim();
|
||||
@@ -184,10 +166,6 @@ function Management() {
|
||||
setSkillOverrideNote("");
|
||||
}, [selectedSkillId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPluginSearch(selectedPluginName ?? "");
|
||||
}, [selectedPluginName]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setReportSearchDebounced(reportSearch), 250);
|
||||
return () => clearTimeout(handle);
|
||||
@@ -279,22 +257,15 @@ function Management() {
|
||||
.catch((error) => window.alert(formatMutationError(error)));
|
||||
};
|
||||
|
||||
const managePlugin = () => {
|
||||
const name = pluginSearch.trim();
|
||||
if (!name) return;
|
||||
void navigate({
|
||||
to: "/management",
|
||||
search: { skill: undefined, plugin: name },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<h1 className="section-title">Management console</h1>
|
||||
<p className="section-subtitle">Moderation, curation, and ownership tools.</p>
|
||||
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Reported skills</h2>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Reported skills
|
||||
</h2>
|
||||
<div className="management-controls">
|
||||
<div className="management-control management-search">
|
||||
<span className="mono">Filter</span>
|
||||
@@ -347,7 +318,9 @@ function Management() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="section-subtitle m-0">No report reasons yet.</div>
|
||||
<div className="section-subtitle m-0">
|
||||
No report reasons yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
@@ -392,7 +365,9 @@ function Management() {
|
||||
</Card>
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">Skill tools</h2>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Skill tools
|
||||
</h2>
|
||||
{selectedSlug ? (
|
||||
<div className="section-subtitle mt-2">
|
||||
Managing "{selectedSlug}" ·{" "}
|
||||
@@ -442,12 +417,16 @@ function Management() {
|
||||
{skill.moderationFlags?.length ? (
|
||||
<div className="management-tags">
|
||||
{skill.moderationFlags.map((flag: string) => (
|
||||
<Badge key={flag}>{flag}</Badge>
|
||||
<Badge key={flag}>
|
||||
{flag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="management-sublist">
|
||||
<div className="section-subtitle m-0">Manual overrides</div>
|
||||
<div className="section-subtitle m-0">
|
||||
Manual overrides
|
||||
</div>
|
||||
<section className="management-override-panel">
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Current override</span>
|
||||
@@ -499,14 +478,18 @@ function Management() {
|
||||
</section>
|
||||
</div>
|
||||
<div className="management-sublist">
|
||||
<div className="section-subtitle m-0">Recent audit activity</div>
|
||||
<div className="section-subtitle m-0">
|
||||
Recent audit activity
|
||||
</div>
|
||||
<section className="management-override-panel management-audit-panel">
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Window</span>
|
||||
<span>Last {SKILL_AUDIT_LOG_LIMIT} entries for this skill.</span>
|
||||
</div>
|
||||
{auditLogs.length === 0 ? (
|
||||
<div className="section-subtitle m-0">No audit activity yet.</div>
|
||||
<div className="section-subtitle m-0">
|
||||
No audit activity yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="management-audit-list">
|
||||
{auditLogs.map((entry) => {
|
||||
@@ -607,7 +590,10 @@ function Management() {
|
||||
</div>
|
||||
<div className="management-actions management-action-grid">
|
||||
<Button asChild className="management-action-btn">
|
||||
<Link to="/$owner/$slug" params={{ owner: ownerParam, slug: skill.slug }}>
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerParam, slug: skill.slug }}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -707,115 +693,9 @@ function Management() {
|
||||
</Card>
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">Plugin tools</h2>
|
||||
<div className="management-controls">
|
||||
<div className="management-control management-search">
|
||||
<span className="mono">Package</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="@scope/plugin-name or package-name"
|
||||
value={pluginSearch}
|
||||
onChange={(event) => setPluginSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
managePlugin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" onClick={managePlugin} disabled={!pluginSearch.trim()}>
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
{selectedPluginName ? (
|
||||
<div className="section-subtitle mt-2">
|
||||
Managing "{selectedPluginName}" ·{" "}
|
||||
<Link to="/management" search={{ skill: undefined, plugin: undefined }}>
|
||||
Clear selection
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="management-list">
|
||||
{!selectedPluginName ? (
|
||||
<div className="stat">Enter a plugin package name to open tooling here.</div>
|
||||
) : selectedPlugin === undefined ? (
|
||||
<div className="stat">Loading plugin…</div>
|
||||
) : !selectedPlugin?.package ? (
|
||||
<div className="stat">No plugin found for "{selectedPluginName}".</div>
|
||||
) : (
|
||||
(() => {
|
||||
const plugin = selectedPlugin.package;
|
||||
const owner = selectedPlugin.owner;
|
||||
const latestRelease = selectedPlugin.latestRelease;
|
||||
const isHighlighted = Boolean(selectedPlugin.highlighted);
|
||||
|
||||
return (
|
||||
<div key={plugin._id} className="management-item management-item-detail">
|
||||
<div className="management-item-main">
|
||||
<Link to="/plugins/$name" params={{ name: plugin.name }}>
|
||||
{plugin.displayName}
|
||||
</Link>
|
||||
<div className="section-subtitle m-0">
|
||||
{owner?.handle ? `@${owner.handle}` : "unknown owner"} ·{" "}
|
||||
{familyLabel(plugin.family)} · v{latestRelease?.version ?? "—"} · updated{" "}
|
||||
{formatTimestamp(plugin.updatedAt)}
|
||||
{plugin.softDeletedAt ? " · hidden" : ""}
|
||||
{isHighlighted ? " · highlighted" : ""}
|
||||
</div>
|
||||
<div className="management-tags">
|
||||
<Badge>{plugin.channel}</Badge>
|
||||
{plugin.isOfficial ? <Badge>official</Badge> : null}
|
||||
{plugin.executesCode ? <Badge>executes code</Badge> : null}
|
||||
{plugin.runtimeId ? <Badge>{plugin.runtimeId}</Badge> : null}
|
||||
</div>
|
||||
<div className="management-sublist">
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Package name</span>
|
||||
<span className="mono">{plugin.name}</span>
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Summary</span>
|
||||
<span>{plugin.summary ?? "No summary provided."}</span>
|
||||
</div>
|
||||
<div className="management-report-item">
|
||||
<span className="management-report-meta">Featured state</span>
|
||||
<span>
|
||||
{isHighlighted
|
||||
? `Highlighted ${formatTimestamp(selectedPlugin.highlighted?.at ?? 0)}`
|
||||
: "Not highlighted"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="management-actions management-action-grid">
|
||||
<Button asChild className="management-action-btn">
|
||||
<Link to="/plugins/$name" params={{ name: plugin.name }}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
className="management-action-btn"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void setPackageBatch({
|
||||
packageId: plugin._id,
|
||||
batch: isHighlighted ? undefined : "highlighted",
|
||||
}).catch((error) => window.alert(formatMutationError(error)))
|
||||
}
|
||||
>
|
||||
{isHighlighted ? "Unhighlight" : "Highlight"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">Duplicate candidates</h2>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Duplicate candidates
|
||||
</h2>
|
||||
<div className="management-list">
|
||||
{duplicateCandidates.length === 0 ? (
|
||||
<div className="stat">No duplicate candidates.</div>
|
||||
@@ -904,7 +784,9 @@ function Management() {
|
||||
</Card>
|
||||
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">Recent pushes</h2>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Recent pushes
|
||||
</h2>
|
||||
<div className="management-list">
|
||||
{recentVersions.length === 0 ? (
|
||||
<div className="stat">No recent versions.</div>
|
||||
@@ -950,7 +832,9 @@ function Management() {
|
||||
|
||||
{admin ? (
|
||||
<Card className="mt-5">
|
||||
<h2 className="section-title text-[1.2rem] m-0">Users</h2>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Users
|
||||
</h2>
|
||||
<div className="management-controls">
|
||||
<div className="management-control management-search">
|
||||
<span className="mono">Filter</span>
|
||||
|
||||
+186
-185
@@ -400,194 +400,195 @@ function PluginDetailRoute() {
|
||||
) : null}
|
||||
|
||||
{/* Capabilities */}
|
||||
{capEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Capabilities</CardTitle>
|
||||
<InstallCopyButton
|
||||
text={JSON.stringify(capabilities, null, 2)}
|
||||
ariaLabel="Copy capabilities JSON"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{capEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{CAPABILITY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words text-[color:var(--ink)]">
|
||||
{key === "capabilityTags" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((tag) => (
|
||||
<Link key={tag} to="/plugins" search={{ q: tag }}>
|
||||
<Badge variant="compact">{tag}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : key === "hostTargets" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((target) => (
|
||||
<Badge key={target} variant="compact">
|
||||
{target}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
formatCapabilityValue(value)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{capEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Capabilities</CardTitle>
|
||||
<InstallCopyButton
|
||||
text={JSON.stringify(capabilities, null, 2)}
|
||||
ariaLabel="Copy capabilities JSON"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{capEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{CAPABILITY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-words text-[color:var(--ink)]">
|
||||
{key === "capabilityTags" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((tag) => (
|
||||
<Link key={tag} to="/plugins" search={{ q: tag }}>
|
||||
<Badge variant="compact">{tag}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : key === "hostTargets" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((target) => (
|
||||
<Badge key={target} variant="compact">
|
||||
{target}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
formatCapabilityValue(value)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Compatibility */}
|
||||
{compatEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Compatibility</CardTitle>
|
||||
<InstallCopyButton
|
||||
text={JSON.stringify(compatibility, null, 2)}
|
||||
ariaLabel="Copy compatibility JSON"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{compatEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{/* Compatibility */}
|
||||
{compatEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Compatibility</CardTitle>
|
||||
<InstallCopyButton
|
||||
text={JSON.stringify(compatibility, null, 2)}
|
||||
ariaLabel="Copy compatibility JSON"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{compatEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
|
||||
</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Verification */}
|
||||
{verification && !isEmptyObject(verification) ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verification</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{verification.tier ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.tier.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scope ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.scope.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.summary ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceRepo
|
||||
? (() => {
|
||||
const raw = verification.sourceRepo;
|
||||
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
|
||||
const display = href.replace(/^https?:\/\//, "");
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
{display}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
{verification.sourceCommit ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceCommit.slice(0, 12)}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceTag ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceTag}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.hasProvenance !== undefined ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.hasProvenance ? "Yes" : "No"}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scanStatus ? (
|
||||
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{/* Verification */}
|
||||
{verification && !isEmptyObject(verification) ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verification</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{verification.tier ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.tier.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scope ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.scope.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.summary ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceRepo
|
||||
? (() => {
|
||||
const raw = verification.sourceRepo;
|
||||
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
|
||||
const display = href.replace(/^https?:\/\//, "");
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex max-w-full flex-wrap items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
{display}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
{verification.sourceCommit ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceCommit.slice(0, 12)}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceTag ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceTag}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.hasProvenance !== undefined ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.hasProvenance ? "Yes" : "No"}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scanStatus ? (
|
||||
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Tags */}
|
||||
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{Object.entries(pkg.tags).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Tags */}
|
||||
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{Object.entries(pkg.tags).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
|
||||
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</DetailHero>
|
||||
</DetailPageShell>
|
||||
</main>
|
||||
|
||||
@@ -14,7 +14,6 @@ type PluginSearchState = {
|
||||
q?: string;
|
||||
cursor?: string;
|
||||
family?: "code-plugin" | "bundle-plugin";
|
||||
featured?: boolean;
|
||||
verified?: boolean;
|
||||
executesCode?: boolean;
|
||||
};
|
||||
@@ -44,16 +43,14 @@ export const Route = createFileRoute("/plugins/")({
|
||||
search.family === "code-plugin" || search.family === "bundle-plugin"
|
||||
? search.family
|
||||
: undefined,
|
||||
featured:
|
||||
search.featured === true || search.featured === "true" || search.featured === "1"
|
||||
? true
|
||||
: undefined,
|
||||
verified:
|
||||
search.verified === true || search.verified === "true" || search.verified === "1"
|
||||
? true
|
||||
: undefined,
|
||||
executesCode:
|
||||
search.executesCode === true || search.executesCode === "true" || search.executesCode === "1"
|
||||
search.executesCode === true ||
|
||||
search.executesCode === "true" ||
|
||||
search.executesCode === "1"
|
||||
? true
|
||||
: undefined,
|
||||
}),
|
||||
@@ -64,7 +61,6 @@ export const Route = createFileRoute("/plugins/")({
|
||||
q: deps.q,
|
||||
cursor: deps.q ? undefined : deps.cursor,
|
||||
family: deps.family,
|
||||
featured: deps.featured,
|
||||
isOfficial: deps.verified,
|
||||
executesCode: deps.executesCode,
|
||||
limit: 50,
|
||||
@@ -104,14 +100,14 @@ function PluginsIndex() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = Route.useNavigate();
|
||||
const loaderData = Route.useLoaderData() as PluginsLoaderData | undefined;
|
||||
|
||||
|
||||
// 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 [query, setQuery] = useState(search.q ?? "");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
@@ -140,24 +136,12 @@ function PluginsIndex() {
|
||||
};
|
||||
|
||||
const handleFamilySort = (value: string) => {
|
||||
if (value === "featured") {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
featured: true,
|
||||
family: undefined,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const family = value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
|
||||
const family =
|
||||
value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
featured: undefined,
|
||||
family: family as "code-plugin" | "bundle-plugin" | undefined,
|
||||
}),
|
||||
});
|
||||
@@ -216,12 +200,11 @@ function PluginsIndex() {
|
||||
<div className={`browse-layout${sidebarOpen ? " sidebar-open" : ""}`}>
|
||||
<BrowseSidebar
|
||||
sortOptions={[
|
||||
{ value: "featured", label: "Featured" },
|
||||
{ value: "all", label: "All types" },
|
||||
{ value: "code-plugin", label: "Code plugins" },
|
||||
{ value: "bundle-plugin", label: "Bundle plugins" },
|
||||
]}
|
||||
activeSort={search.featured ? "featured" : (search.family ?? "all")}
|
||||
activeSort={search.family ?? "all"}
|
||||
onSortChange={handleFamilySort}
|
||||
filters={[
|
||||
{ key: "verified", label: "Verified only", active: search.verified ?? false },
|
||||
@@ -248,7 +231,9 @@ function PluginsIndex() {
|
||||
<div className="empty-state">
|
||||
<AlertTriangle size={20} aria-hidden="true" />
|
||||
<p className="empty-state-title">Plugin catalog is temporarily unavailable</p>
|
||||
<p className="empty-state-body">Try again {formatRetryDelay(retryAfterSeconds)}.</p>
|
||||
<p className="empty-state-body">
|
||||
Try again {formatRetryDelay(retryAfterSeconds)}.
|
||||
</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
|
||||
@@ -15,7 +15,6 @@ export type SkillsSearchState = {
|
||||
sort?: SortKey;
|
||||
dir?: SortDir;
|
||||
highlighted?: boolean;
|
||||
featured?: boolean;
|
||||
nonSuspicious?: boolean;
|
||||
tag?: string;
|
||||
view?: SkillsView;
|
||||
@@ -58,7 +57,7 @@ export function useSkillsBrowseModel({
|
||||
const navigateTimer = useRef<number>(0);
|
||||
|
||||
const view: SkillsView = search.view ?? "list";
|
||||
const featuredOnly = search.featured ?? search.highlighted ?? false;
|
||||
const highlightedOnly = search.highlighted ?? false;
|
||||
const nonSuspiciousOnly = search.nonSuspicious ?? false;
|
||||
const capabilityTag = search.tag;
|
||||
const searchSkills = useAction(api.search.searchSkills);
|
||||
@@ -73,7 +72,7 @@ export function useSkillsBrowseModel({
|
||||
const listSort = toListSort(sort);
|
||||
const dir = parseDir(search.dir, sort);
|
||||
const searchKey = trimmedQuery
|
||||
? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${nonSuspiciousOnly ? "1" : "0"}::${capabilityTag ?? ""}`
|
||||
? `${trimmedQuery}::${highlightedOnly ? "1" : "0"}::${nonSuspiciousOnly ? "1" : "0"}::${capabilityTag ?? ""}`
|
||||
: "";
|
||||
|
||||
// One-shot paginated fetches (no reactive subscription)
|
||||
@@ -90,7 +89,7 @@ export function useSkillsBrowseModel({
|
||||
numItems: pageSize,
|
||||
sort: listSort,
|
||||
dir,
|
||||
highlightedOnly: featuredOnly,
|
||||
highlightedOnly,
|
||||
nonSuspiciousOnly,
|
||||
capabilityTag,
|
||||
});
|
||||
@@ -106,7 +105,7 @@ export function useSkillsBrowseModel({
|
||||
setListStatus(cursor ? "idle" : "done");
|
||||
}
|
||||
},
|
||||
[capabilityTag, dir, featuredOnly, listSort, nonSuspiciousOnly],
|
||||
[capabilityTag, dir, highlightedOnly, listSort, nonSuspiciousOnly],
|
||||
);
|
||||
|
||||
// Reset and fetch first page when sort/dir/filters change
|
||||
@@ -156,7 +155,7 @@ export function useSkillsBrowseModel({
|
||||
try {
|
||||
const data = (await searchSkills({
|
||||
query: trimmedQuery,
|
||||
highlightedOnly: featuredOnly,
|
||||
highlightedOnly,
|
||||
nonSuspiciousOnly,
|
||||
capabilityTag,
|
||||
limit: searchLimit,
|
||||
@@ -175,7 +174,7 @@ export function useSkillsBrowseModel({
|
||||
}, [
|
||||
capabilityTag,
|
||||
hasQuery,
|
||||
featuredOnly,
|
||||
highlightedOnly,
|
||||
nonSuspiciousOnly,
|
||||
searchLimit,
|
||||
searchSkills,
|
||||
@@ -198,8 +197,7 @@ export function useSkillsBrowseModel({
|
||||
const sorted = useMemo(() => {
|
||||
if (isOtherCategory) {
|
||||
return baseItems.filter((entry) => {
|
||||
const text =
|
||||
`${entry.skill.displayName} ${entry.skill.summary ?? ""} ${entry.skill.slug}`.toLowerCase();
|
||||
const text = `${entry.skill.displayName} ${entry.skill.summary ?? ""} ${entry.skill.slug}`.toLowerCase();
|
||||
return !ALL_CATEGORY_KEYWORDS.some((kw) => text.includes(kw));
|
||||
});
|
||||
}
|
||||
@@ -321,12 +319,11 @@ export function useSkillsBrowseModel({
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const onToggleFeatured = useCallback(() => {
|
||||
const onToggleHighlighted = useCallback(() => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
featured: prev.featured || prev.highlighted ? undefined : true,
|
||||
highlighted: undefined,
|
||||
highlighted: prev.highlighted ? undefined : true,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
@@ -378,7 +375,7 @@ export function useSkillsBrowseModel({
|
||||
}, [navigate]);
|
||||
|
||||
const activeFilters: string[] = [];
|
||||
if (featuredOnly) activeFilters.push("featured");
|
||||
if (highlightedOnly) activeFilters.push("highlighted");
|
||||
if (nonSuspiciousOnly) activeFilters.push("non-suspicious");
|
||||
if (capabilityTag) activeFilters.push(SKILL_CAPABILITY_LABELS[capabilityTag] ?? capabilityTag);
|
||||
|
||||
@@ -402,7 +399,7 @@ export function useSkillsBrowseModel({
|
||||
canLoadMore,
|
||||
dir,
|
||||
hasQuery,
|
||||
featuredOnly,
|
||||
highlightedOnly,
|
||||
isLoadingMore,
|
||||
isLoadingSkills,
|
||||
loadMore,
|
||||
@@ -412,7 +409,7 @@ export function useSkillsBrowseModel({
|
||||
onQueryChange,
|
||||
onSortChange,
|
||||
onToggleDir,
|
||||
onToggleFeatured,
|
||||
onToggleHighlighted,
|
||||
onToggleNonSuspicious,
|
||||
onToggleView,
|
||||
query,
|
||||
|
||||
+30
-60
@@ -6,7 +6,7 @@ import { api } from "../../../convex/_generated/api";
|
||||
import { BrowseSidebar } from "../../components/BrowseSidebar";
|
||||
import { SKILL_CATEGORIES } from "../../lib/categories";
|
||||
import { formatCompactStat } from "../../lib/numberFormat";
|
||||
import { parseDir, parseSort } from "./-params";
|
||||
import { parseSort } from "./-params";
|
||||
import { SkillsResults } from "./-SkillsResults";
|
||||
import { useSkillsBrowseModel, type SkillsSearchState } from "./-useSkillsBrowseModel";
|
||||
|
||||
@@ -29,10 +29,6 @@ export const Route = createFileRoute("/skills/")({
|
||||
search.highlighted === "1" || search.highlighted === "true" || search.highlighted === true
|
||||
? true
|
||||
: undefined,
|
||||
featured:
|
||||
search.featured === "1" || search.featured === "true" || search.featured === true
|
||||
? true
|
||||
: undefined,
|
||||
nonSuspicious:
|
||||
search.nonSuspicious === "1" ||
|
||||
search.nonSuspicious === "true" ||
|
||||
@@ -45,9 +41,7 @@ export const Route = createFileRoute("/skills/")({
|
||||
},
|
||||
beforeLoad: ({ search }) => {
|
||||
const hasQuery = Boolean(search.q?.trim());
|
||||
if (hasQuery || search.sort || search.featured || search.highlighted || search.nonSuspicious) {
|
||||
return;
|
||||
}
|
||||
if (hasQuery || search.sort) return;
|
||||
throw redirect({
|
||||
to: "/skills",
|
||||
search: {
|
||||
@@ -55,7 +49,6 @@ export const Route = createFileRoute("/skills/")({
|
||||
sort: "downloads",
|
||||
dir: search.dir || undefined,
|
||||
highlighted: search.highlighted || undefined,
|
||||
featured: search.featured || undefined,
|
||||
nonSuspicious: search.nonSuspicious || undefined,
|
||||
view: search.view || undefined,
|
||||
focus: search.focus || undefined,
|
||||
@@ -71,7 +64,8 @@ export function SkillsIndex() {
|
||||
const search = Route.useSearch();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const totalSkills = useQuery(api.skills.countPublicSkills);
|
||||
const totalSkillsText = typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
|
||||
const totalSkillsText =
|
||||
typeof totalSkills === "number" ? formatCompactStat(totalSkills) : null;
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const model = useSkillsBrowseModel({
|
||||
@@ -86,50 +80,12 @@ export function SkillsIndex() {
|
||||
|
||||
const handleFilterToggle = useCallback(
|
||||
(key: string) => {
|
||||
if (key === "nonSuspicious") model.onToggleNonSuspicious();
|
||||
if (key === "highlighted") model.onToggleHighlighted();
|
||||
else if (key === "nonSuspicious") model.onToggleNonSuspicious();
|
||||
},
|
||||
[model.onToggleNonSuspicious],
|
||||
[model.onToggleHighlighted, model.onToggleNonSuspicious],
|
||||
);
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "featured") {
|
||||
if (!model.featuredOnly) model.onToggleFeatured();
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.featuredOnly) {
|
||||
const nextSort = parseSort(value);
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
sort: nextSort,
|
||||
dir: parseDir(prev.dir, nextSort),
|
||||
featured: undefined,
|
||||
highlighted: undefined,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
model.onSortChange(value);
|
||||
},
|
||||
[model.featuredOnly, model.onSortChange, model.onToggleFeatured, navigate],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
model.onQueryChange("");
|
||||
if (model.featuredOnly) model.onToggleFeatured();
|
||||
if (model.nonSuspiciousOnly) model.onToggleNonSuspicious();
|
||||
}, [
|
||||
model.featuredOnly,
|
||||
model.onQueryChange,
|
||||
model.onToggleFeatured,
|
||||
model.onToggleNonSuspicious,
|
||||
model.nonSuspiciousOnly,
|
||||
]);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(slug: string | undefined) => {
|
||||
if (slug) {
|
||||
@@ -147,8 +103,9 @@ export function SkillsIndex() {
|
||||
const activeCategory = useMemo(() => {
|
||||
if (!model.query) return undefined;
|
||||
return (
|
||||
SKILL_CATEGORIES.find((c) => c.keywords.some((k) => k === model.query.trim().toLowerCase()))
|
||||
?.slug ?? undefined
|
||||
SKILL_CATEGORIES.find((c) =>
|
||||
c.keywords.some((k) => k === model.query.trim().toLowerCase()),
|
||||
)?.slug ?? undefined
|
||||
);
|
||||
}, [model.query]);
|
||||
|
||||
@@ -165,7 +122,9 @@ export function SkillsIndex() {
|
||||
</button>
|
||||
<h1 className="browse-title">
|
||||
Skills
|
||||
{totalSkillsText ? <span className="browse-count">{totalSkillsText}</span> : null}
|
||||
{totalSkillsText ? (
|
||||
<span className="browse-count">{totalSkillsText}</span>
|
||||
) : null}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="browse-page-search">
|
||||
@@ -183,10 +142,11 @@ export function SkillsIndex() {
|
||||
categories={SKILL_CATEGORIES}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
sortOptions={[{ value: "featured", label: "Featured" }, ...sortOptionsWithRelevance]}
|
||||
activeSort={model.featuredOnly ? "featured" : model.sort}
|
||||
onSortChange={handleSortChange}
|
||||
sortOptions={sortOptionsWithRelevance}
|
||||
activeSort={model.sort}
|
||||
onSortChange={model.onSortChange}
|
||||
filters={[
|
||||
{ key: "highlighted", label: "Staff picks", active: model.highlightedOnly },
|
||||
{ key: "nonSuspicious", label: "Hide suspicious", active: model.nonSuspiciousOnly },
|
||||
]}
|
||||
onFilterToggle={handleFilterToggle}
|
||||
@@ -194,9 +154,19 @@ export function SkillsIndex() {
|
||||
<div className="browse-results">
|
||||
<div className="browse-results-toolbar">
|
||||
<span className="browse-results-count">
|
||||
{model.isLoadingSkills ? "\u2014" : `${model.sorted.length} results`}
|
||||
{model.hasQuery || model.featuredOnly || model.nonSuspiciousOnly ? (
|
||||
<button className="browse-clear-btn" type="button" onClick={handleClear}>
|
||||
{model.isLoadingSkills
|
||||
? "\u2014"
|
||||
: `${model.sorted.length} results`}
|
||||
{(model.hasQuery || model.highlightedOnly || model.nonSuspiciousOnly) ? (
|
||||
<button
|
||||
className="browse-clear-btn"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
model.onQueryChange("");
|
||||
if (model.highlightedOnly) model.onToggleHighlighted();
|
||||
if (model.nonSuspiciousOnly) model.onToggleNonSuspicious();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user