chore(ci): enforce formatting

This commit is contained in:
Vincent Koc
2026-04-30 23:39:28 -07:00
parent cd37acadbb
commit dfc0d540d8
79 changed files with 2125 additions and 2122 deletions
@@ -126,10 +126,10 @@ defineTable({ team: v.id("teams"), user: v.id("users") })
```ts
// Good: single compound index serves both query patterns
defineTable({ team: v.id("teams"), user: v.id("users") }).index(
"by_team_and_user",
["team", "user"],
);
defineTable({ team: v.id("teams"), user: v.id("users") }).index("by_team_and_user", [
"team",
"user",
]);
```
Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first.
@@ -171,8 +171,7 @@ const ownerName = project.ownerName ?? "Unknown owner";
```ts
// Good: denormalized data is an optimization, not the only source of truth
const ownerName =
project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null;
const ownerName = project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null;
```
Bad lookup map pattern:
@@ -134,10 +134,7 @@ const profile = useQuery(api.users.getProfile, { userId: selectedId! });
```ts
// Good: skip when there is nothing to fetch
const profile = useQuery(
api.users.getProfile,
selectedId ? { userId: selectedId } : "skip",
);
const profile = useQuery(api.users.getProfile, selectedId ? { userId: selectedId } : "skip");
```
### 4. Isolate frequently-updated fields into separate documents
+2 -8
View File
@@ -143,9 +143,7 @@ Create the `ConvexReactClient` at module scope, not inside a component:
```tsx
// Bad: re-creates the client on every render
function App() {
const convex = new ConvexReactClient(
import.meta.env.VITE_CONVEX_URL as string,
);
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
return <ConvexProvider client={convex}>...</ConvexProvider>;
}
@@ -196,11 +194,7 @@ export function ConvexClientProvider({ children }: { children: ReactNode }) {
// app/layout.tsx
import { ConvexClientProvider } from "./ConvexClientProvider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
+1 -3
View File
@@ -101,9 +101,7 @@ export const getMyProfile = query({
return await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier),
)
.withIndex("by_tokenIdentifier", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier))
.unique();
},
});
+3
View File
@@ -29,6 +29,9 @@ jobs:
- name: Audit dependencies
run: bun audit
- name: Format
run: bun run format:check
- name: Lint
run: bun run lint
+36 -36
View File
@@ -1,38 +1,38 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["unicorn", "typescript", "oxc"],
"categories": {
"correctness": "error",
"perf": "error",
"suspicious": "error"
},
"rules": {
"curly": "off",
"eslint-plugin-unicorn/prefer-array-find": "off",
"eslint-plugin-unicorn/no-array-sort": "off",
"eslint/no-await-in-loop": "off",
"eslint/no-underscore-dangle": "off",
"eslint/no-new": "off",
"oxc/no-accumulating-spread": "off",
"oxc/no-async-endpoint-handlers": "off",
"oxc/no-map-spread": "off",
"typescript/no-explicit-any": "error",
"typescript/no-extraneous-class": "off",
"typescript/no-unnecessary-boolean-literal-compare": "off",
"typescript/no-unnecessary-type-assertion": "off",
"typescript/no-unsafe-type-assertion": "off",
"unicorn/consistent-function-scoping": "off",
"unicorn/require-post-message-target-origin": "off"
},
"ignorePatterns": [
".output/",
".tanstack/",
"convex/_generated/",
"coverage/",
"dist/",
"node_modules/",
"public/",
"src/routeTree.gen.ts",
"test-results/"
]
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["unicorn", "typescript", "oxc"],
"categories": {
"correctness": "error",
"perf": "error",
"suspicious": "error"
},
"rules": {
"curly": "off",
"eslint-plugin-unicorn/prefer-array-find": "off",
"eslint-plugin-unicorn/no-array-sort": "off",
"eslint/no-await-in-loop": "off",
"eslint/no-underscore-dangle": "off",
"eslint/no-new": "off",
"oxc/no-accumulating-spread": "off",
"oxc/no-async-endpoint-handlers": "off",
"oxc/no-map-spread": "off",
"typescript/no-explicit-any": "error",
"typescript/no-extraneous-class": "off",
"typescript/no-unnecessary-boolean-literal-compare": "off",
"typescript/no-unnecessary-type-assertion": "off",
"typescript/no-unsafe-type-assertion": "off",
"unicorn/consistent-function-scoping": "off",
"unicorn/require-post-message-target-origin": "off"
},
"ignorePatterns": [
".output/",
".tanstack/",
"convex/_generated/",
"coverage/",
"dist/",
"node_modules/",
"public/",
"src/routeTree.gen.ts",
"test-results/"
]
}
+2
View File
@@ -47,9 +47,11 @@
- Mock `db` objects MUST include `normalizeId: vi.fn()` for trigger wrapper compatibility.
<!-- 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 -->
+2
View File
@@ -145,7 +145,9 @@ clawhub publish <path-to-skill-directory>
## Before Submitting a PR
```bash
bun run format:check # oxfmt
bun run lint # oxlint
bun run deadcode:ci # Knip files/deps/exports
bun run test # Vitest (80% coverage threshold)
bun run build # Vite + Nitro
bun run --cwd packages/clawhub verify
+47 -41
View File
@@ -10,14 +10,14 @@ This document outlines the design rules, patterns, and guidelines for the ClawHu
ClawHub uses a strict **3-5 color palette** based on the OpenClaw brand:
| Token | Light Mode | Dark Mode | Usage |
|-------|------------|-----------|-------|
| `--accent` | `#dc2626` | `#dc2626` | Primary actions, interactive elements, emphasis |
| `--accent-deep` | `#b91c1c` | `#ef4444` | Hover states, secondary emphasis |
| `--ink` | `#0a0a0a` | `#fafafa` | Primary text |
| `--ink-soft` | `#525252` | `#a1a1a1` | Secondary text, descriptions |
| `--surface` | `#ffffff` | `#121212` | Card backgrounds, elevated surfaces |
| `--bg` | `#fafafa` | `#0a0a0a` | Page background |
| Token | Light Mode | Dark Mode | Usage |
| --------------- | ---------- | --------- | ----------------------------------------------- |
| `--accent` | `#dc2626` | `#dc2626` | Primary actions, interactive elements, emphasis |
| `--accent-deep` | `#b91c1c` | `#ef4444` | Hover states, secondary emphasis |
| `--ink` | `#0a0a0a` | `#fafafa` | Primary text |
| `--ink-soft` | `#525252` | `#a1a1a1` | Secondary text, descriptions |
| `--surface` | `#ffffff` | `#121212` | Card backgrounds, elevated surfaces |
| `--bg` | `#fafafa` | `#0a0a0a` | Page background |
### Rules
@@ -33,21 +33,21 @@ ClawHub uses a strict **3-5 color palette** based on the OpenClaw brand:
### Font Stack
```css
--font-sans: 'Geist', system-ui, sans-serif;
--font-mono: 'Geist Mono', monospace;
--font-display: 'Geist', system-ui, sans-serif;
--font-sans: "Geist", system-ui, sans-serif;
--font-mono: "Geist Mono", monospace;
--font-display: "Geist", system-ui, sans-serif;
```
### Scale
| Token | Size | Usage |
|-------|------|-------|
| `--fs-xs` | 0.75rem (12px) | Labels, badges, metadata |
| `--fs-sm` | 0.875rem (14px) | Body text, descriptions |
| `--fs-base` | 1rem (16px) | Default body text |
| `--fs-md` | 1.125rem (18px) | Subheadings |
| `--fs-lg` | 1.25rem (20px) | Section titles |
| `--fs-xl` | 1.5rem (24px) | Page headings |
| Token | Size | Usage |
| ----------- | --------------- | ------------------------ |
| `--fs-xs` | 0.75rem (12px) | Labels, badges, metadata |
| `--fs-sm` | 0.875rem (14px) | Body text, descriptions |
| `--fs-base` | 1rem (16px) | Default body text |
| `--fs-md` | 1.125rem (18px) | Subheadings |
| `--fs-lg` | 1.25rem (20px) | Section titles |
| `--fs-xl` | 1.5rem (24px) | Page headings |
### Rules
@@ -72,25 +72,24 @@ Use this hierarchy for layout decisions:
### Spacing Scale
```css
--space-1: 0.25rem /* 4px */
--space-2: 0.5rem /* 8px */
--space-3: 0.75rem /* 12px */
--space-4: 1rem /* 16px */
--space-5: 1.5rem /* 24px */
--space-6: 2rem /* 32px */
--space-1: 0.25rem /* 4px */ --space-2: 0.5rem /* 8px */ --space-3: 0.75rem /* 12px */
--space-4: 1rem /* 16px */ --space-5: 1.5rem /* 24px */ --space-6: 2rem /* 32px */;
```
### Grid Patterns
#### Auto-fit Grid (Recommended for Cards)
```css
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
```
- Automatically adjusts columns based on container width
- Prevents orphan items on partial rows
- Maintains consistent card widths
#### Fixed Grid (When exact columns needed)
```css
/* 3-column at desktop, 2 at tablet, 1 at mobile */
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -106,11 +105,11 @@ grid-template-columns: repeat(3, minmax(0, 1fr));
### Container Widths
| Size | Max Width | Usage |
|------|-----------|-------|
| Default | `--page-max` (1200px) | Standard pages |
| Narrow | `--page-narrow` (720px) | Reading content, forms |
| Wide | Full width | Dashboards, data tables |
| Size | Max Width | Usage |
| ------- | ----------------------- | ----------------------- |
| Default | `--page-max` (1200px) | Standard pages |
| Narrow | `--page-narrow` (720px) | Reading content, forms |
| Wide | Full width | Dashboards, data tables |
---
@@ -128,20 +127,22 @@ grid-template-columns: repeat(3, minmax(0, 1fr));
```
**Rules:**
- Always use `display: flex; flex-direction: column;` for consistent height
- Add `flex: 1` to content area for equal-height cards in grids
- Include hover state with `border-color` and subtle `box-shadow`
### Buttons
| Variant | Usage |
|---------|-------|
| `primary` | Main actions (Submit, Save, Download) |
| `secondary` | Alternative actions |
| `ghost` | Tertiary actions, navigation |
| `destructive` | Delete, remove, dangerous actions |
| Variant | Usage |
| ------------- | ------------------------------------- |
| `primary` | Main actions (Submit, Save, Download) |
| `secondary` | Alternative actions |
| `ghost` | Tertiary actions, navigation |
| `destructive` | Delete, remove, dangerous actions |
**Rules:**
- Always include visible focus state
- Minimum touch target: 44x44px on mobile
- Include `aria-label` when icon-only
@@ -314,17 +315,22 @@ grid-template-columns: repeat(3, minmax(0, 1fr));
```css
/* Component */
.component-name { }
.component-name {
}
/* Component modifier */
.component-name.variant { }
.component-name.variant {
}
/* Component child */
.component-name-child { }
.component-name-child {
}
/* State */
.component-name.is-active { }
.component-name[data-state="open"] { }
.component-name.is-active {
}
.component-name[data-state="open"] {
}
```
### File Organization
+1 -3
View File
@@ -35,9 +35,7 @@ export function safeTextFileResponse(params: {
const headers = mergeHeaders(
params.headers,
{
"Content-Type": contentType
? `${contentType}; charset=utf-8`
: "text/plain; charset=utf-8",
"Content-Type": contentType ? `${contentType}; charset=utf-8` : "text/plain; charset=utf-8",
"Cache-Control": "private, max-age=60",
ETag: params.sha256,
"X-Content-SHA256": params.sha256,
+1 -2
View File
@@ -207,8 +207,7 @@ describe("deriveModerationFlags", () => {
skill: {
slug: "test",
displayName: "Test",
summary:
"Malware stealer that posts to discord.gg/hook via curl | bash from bit.ly",
summary: "Malware stealer that posts to discord.gg/hook via curl | bash from bit.ly",
},
parsed: { frontmatter: {} },
files: [],
+2 -1
View File
@@ -15,7 +15,8 @@ const FLAG_RULES: Array<{ flag: string; pattern: RegExp }> = [
// not legitimate integrations that mention generic webhook support.
{
flag: "suspicious.webhook",
pattern: /(discord\.gg\/|discord\.com\/api\/webhooks|discordapp\.com\/api\/webhooks|hooks\.slack)/i,
pattern:
/(discord\.gg\/|discord\.com\/api\/webhooks|discordapp\.com\/api\/webhooks|hooks\.slack)/i,
},
// Arbitrary code execution - curl | bash is dangerous
+1 -1
View File
@@ -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";
+4 -2
View File
@@ -42,9 +42,11 @@ export async function adjustUserSkillStatsForSkillChange(
if (prevOwnerId && prevOwnerId === nextOwnerId) {
await patchUserStats(ctx, prevOwnerId, {
publishedSkills: (nextContribution?.publishedSkills ?? 0) - (prevContribution?.publishedSkills ?? 0),
publishedSkills:
(nextContribution?.publishedSkills ?? 0) - (prevContribution?.publishedSkills ?? 0),
totalStars: (nextContribution?.totalStars ?? 0) - (prevContribution?.totalStars ?? 0),
totalDownloads: (nextContribution?.totalDownloads ?? 0) - (prevContribution?.totalDownloads ?? 0),
totalDownloads:
(nextContribution?.totalDownloads ?? 0) - (prevContribution?.totalDownloads ?? 0),
});
return;
}
+13 -8
View File
@@ -285,10 +285,11 @@ describe("maintenance backfill", () => {
});
const runMutation = vi.fn().mockResolvedValue({ ok: true });
const result = await backfillUserStatsInternalHandler(
{ runQuery, runMutation } as never,
{ batchSize: 10, skillBatchSize: 50, maxBatches: 1 },
);
const result = await backfillUserStatsInternalHandler({ runQuery, runMutation } as never, {
batchSize: 10,
skillBatchSize: 50,
maxBatches: 1,
});
expect(result).toEqual({
ok: true,
@@ -299,10 +300,14 @@ describe("maintenance backfill", () => {
isDone: true,
cursor: null,
});
expect(runQuery).toHaveBeenNthCalledWith(1, internal.maintenance.getUserStatsBackfillPageInternal, {
cursor: undefined,
batchSize: 10,
});
expect(runQuery).toHaveBeenNthCalledWith(
1,
internal.maintenance.getUserStatsBackfillPageInternal,
{
cursor: undefined,
batchSize: 10,
},
);
expect(runQuery).toHaveBeenNthCalledWith(
2,
internal.maintenance.getUserOwnedSkillsBackfillPageInternal,
+1 -2
View File
@@ -145,8 +145,7 @@ export async function buildRescanState(
maxRequests: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
requestCount,
remainingRequests: Math.max(0, MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE - requestCount),
canRequest:
requestCount < MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE && inProgressRequest === null,
canRequest: requestCount < MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE && inProgressRequest === null,
inProgressRequest: serializeRescanRequest(inProgressRequest),
latestRequest: serializeRescanRequest(requests[0] ?? null),
};
+27 -25
View File
@@ -583,33 +583,35 @@ function makeInsertReleaseCtx(
indexName: string,
buildQuery?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
if (indexName === "by_package") {
if (indexName === "by_package") {
return {
collect: vi.fn().mockResolvedValue(priorReleases),
};
}
if (indexName === "by_package_version") {
const filters = new Map<string, unknown>();
const query = {
eq(field: string, value: unknown) {
filters.set(field, value);
return query;
},
};
buildQuery?.(query);
return {
unique: vi
.fn()
.mockResolvedValue(
priorReleases.find(
(release) =>
release.packageId === filters.get("packageId") &&
release.version === filters.get("version"),
) ?? null,
),
};
}
return {
collect: vi.fn().mockResolvedValue(priorReleases),
unique: vi.fn().mockResolvedValue(null),
};
}
if (indexName === "by_package_version") {
const filters = new Map<string, unknown>();
const query = {
eq(field: string, value: unknown) {
filters.set(field, value);
return query;
},
};
buildQuery?.(query);
return {
unique: vi.fn().mockResolvedValue(
priorReleases.find(
(release) =>
release.packageId === filters.get("packageId") &&
release.version === filters.get("version"),
) ?? null,
),
};
}
return {
unique: vi.fn().mockResolvedValue(null),
};
},
),
};
+9 -10
View File
@@ -1,18 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
dispatchPackageRescanInternal,
requestRescan as requestPackageRescan,
} from "./packages";
import {
dispatchSkillRescanInternal,
getRescanState as getSkillRescanState,
requestRescan as requestSkillRescan,
} from "./skills";
import { requireUser } from "./lib/access";
import {
finalizeInProgressRescanRequestsForTarget,
MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
} from "./model/rescans/policy";
import { dispatchPackageRescanInternal, requestRescan as requestPackageRescan } from "./packages";
import {
dispatchSkillRescanInternal,
getRescanState as getSkillRescanState,
requestRescan as requestSkillRescan,
} from "./skills";
vi.mock("./lib/access", () => ({
requireUser: vi.fn(),
@@ -172,7 +169,9 @@ function createDb(options?: {
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
const matched = requests
.filter((request) => matches(request as unknown as Record<string, unknown>, constraints))
.filter((request) =>
matches(request as unknown as Record<string, unknown>, constraints),
)
.sort((a, b) => b.createdAt - a.createdAt);
return {
order: () => ({
+6 -18
View File
@@ -257,9 +257,7 @@ function buildDb(skill: SkillDoc, captured: Captured) {
return {
withIndex: (
name: string,
build:
| ((q: { eq: (field: string, value: string) => unknown }) => unknown)
| undefined,
build: ((q: { eq: (field: string, value: string) => unknown }) => unknown) | undefined,
) => {
if (name !== "by_version") {
throw new Error(`unexpected skillEmbeddings index ${name}`);
@@ -328,8 +326,7 @@ function buildDb(skill: SkillDoc, captured: Captured) {
// convex-helpers `triggers` calls innerDb.patch(tableName, id, value)
// for tables with registered triggers (e.g. "skills"); otherwise it
// falls back to innerDb.patch(id, value).
const [id, value] =
arg2 !== undefined ? [arg1 as string, arg2] : [arg0 as string, arg1];
const [id, value] = arg2 !== undefined ? [arg1 as string, arg2] : [arg0 as string, arg1];
captured.allPatches.push({
id: id,
@@ -474,9 +471,7 @@ describe("skills.insertVersion latest-tag protection", () => {
expect(finalPatch.capabilityTags).toEqual(["cap-v2"]);
// `tags.latest` still points to the previous version.
expect(finalPatch.tags).toEqual(
expect.objectContaining({ latest: PREV_LATEST_VERSION_ID }),
);
expect(finalPatch.tags).toEqual(expect.objectContaining({ latest: PREV_LATEST_VERSION_ID }));
// versions counter still increments on every publish, regardless of version order.
expect(finalPatch.stats).toMatchObject({ versions: 2 });
@@ -486,10 +481,7 @@ describe("skills.insertVersion latest-tag protection", () => {
const skill = buildExistingSkill();
const { ctx, captured } = buildCtx(skill);
await insertVersionHandler(
ctx as never,
buildPublishArgs({ version: "1.0.1" }) as never,
);
await insertVersionHandler(ctx as never, buildPublishArgs({ version: "1.0.1" }) as never);
// New version embedding is NOT marked latest.
expect(captured.embeddingInserts).toHaveLength(1);
@@ -566,9 +558,7 @@ describe("skills.insertVersion latest-tag protection", () => {
const finalPatch = captured.skillPatches.at(-1) as Record<string, unknown>;
expect(finalPatch.latestVersionId).toBe(PREV_LATEST_VERSION_ID);
expect(finalPatch.tags).toEqual(
expect.objectContaining({ latest: PREV_LATEST_VERSION_ID }),
);
expect(finalPatch.tags).toEqual(expect.objectContaining({ latest: PREV_LATEST_VERSION_ID }));
// The case-variant tag must not leak into the stored tag map either.
const tags = finalPatch.tags as Record<string, string>;
expect(tags.LaTeSt).toBeUndefined();
@@ -685,9 +675,7 @@ describe("skills.insertVersion latest-tag protection", () => {
const finalPatch = captured.skillPatches.at(-1) as Record<string, unknown>;
expect(finalPatch.latestVersionId).toBe(NEW_VERSION_ID);
expect(finalPatch.latestVersionSummary).toMatchObject({ version: "1.0.0" });
expect(finalPatch.tags).toEqual(
expect.objectContaining({ latest: NEW_VERSION_ID }),
);
expect(finalPatch.tags).toEqual(expect.objectContaining({ latest: NEW_VERSION_ID }));
expect(captured.embeddingInserts[0]).toMatchObject({ isLatest: true });
});
+1 -1
View File
@@ -5,11 +5,11 @@ vi.mock("@convex-dev/auth/server", () => ({
authTables: {},
}));
import { MODERATION_ENGINE_VERSION } from "./lib/moderationReasonCodes";
import {
getActiveSkillBatchForStaticScanBackfillInternal,
getPendingScanSkillsInternal,
} from "./skills";
import { MODERATION_ENGINE_VERSION } from "./lib/moderationReasonCodes";
type PendingScanResult = Array<{
skillId: string;
+2 -1
View File
@@ -10,7 +10,8 @@ const insertVersionHandler = (insertVersion as unknown as WrappedHandler<Record<
const getSoulBySlugInternalHandler = (
getSoulBySlugInternal as unknown as WrappedHandler<{ slug: string }>
)._handler;
const listHandler = (list as unknown as WrappedHandler<{ ownerUserId?: string; limit?: number }>)._handler;
const listHandler = (list as unknown as WrappedHandler<{ ownerUserId?: string; limit?: number }>)
._handler;
describe("souls.insertVersion", () => {
it("throws a soul-specific ownership error for non-owners", async () => {
+1 -1
View File
@@ -2,8 +2,8 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isStarred } from "./stars";
import { isStarred as isSoulStarred } from "./soulStars";
import { isStarred } from "./stars";
vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
+15 -9
View File
@@ -215,7 +215,7 @@ describe("reconcileSkillStarCounts", () => {
// it should NOT trigger a patch based on the star count alone.
const skill = {
_id: "skills:1",
statsStars: 5, // canonical value — correct
statsStars: 5, // canonical value — correct
stats: { stars: 99, comments: 0 }, // legacy value — stale, but not reconcile's concern
};
@@ -249,7 +249,7 @@ describe("reconcileSkillStarCounts", () => {
it("patches both statsStars and stats.stars when canonical value drifts from actual count", async () => {
const skill = {
_id: "skills:1",
statsStars: 10, // canonical value — out of sync with actual
statsStars: 10, // canonical value — out of sync with actual
stats: { stars: 10, comments: 0 },
};
@@ -259,10 +259,13 @@ describe("reconcileSkillStarCounts", () => {
expect(result.scanned).toBe(1);
expect(result.patched).toBe(1);
expect(patch).toHaveBeenCalledWith("skills:1", expect.objectContaining({
statsStars: 7,
stats: expect.objectContaining({ stars: 7 }),
}));
expect(patch).toHaveBeenCalledWith(
"skills:1",
expect.objectContaining({
statsStars: 7,
stats: expect.objectContaining({ stars: 7 }),
}),
);
});
it("patches when comment count drifts even if star count is correct", async () => {
@@ -278,9 +281,12 @@ describe("reconcileSkillStarCounts", () => {
expect(result.scanned).toBe(1);
expect(result.patched).toBe(1);
expect(patch).toHaveBeenCalledWith("skills:1", expect.objectContaining({
stats: expect.objectContaining({ comments: 3 }),
}));
expect(patch).toHaveBeenCalledWith(
"skills:1",
expect.objectContaining({
stats: expect.objectContaining({ comments: 3 }),
}),
);
});
it("skips soft-deleted skills", async () => {
+6 -8
View File
@@ -3,10 +3,7 @@ import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
// Only run in mobile projects — skip on desktop
test.beforeEach(({}, testInfo) => {
test.skip(
!testInfo.project.name.includes("mobile"),
"mobile-only test",
);
test.skip(!testInfo.project.name.includes("mobile"), "mobile-only test");
});
test("browse page has no horizontal overflow on mobile", async ({ page }) => {
@@ -75,12 +72,13 @@ test("skill detail page has no horizontal overflow on mobile", async ({ page, re
};
const ownerHandle = payload.owner?.handle?.trim();
const slug = payload.skill?.slug?.trim();
test.skip(!ownerHandle || !slug || !payload.skill?.displayName, "fixture missing owner handle, slug, or displayName");
test.skip(
!ownerHandle || !slug || !payload.skill?.displayName,
"fixture missing owner handle, slug, or displayName",
);
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: payload.skill!.displayName! }),
).toBeVisible();
await expect(page.getByRole("heading", { name: payload.skill!.displayName! })).toBeVisible();
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
+57 -57
View File
@@ -1,59 +1,59 @@
{
"name": "clawhub",
"version": "0.12.0",
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
"homepage": "https://clawhub.ai",
"bugs": {
"url": "https://github.com/openclaw/clawhub/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/clawhub.git",
"directory": "packages/clawhub"
},
"bin": {
"clawdhub": "bin/clawdhub.js",
"clawhub": "bin/clawdhub.js"
},
"files": [
"bin",
"dist",
"README.md",
"LICENSE"
],
"type": "module",
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "node ./scripts/build.mjs",
"dev": "node --enable-source-maps dist/cli.js",
"prepublishOnly": "npm run build",
"test": "bun run test:src",
"test:artifact": "bun run build && vitest run -c vitest.artifact.config.ts",
"test:src": "vitest run -c vitest.config.ts",
"verify": "bun run test:src && bun run verify:build && bun run test:artifact",
"verify:build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@clack/prompts": "^1.3.0",
"arktype": "^2.2.0",
"commander": "^14.0.3",
"fflate": "^0.8.2",
"ignore": "^7.0.5",
"json5": "^2.2.3",
"mime": "^4.1.0",
"ora": "^9.4.0",
"p-retry": "8.0.0",
"semver": "^7.7.4",
"undici": "7.25.0"
},
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "6.0.3"
},
"engines": {
"node": ">=20"
}
"name": "clawhub",
"version": "0.12.0",
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
"homepage": "https://clawhub.ai",
"bugs": {
"url": "https://github.com/openclaw/clawhub/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/clawhub.git",
"directory": "packages/clawhub"
},
"bin": {
"clawdhub": "bin/clawdhub.js",
"clawhub": "bin/clawdhub.js"
},
"files": [
"bin",
"dist",
"README.md",
"LICENSE"
],
"type": "module",
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "node ./scripts/build.mjs",
"dev": "node --enable-source-maps dist/cli.js",
"prepublishOnly": "npm run build",
"test": "bun run test:src",
"test:artifact": "bun run build && vitest run -c vitest.artifact.config.ts",
"test:src": "vitest run -c vitest.config.ts",
"verify": "bun run test:src && bun run verify:build && bun run test:artifact",
"verify:build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@clack/prompts": "^1.3.0",
"arktype": "^2.2.0",
"commander": "^14.0.3",
"fflate": "^0.8.2",
"ignore": "^7.0.5",
"json5": "^2.2.3",
"mime": "^4.1.0",
"ora": "^9.4.0",
"p-retry": "8.0.0",
"semver": "^7.7.4",
"undici": "7.25.0"
},
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "6.0.3"
},
"engines": {
"node": ">=20"
}
}
+36 -36
View File
@@ -1,38 +1,38 @@
{
"name": "clawhub-schema",
"version": "0.0.2",
"private": true,
"files": [
"dist",
"README.md"
],
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./licenseConstants": {
"types": "./dist/licenseConstants.d.ts",
"default": "./dist/licenseConstants.js"
},
"./routes": {
"types": "./dist/routes.d.ts",
"default": "./dist/routes.js"
},
"./textFiles": {
"types": "./dist/textFiles.d.ts",
"default": "./dist/textFiles.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"prepublishOnly": "npm run build"
},
"dependencies": {
"arktype": "^2.2.0"
},
"devDependencies": {
"typescript": "6.0.3"
}
"name": "clawhub-schema",
"version": "0.0.2",
"private": true,
"files": [
"dist",
"README.md"
],
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./licenseConstants": {
"types": "./dist/licenseConstants.d.ts",
"default": "./dist/licenseConstants.js"
},
"./routes": {
"types": "./dist/routes.d.ts",
"default": "./dist/routes.js"
},
"./textFiles": {
"types": "./dist/textFiles.d.ts",
"default": "./dist/textFiles.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"prepublishOnly": "npm run build"
},
"dependencies": {
"arktype": "^2.2.0"
},
"devDependencies": {
"typescript": "6.0.3"
}
}
+14 -9
View File
@@ -35,13 +35,14 @@ const SECRET_PATTERNS = [
];
function getStagedPaths() {
const output = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"], {
encoding: "buffer",
});
return output
.toString("utf8")
.split("\0")
.filter(Boolean);
const output = execFileSync(
"git",
["diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z"],
{
encoding: "buffer",
},
);
return output.toString("utf8").split("\0").filter(Boolean);
}
function isAllowedExamplePath(path) {
@@ -49,7 +50,9 @@ function isAllowedExamplePath(path) {
}
function isDisallowedPath(path) {
return DISALLOWED_PATH_PATTERNS.some((pattern) => pattern.test(path)) && !isAllowedExamplePath(path);
return (
DISALLOWED_PATH_PATTERNS.some((pattern) => pattern.test(path)) && !isAllowedExamplePath(path)
);
}
function getStagedFileContent(path) {
@@ -100,7 +103,9 @@ if (findings.length === 0) {
}
console.error("Secret scan blocked this commit.");
console.error("Remove the secret, move it to local env/config, or add `secret-scan: allow` next to an intentional test fixture.");
console.error(
"Remove the secret, move it to local env/config, or add `secret-scan: allow` next to an intentional test fixture.",
);
console.error("");
for (const finding of findings) {
console.error(`- ${finding.path}: ${finding.reason}`);
+6 -2
View File
@@ -25,10 +25,14 @@ const resvgWasmSource = await resolveExistingPath(
nodeModuleCandidates("@resvg/resvg-wasm/index_bg.wasm"),
);
const bricolage800Source = await resolveExistingPath(
nodeModuleCandidates("@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2"),
nodeModuleCandidates(
"@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2",
),
);
const bricolage500Source = await resolveExistingPath(
nodeModuleCandidates("@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2"),
nodeModuleCandidates(
"@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2",
),
);
const ibmPlex500Source = await resolveExistingPath(
nodeModuleCandidates("@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2"),
@@ -62,11 +62,7 @@ describe("clawhub rescan auto-response classifier", () => {
"Skill flagged as suspicious",
"This skill should be clean now. Please tell me how to clear the flag.",
],
[
1903,
"supicious flag on plugin",
"The plugin is incorrectly flagged and needs a fresh scan.",
],
[1903, "supicious flag on plugin", "The plugin is incorrectly flagged and needs a fresh scan."],
])("matches explicit rescan/re-evaluation request #%s", (number, title, body) => {
const result = classifyRescanRequest(issue({ number, title, body }));
+12 -4
View File
@@ -33,9 +33,13 @@ const hookSnippet = `
`;
if (!existsSync(preCommitPath)) {
writeFileSync(preCommitPath, `#!/bin/sh
writeFileSync(
preCommitPath,
`#!/bin/sh
set -eu${hookSnippet}
`, "utf8");
`,
"utf8",
);
chmodSync(preCommitPath, 0o755);
process.exit(0);
}
@@ -45,6 +49,10 @@ if (existing.includes("pre-commit-secret-scan")) {
process.exit(0);
}
writeFileSync(preCommitPath, `${existing.trimEnd()}${hookSnippet}
`, "utf8");
writeFileSync(
preCommitPath,
`${existing.trimEnd()}${hookSnippet}
`,
"utf8",
);
chmodSync(preCommitPath, 0o755);
+38 -38
View File
@@ -2,45 +2,45 @@ import { describe, expect, it } from "vitest";
import { parseConvexJson, parseConvexJsonMatching } from "./convexOutput";
describe("Convex CLI output parsing", () => {
it("parses a JSON object with nested braces and trailing CLI text", () => {
expect(
parseConvexJson(
[
"Running function...",
'{ "page": [{ "text": "brace } inside string", "items": [1, 2] }], "isDone": true }',
"Function ran successfully.",
].join("\n"),
),
).toEqual({
page: [{ text: "brace } inside string", items: [1, 2] }],
isDone: true,
});
});
it("parses a JSON object with nested braces and trailing CLI text", () => {
expect(
parseConvexJson(
[
"Running function...",
'{ "page": [{ "text": "brace } inside string", "items": [1, 2] }], "isDone": true }',
"Function ran successfully.",
].join("\n"),
),
).toEqual({
page: [{ text: "brace } inside string", items: [1, 2] }],
isDone: true,
});
});
it("skips incomplete JSON-looking prefixes", () => {
expect(parseConvexJson("partial { nope\n[1, 2, 3]\n")).toEqual([1, 2, 3]);
});
it("skips incomplete JSON-looking prefixes", () => {
expect(parseConvexJson("partial { nope\n[1, 2, 3]\n")).toEqual([1, 2, 3]);
});
it("can require the expected response envelope", () => {
const output = [
'{"artifactSha256":"nested-artifact"}',
'{"continueCursor":"cursor","exportMode":"public","isDone":false,"page":[]}',
].join("\n");
it("can require the expected response envelope", () => {
const output = [
'{"artifactSha256":"nested-artifact"}',
'{"continueCursor":"cursor","exportMode":"public","isDone":false,"page":[]}',
].join("\n");
expect(
parseConvexJsonMatching(
output,
(value): value is { continueCursor: string } =>
typeof value === "object" &&
value !== null &&
"continueCursor" in value &&
"page" in value,
),
).toEqual({
continueCursor: "cursor",
exportMode: "public",
isDone: false,
page: [],
});
});
expect(
parseConvexJsonMatching(
output,
(value): value is { continueCursor: string } =>
typeof value === "object" &&
value !== null &&
"continueCursor" in value &&
"page" in value,
),
).toEqual({
continueCursor: "cursor",
exportMode: "public",
isDone: false,
page: [],
});
});
});
+46 -46
View File
@@ -1,65 +1,65 @@
export function parseConvexJson(output: string): unknown {
return parseConvexJsonMatching(output, isJsonValue);
return parseConvexJsonMatching(output, isJsonValue);
}
export function parseConvexJsonMatching<T>(
output: string,
validate: (value: unknown) => value is T,
output: string,
validate: (value: unknown) => value is T,
): T {
for (let index = 0; index < output.length; index += 1) {
const char = output[index];
if (char !== "{" && char !== "[") continue;
for (let index = 0; index < output.length; index += 1) {
const char = output[index];
if (char !== "{" && char !== "[") continue;
const end = findJsonValueEnd(output, index);
if (end === null) continue;
const end = findJsonValueEnd(output, index);
if (end === null) continue;
try {
const parsed: unknown = JSON.parse(output.slice(index, end));
if (validate(parsed)) return parsed;
} catch {
continue;
}
}
try {
const parsed: unknown = JSON.parse(output.slice(index, end));
if (validate(parsed)) return parsed;
} catch {
continue;
}
}
throw new Error(`Unable to parse matching Convex JSON output (${output.length} bytes)`);
throw new Error(`Unable to parse matching Convex JSON output (${output.length} bytes)`);
}
function isJsonValue(value: unknown): value is unknown {
return value !== undefined;
return value !== undefined;
}
function findJsonValueEnd(output: string, start: number) {
const first = output[start];
const stack = first === "{" ? ["}"] : first === "[" ? ["]"] : [];
let inString = false;
let escaped = false;
const first = output[start];
const stack = first === "{" ? ["}"] : first === "[" ? ["]"] : [];
let inString = false;
let escaped = false;
for (let index = start + 1; index < output.length; index += 1) {
const char = output[index];
for (let index = start + 1; index < output.length; index += 1) {
const char = output[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
} else if (char === "{") {
stack.push("}");
} else if (char === "[") {
stack.push("]");
} else if (char === "}" || char === "]") {
if (stack.at(-1) !== char) return null;
stack.pop();
if (stack.length === 0) return index + 1;
}
}
if (char === '"') {
inString = true;
} else if (char === "{") {
stack.push("}");
} else if (char === "[") {
stack.push("]");
} else if (char === "}" || char === "]") {
if (stack.at(-1) !== char) return null;
stack.pop();
if (stack.length === 0) return index + 1;
}
}
return null;
return null;
}
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -2,19 +2,19 @@ import { describe, expect, it } from "vitest";
import { reserveExportInputs } from "./exportLimit";
describe("security dataset export limits", () => {
it("reserves rows synchronously so concurrent shards cannot exceed the global limit", () => {
const state = { sourceArtifacts: 0 };
it("reserves rows synchronously so concurrent shards cannot exceed the global limit", () => {
const state = { sourceArtifacts: 0 };
expect(reserveExportInputs(["a", "b", "c"], state, 5)).toEqual(["a", "b", "c"]);
expect(reserveExportInputs(["d", "e", "f"], state, 5)).toEqual(["d", "e"]);
expect(reserveExportInputs(["g"], state, 5)).toEqual([]);
expect(state.sourceArtifacts).toBe(5);
});
expect(reserveExportInputs(["a", "b", "c"], state, 5)).toEqual(["a", "b", "c"]);
expect(reserveExportInputs(["d", "e", "f"], state, 5)).toEqual(["d", "e"]);
expect(reserveExportInputs(["g"], state, 5)).toEqual([]);
expect(state.sourceArtifacts).toBe(5);
});
it("counts all rows when no limit is set", () => {
const state = { sourceArtifacts: 2 };
it("counts all rows when no limit is set", () => {
const state = { sourceArtifacts: 2 };
expect(reserveExportInputs(["a", "b"], state, null)).toEqual(["a", "b"]);
expect(state.sourceArtifacts).toBe(4);
});
expect(reserveExportInputs(["a", "b"], state, null)).toEqual(["a", "b"]);
expect(state.sourceArtifacts).toBe(4);
});
});
+5 -5
View File
@@ -1,10 +1,10 @@
export type ExportLimitState = {
sourceArtifacts: number;
sourceArtifacts: number;
};
export function reserveExportInputs<T>(inputs: T[], state: ExportLimitState, limit: number | null) {
const remaining = limit === null ? inputs.length : Math.max(0, limit - state.sourceArtifacts);
const reserved = inputs.slice(0, remaining);
state.sourceArtifacts += reserved.length;
return reserved;
const remaining = limit === null ? inputs.length : Math.max(0, limit - state.sourceArtifacts);
const reserved = inputs.slice(0, remaining);
state.sourceArtifacts += reserved.length;
return reserved;
}
+39 -39
View File
@@ -2,45 +2,45 @@ import { describe, expect, it } from "vitest";
import { buildSecurityDatasetManifest, inferConvexProject } from "./manifest";
describe("security dataset manifest", () => {
it("includes dataset lineage fields from the spec", () => {
const manifest = buildSecurityDatasetManifest({
snapshotId: "clawhub-prod-20260430T000000Z-abcdef12",
createdAt: "2026-04-30T00:00:00.000Z",
repoGitSha: "abcdef123456",
convexDeployment: "amantus:clawdhub:prod",
exportMode: "public",
pageSize: 50,
concurrency: 6,
shards: 12,
shardCount: 24,
rowCounts: {
sourceArtifacts: 1,
artifacts: 1,
scanResults: 2,
staticFindings: 3,
labels: 4,
splits: 1,
},
scannerVersions: ["v2.4.2"],
modelNames: ["gpt-5-mini"],
redactionPolicyVersion: "public-signals-v1",
sourceTables: ["skillVersions", "packageReleases"],
timeWindow: { createdAtGte: 1777507200000, createdAtLt: 1780185600000 },
});
it("includes dataset lineage fields from the spec", () => {
const manifest = buildSecurityDatasetManifest({
snapshotId: "clawhub-prod-20260430T000000Z-abcdef12",
createdAt: "2026-04-30T00:00:00.000Z",
repoGitSha: "abcdef123456",
convexDeployment: "amantus:clawdhub:prod",
exportMode: "public",
pageSize: 50,
concurrency: 6,
shards: 12,
shardCount: 24,
rowCounts: {
sourceArtifacts: 1,
artifacts: 1,
scanResults: 2,
staticFindings: 3,
labels: 4,
splits: 1,
},
scannerVersions: ["v2.4.2"],
modelNames: ["gpt-5-mini"],
redactionPolicyVersion: "public-signals-v1",
sourceTables: ["skillVersions", "packageReleases"],
timeWindow: { createdAtGte: 1777507200000, createdAtLt: 1780185600000 },
});
expect(manifest).toMatchObject({
repo_git_sha: "abcdef123456",
source_commit: "abcdef123456",
convex_deployment: "amantus:clawdhub:prod",
convex_project: "clawdhub",
created_time_window: {
created_at_gte: 1777507200000,
created_at_lt: 1780185600000,
},
});
});
expect(manifest).toMatchObject({
repo_git_sha: "abcdef123456",
source_commit: "abcdef123456",
convex_deployment: "amantus:clawdhub:prod",
convex_project: "clawdhub",
created_time_window: {
created_at_gte: 1777507200000,
created_at_lt: 1780185600000,
},
});
});
it("leaves the project null when Convex only provides a deployment name", () => {
expect(inferConvexProject("wry-manatee-359")).toBeNull();
});
it("leaves the project null when Convex only provides a deployment name", () => {
expect(inferConvexProject("wry-manatee-359")).toBeNull();
});
});
+57 -57
View File
@@ -1,65 +1,65 @@
export type SnapshotManifestInput = {
snapshotId: string;
createdAt: string;
repoGitSha: string;
convexDeployment: string;
exportMode: "public";
pageSize: number;
concurrency: number;
shards: number;
shardCount: number;
rowCounts: {
sourceArtifacts: number;
artifacts: number;
scanResults: number;
staticFindings: number;
labels: number;
splits: number;
};
scannerVersions: string[];
modelNames: string[];
redactionPolicyVersion: string;
sourceTables: string[];
timeWindow?: {
createdAtGte: number | null;
createdAtLt: number | null;
};
snapshotId: string;
createdAt: string;
repoGitSha: string;
convexDeployment: string;
exportMode: "public";
pageSize: number;
concurrency: number;
shards: number;
shardCount: number;
rowCounts: {
sourceArtifacts: number;
artifacts: number;
scanResults: number;
staticFindings: number;
labels: number;
splits: number;
};
scannerVersions: string[];
modelNames: string[];
redactionPolicyVersion: string;
sourceTables: string[];
timeWindow?: {
createdAtGte: number | null;
createdAtLt: number | null;
};
};
export function buildSecurityDatasetManifest(input: SnapshotManifestInput) {
return {
snapshot_id: input.snapshotId,
created_at: input.createdAt,
repo_git_sha: input.repoGitSha,
convex_deployment: input.convexDeployment,
convex_project: inferConvexProject(input.convexDeployment),
export_mode: input.exportMode,
page_size: input.pageSize,
concurrency: input.concurrency,
shards: input.shards,
shard_count: input.shardCount,
row_counts: {
source_artifacts: input.rowCounts.sourceArtifacts,
artifacts: input.rowCounts.artifacts,
scan_results: input.rowCounts.scanResults,
static_findings: input.rowCounts.staticFindings,
labels: input.rowCounts.labels,
splits: input.rowCounts.splits,
},
scanner_versions: input.scannerVersions,
model_names: input.modelNames,
redaction_policy_version: input.redactionPolicyVersion,
source_tables: input.sourceTables,
source_commit: input.repoGitSha,
created_time_window: {
created_at_gte: input.timeWindow?.createdAtGte ?? null,
created_at_lt: input.timeWindow?.createdAtLt ?? null,
},
};
return {
snapshot_id: input.snapshotId,
created_at: input.createdAt,
repo_git_sha: input.repoGitSha,
convex_deployment: input.convexDeployment,
convex_project: inferConvexProject(input.convexDeployment),
export_mode: input.exportMode,
page_size: input.pageSize,
concurrency: input.concurrency,
shards: input.shards,
shard_count: input.shardCount,
row_counts: {
source_artifacts: input.rowCounts.sourceArtifacts,
artifacts: input.rowCounts.artifacts,
scan_results: input.rowCounts.scanResults,
static_findings: input.rowCounts.staticFindings,
labels: input.rowCounts.labels,
splits: input.rowCounts.splits,
},
scanner_versions: input.scannerVersions,
model_names: input.modelNames,
redaction_policy_version: input.redactionPolicyVersion,
source_tables: input.sourceTables,
source_commit: input.repoGitSha,
created_time_window: {
created_at_gte: input.timeWindow?.createdAtGte ?? null,
created_at_lt: input.timeWindow?.createdAtLt ?? null,
},
};
}
export function inferConvexProject(convexDeployment: string) {
const parts = convexDeployment.split(":");
if (parts.length >= 3 && parts[1]) return parts[1];
return null;
const parts = convexDeployment.split(":");
if (parts.length >= 3 && parts[1]) return parts[1];
return null;
}
+27 -27
View File
@@ -2,34 +2,34 @@ import { describe, expect, it } from "vitest";
import { assertCreatedTimeWindow, clampCreatedBounds, parseCreatedTimestamp } from "./timeWindow";
describe("security dataset time windows", () => {
it("parses millisecond timestamps and ISO dates", () => {
expect(parseCreatedTimestamp("1777507200000", "--created-after")).toBe(1777507200000);
expect(parseCreatedTimestamp("2026-04-30T00:00:00.000Z", "--created-before")).toBe(
1777507200000,
);
});
it("parses millisecond timestamps and ISO dates", () => {
expect(parseCreatedTimestamp("1777507200000", "--created-after")).toBe(1777507200000);
expect(parseCreatedTimestamp("2026-04-30T00:00:00.000Z", "--created-before")).toBe(
1777507200000,
);
});
it("rejects inverted windows", () => {
expect(() => assertCreatedTimeWindow({ createdAtGte: 20, createdAtLt: 10 })).toThrowError(
"--created-after must be earlier than --created-before.",
);
});
it("rejects inverted windows", () => {
expect(() => assertCreatedTimeWindow({ createdAtGte: 20, createdAtLt: 10 })).toThrowError(
"--created-after must be earlier than --created-before.",
);
});
it("clamps source bounds to the requested window", () => {
expect(
clampCreatedBounds(
{ sourceKind: "skill", minCreatedAt: 10, maxCreatedAt: 30 },
{ createdAtGte: 15, createdAtLt: 25 },
),
).toEqual({ sourceKind: "skill", minCreatedAt: 15, maxCreatedAt: 24 });
});
it("clamps source bounds to the requested window", () => {
expect(
clampCreatedBounds(
{ sourceKind: "skill", minCreatedAt: 10, maxCreatedAt: 30 },
{ createdAtGte: 15, createdAtLt: 25 },
),
).toEqual({ sourceKind: "skill", minCreatedAt: 15, maxCreatedAt: 24 });
});
it("returns empty bounds when the requested window does not overlap", () => {
expect(
clampCreatedBounds(
{ sourceKind: "package", minCreatedAt: 10, maxCreatedAt: 30 },
{ createdAtGte: 40, createdAtLt: null },
),
).toEqual({ sourceKind: "package", minCreatedAt: null, maxCreatedAt: null });
});
it("returns empty bounds when the requested window does not overlap", () => {
expect(
clampCreatedBounds(
{ sourceKind: "package", minCreatedAt: 10, maxCreatedAt: 30 },
{ createdAtGte: 40, createdAtLt: null },
),
).toEqual({ sourceKind: "package", minCreatedAt: null, maxCreatedAt: null });
});
});
+35 -35
View File
@@ -1,57 +1,57 @@
export type CreatedTimeWindow = {
createdAtGte: number | null;
createdAtLt: number | null;
createdAtGte: number | null;
createdAtLt: number | null;
};
export type CreatedBounds<TSourceKind extends string = string> = {
sourceKind: TSourceKind;
minCreatedAt: number | null;
maxCreatedAt: number | null;
sourceKind: TSourceKind;
minCreatedAt: number | null;
maxCreatedAt: number | null;
};
export function emptyCreatedTimeWindow(): CreatedTimeWindow {
return { createdAtGte: null, createdAtLt: null };
return { createdAtGte: null, createdAtLt: null };
}
export function parseCreatedTimestamp(value: string, flag: string) {
if (/^\d+$/.test(value)) {
const parsed = Number.parseInt(value, 10);
if (Number.isSafeInteger(parsed)) return parsed;
}
if (/^\d+$/.test(value)) {
const parsed = Number.parseInt(value, 10);
if (Number.isSafeInteger(parsed)) return parsed;
}
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
throw new Error(`Expected ${flag} to be a millisecond timestamp or ISO date.`);
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
throw new Error(`Expected ${flag} to be a millisecond timestamp or ISO date.`);
}
export function assertCreatedTimeWindow(window: CreatedTimeWindow) {
if (
window.createdAtGte !== null &&
window.createdAtLt !== null &&
window.createdAtGte >= window.createdAtLt
) {
throw new Error("--created-after must be earlier than --created-before.");
}
if (
window.createdAtGte !== null &&
window.createdAtLt !== null &&
window.createdAtGte >= window.createdAtLt
) {
throw new Error("--created-after must be earlier than --created-before.");
}
}
export function clampCreatedBounds<TSourceKind extends string>(
bounds: CreatedBounds<TSourceKind>,
window: CreatedTimeWindow,
bounds: CreatedBounds<TSourceKind>,
window: CreatedTimeWindow,
): CreatedBounds<TSourceKind> {
if (bounds.minCreatedAt === null || bounds.maxCreatedAt === null) return bounds;
if (bounds.minCreatedAt === null || bounds.maxCreatedAt === null) return bounds;
const minCreatedAt =
window.createdAtGte === null
? bounds.minCreatedAt
: Math.max(bounds.minCreatedAt, window.createdAtGte);
const maxCreatedExclusive =
window.createdAtLt === null
? bounds.maxCreatedAt + 1
: Math.min(bounds.maxCreatedAt + 1, window.createdAtLt);
const minCreatedAt =
window.createdAtGte === null
? bounds.minCreatedAt
: Math.max(bounds.minCreatedAt, window.createdAtGte);
const maxCreatedExclusive =
window.createdAtLt === null
? bounds.maxCreatedAt + 1
: Math.min(bounds.maxCreatedAt + 1, window.createdAtLt);
if (minCreatedAt >= maxCreatedExclusive) {
return { ...bounds, minCreatedAt: null, maxCreatedAt: null };
}
if (minCreatedAt >= maxCreatedExclusive) {
return { ...bounds, minCreatedAt: null, maxCreatedAt: null };
}
return { ...bounds, minCreatedAt, maxCreatedAt: maxCreatedExclusive - 1 };
return { ...bounds, minCreatedAt, maxCreatedAt: maxCreatedExclusive - 1 };
}
+1 -3
View File
@@ -26,9 +26,7 @@ describe("renderWithInlineCode", () => {
});
it("handles multiple code spans in a single string", () => {
const el = renderToContainer(
"Use `curl | sh` or `npx @latest` for setup."
);
const el = renderToContainer("Use `curl | sh` or `npx @latest` for setup.");
const codes = el.querySelectorAll("code");
expect(codes).toHaveLength(2);
expect(codes[0].textContent).toBe("curl | sh");
+3 -3
View File
@@ -49,9 +49,9 @@ async function loadRoute() {
async function runBeforeLoad(params: { owner: string; slug: string }) {
const route = await loadRoute();
const beforeLoad = route.__config.beforeLoad as ((args: {
params: { owner: string; slug: string };
}) => unknown) | undefined;
const beforeLoad = route.__config.beforeLoad as
| ((args: { params: { owner: string; slug: string } }) => unknown)
| undefined;
return beforeLoad?.({ params });
}
+3 -3
View File
@@ -252,9 +252,9 @@ describe("SkillsIndex", () => {
await vi.runAllTimersAsync();
});
const titles = Array.from(
document.querySelectorAll(".skill-list-item-name"),
).map((node) => node.textContent);
const titles = Array.from(document.querySelectorAll(".skill-list-item-name")).map(
(node) => node.textContent,
);
expect(titles[0]).toBe("Older High Score");
expect(titles[1]).toBe("Newer Low Score");
+14 -14
View File
@@ -1,8 +1,8 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { createRef, type ComponentProps } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { SkillsToolbar } from '../routes/skills/-SkillsToolbar';
import { render, screen } from "@testing-library/react";
import { createRef, type ComponentProps } from "react";
import { describe, expect, it, vi } from "vitest";
import { SkillsToolbar } from "../routes/skills/-SkillsToolbar";
function renderToolbar(overrides?: Partial<ComponentProps<typeof SkillsToolbar>>) {
return render(
@@ -28,23 +28,23 @@ function renderToolbar(overrides?: Partial<ComponentProps<typeof SkillsToolbar>>
);
}
describe('SkillsToolbar', () => {
it('keeps filter chips on a dark-mode surface', () => {
describe("SkillsToolbar", () => {
it("keeps filter chips on a dark-mode surface", () => {
renderToolbar();
const staffPicksButton = screen.getByRole('button', { name: 'Staff Picks' });
const staffPicksButton = screen.getByRole("button", { name: "Staff Picks" });
expect(staffPicksButton.className).toContain('dark:bg-[rgba(14,28,37,0.84)]');
expect(staffPicksButton.className).toContain('dark:text-[rgba(245,238,232,0.88)]');
expect(staffPicksButton.className).toContain("dark:bg-[rgba(14,28,37,0.84)]");
expect(staffPicksButton.className).toContain("dark:text-[rgba(245,238,232,0.88)]");
});
it('uses a readable active color treatment in dark mode', () => {
it("uses a readable active color treatment in dark mode", () => {
renderToolbar({ highlightedOnly: true });
const staffPicksButton = screen.getByRole('button', { name: 'Staff Picks' });
const staffPicksButton = screen.getByRole("button", { name: "Staff Picks" });
expect(staffPicksButton.getAttribute('aria-pressed')).toBe('true');
expect(staffPicksButton.className).toContain('dark:bg-[rgba(255,131,95,0.14)]');
expect(staffPicksButton.className).toContain('dark:text-[#ffd5c9]');
expect(staffPicksButton.getAttribute("aria-pressed")).toBe("true");
expect(staffPicksButton.className).toContain("dark:bg-[rgba(255,131,95,0.14)]");
expect(staffPicksButton.className).toContain("dark:text-[#ffd5c9]");
});
});
@@ -31,9 +31,7 @@ describe("DetailSecuritySummary", () => {
const button = screen.getByRole("button", { name: "Scanning" });
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(button.getAttribute("title")).toBe("A rescan is already in progress.");
expect(button.querySelector(".animate-spin")?.className).toContain(
"[animation-duration:2.4s]",
);
expect(button.querySelector(".animate-spin")?.className).toContain("[animation-duration:2.4s]");
});
it("shows staff-cleared public scan summaries as cleared", () => {
+671 -671
View File
File diff suppressed because it is too large Load Diff
+150 -150
View File
@@ -5,72 +5,72 @@ import { describe, expect, it } from "vitest";
import { MarkdownPreview } from "./MarkdownPreview";
function renderMarkdown(source: string) {
// Disable Shiki highlighting to keep the tree synchronous for assertions.
const { container } = render(<MarkdownPreview highlight={false}>{source}</MarkdownPreview>);
return container;
// Disable Shiki highlighting to keep the tree synchronous for assertions.
const { container } = render(<MarkdownPreview highlight={false}>{source}</MarkdownPreview>);
return container;
}
describe("MarkdownPreview — raw HTML passthrough", () => {
it('renders an <h1 align="center"> block as a real <h1>', () => {
const container = renderMarkdown(`<h1 align="center">Hello logo</h1>`);
const h1 = container.querySelector("h1");
expect(h1).not.toBeNull();
expect(h1?.textContent).toBe("Hello logo");
});
it('renders an <h1 align="center"> block as a real <h1>', () => {
const container = renderMarkdown(`<h1 align="center">Hello logo</h1>`);
const h1 = container.querySelector("h1");
expect(h1).not.toBeNull();
expect(h1?.textContent).toBe("Hello logo");
});
it('renders a <div align="center"> block as a real <div>', () => {
const container = renderMarkdown(`<div align="center">centered</div>`);
const div = container.querySelector('div[align="center"]');
expect(div).not.toBeNull();
expect(div?.textContent).toBe("centered");
});
it('renders a <div align="center"> block as a real <div>', () => {
const container = renderMarkdown(`<div align="center">centered</div>`);
const div = container.querySelector('div[align="center"]');
expect(div).not.toBeNull();
expect(div?.textContent).toBe("centered");
});
it("renders <picture> with <source> + <img> fallback", () => {
const container = renderMarkdown(
`<picture><source media="(prefers-color-scheme: dark)" srcset="dark.png"/><img alt="Logo" src="light.png"/></picture>`,
);
expect(container.querySelector("picture")).not.toBeNull();
expect(container.querySelector("picture source")).not.toBeNull();
const img = container.querySelector("picture img");
expect(img).not.toBeNull();
expect(img?.getAttribute("alt")).toBe("Logo");
expect(img?.getAttribute("src")).toBe("light.png");
});
it("renders <picture> with <source> + <img> fallback", () => {
const container = renderMarkdown(
`<picture><source media="(prefers-color-scheme: dark)" srcset="dark.png"/><img alt="Logo" src="light.png"/></picture>`,
);
expect(container.querySelector("picture")).not.toBeNull();
expect(container.querySelector("picture source")).not.toBeNull();
const img = container.querySelector("picture img");
expect(img).not.toBeNull();
expect(img?.getAttribute("alt")).toBe("Logo");
expect(img?.getAttribute("src")).toBe("light.png");
});
it("renders standalone <img> tags with src and alt", () => {
const container = renderMarkdown(`<img src="screenshot.png" alt="Demo screenshot"/>`);
const img = container.querySelector("img");
expect(img).not.toBeNull();
// Relative paths render as-is — only external http(s) URLs get proxied.
expect(img?.getAttribute("src")).toBe("screenshot.png");
expect(img?.getAttribute("alt")).toBe("Demo screenshot");
});
it("renders standalone <img> tags with src and alt", () => {
const container = renderMarkdown(`<img src="screenshot.png" alt="Demo screenshot"/>`);
const img = container.querySelector("img");
expect(img).not.toBeNull();
// Relative paths render as-is — only external http(s) URLs get proxied.
expect(img?.getAttribute("src")).toBe("screenshot.png");
expect(img?.getAttribute("alt")).toBe("Demo screenshot");
});
it("routes external https <img> URLs through /_vercel/image", () => {
const container = renderMarkdown(
`<img src="https://raw.githubusercontent.com/foo/bar/main/logo.png" alt="logo"/>`,
);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffoo%2Fbar%2Fmain%2Flogo.png&w=1024&q=75",
);
});
it("routes external https <img> URLs through /_vercel/image", () => {
const container = renderMarkdown(
`<img src="https://raw.githubusercontent.com/foo/bar/main/logo.png" alt="logo"/>`,
);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffoo%2Fbar%2Fmain%2Flogo.png&w=1024&q=75",
);
});
it("routes external markdown ![](url) images through /_vercel/image", () => {
const container = renderMarkdown(`![logo](https://img.shields.io/badge/x-y-blue.svg)`);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"/_vercel/image?url=https%3A%2F%2Fimg.shields.io%2Fbadge%2Fx-y-blue.svg&w=1024&q=75",
);
});
it("routes external markdown ![](url) images through /_vercel/image", () => {
const container = renderMarkdown(`![logo](https://img.shields.io/badge/x-y-blue.svg)`);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"/_vercel/image?url=https%3A%2F%2Fimg.shields.io%2Fbadge%2Fx-y-blue.svg&w=1024&q=75",
);
});
it("renders <br/> as a real line break", () => {
const container = renderMarkdown(`line one<br/>line two`);
expect(container.querySelector("br")).not.toBeNull();
});
it("renders <br/> as a real line break", () => {
const container = renderMarkdown(`line one<br/>line two`);
expect(container.querySelector("br")).not.toBeNull();
});
it("renders the Opik README banner (centered h1 + picture + img)", () => {
const opikBanner = `<h1 align="center">
it("renders the Opik README banner (centered h1 + picture + img)", () => {
const opikBanner = `<h1 align="center">
<a href="https://www.comet.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="dark.svg"/>
@@ -79,117 +79,117 @@ describe("MarkdownPreview — raw HTML passthrough", () => {
</a>
<br/>OpenClaw Opik Observability Plugin
</h1>`;
const container = renderMarkdown(opikBanner);
expect(container.querySelector("h1")).not.toBeNull();
expect(container.querySelector("picture")).not.toBeNull();
const img = container.querySelector("img");
expect(img?.getAttribute("alt")).toBe("Comet Opik logo");
// And the escaped tag must NOT be present as literal text anywhere.
expect(container.textContent ?? "").not.toContain("<picture>");
});
const container = renderMarkdown(opikBanner);
expect(container.querySelector("h1")).not.toBeNull();
expect(container.querySelector("picture")).not.toBeNull();
const img = container.querySelector("img");
expect(img?.getAttribute("alt")).toBe("Comet Opik logo");
// And the escaped tag must NOT be present as literal text anywhere.
expect(container.textContent ?? "").not.toContain("<picture>");
});
});
describe("MarkdownPreview — standard markdown still renders", () => {
it("renders ATX headings", () => {
const container = renderMarkdown(`## Why This Plugin`);
const h2 = container.querySelector("h2");
expect(h2?.textContent).toBe("Why This Plugin");
});
it("renders ATX headings", () => {
const container = renderMarkdown(`## Why This Plugin`);
const h2 = container.querySelector("h2");
expect(h2?.textContent).toBe("Why This Plugin");
});
it("renders markdown links", () => {
const container = renderMarkdown(`[Opik](https://example.com/opik)`);
const a = container.querySelector("a");
expect(a?.getAttribute("href")).toBe("https://example.com/opik");
expect(a?.textContent).toBe("Opik");
});
it("renders markdown links", () => {
const container = renderMarkdown(`[Opik](https://example.com/opik)`);
const a = container.querySelector("a");
expect(a?.getAttribute("href")).toBe("https://example.com/opik");
expect(a?.textContent).toBe("Opik");
});
it("renders inline code", () => {
const container = renderMarkdown("Use `@opik/opik-openclaw` now.");
const code = container.querySelector("code");
expect(code?.textContent).toBe("@opik/opik-openclaw");
});
it("renders inline code", () => {
const container = renderMarkdown("Use `@opik/opik-openclaw` now.");
const code = container.querySelector("code");
expect(code?.textContent).toBe("@opik/opik-openclaw");
});
it("renders unordered lists", () => {
const container = renderMarkdown(`- one\n- two\n- three`);
const items = container.querySelectorAll("li");
expect(items.length).toBe(3);
expect(items[0].textContent).toBe("one");
});
it("renders unordered lists", () => {
const container = renderMarkdown(`- one\n- two\n- three`);
const items = container.querySelectorAll("li");
expect(items.length).toBe(3);
expect(items[0].textContent).toBe("one");
});
it("renders GFM tables", () => {
const container = renderMarkdown(
["| Key | Value |", "| --- | ----- |", "| a | 1 |", "| b | 2 |"].join("\n"),
);
expect(container.querySelector("table")).not.toBeNull();
expect(container.querySelectorAll("tbody tr").length).toBe(2);
});
it("renders GFM tables", () => {
const container = renderMarkdown(
["| Key | Value |", "| --- | ----- |", "| a | 1 |", "| b | 2 |"].join("\n"),
);
expect(container.querySelector("table")).not.toBeNull();
expect(container.querySelectorAll("tbody tr").length).toBe(2);
});
it("renders fenced code blocks as <pre><code>", () => {
const container = renderMarkdown("```ts\nconst x = 1;\n```");
const code = container.querySelector("pre code");
expect(code).not.toBeNull();
expect(code?.textContent).toContain("const x = 1;");
});
it("renders fenced code blocks as <pre><code>", () => {
const container = renderMarkdown("```ts\nconst x = 1;\n```");
const code = container.querySelector("pre code");
expect(code).not.toBeNull();
expect(code?.textContent).toContain("const x = 1;");
});
});
describe("MarkdownPreview — syntax highlighting", () => {
it("shiki-highlights fenced code blocks (produces colored <span> tokens)", async () => {
const { container } = render(
<MarkdownPreview>{"```ts\nconst x: number = 1;\n```"}</MarkdownPreview>,
);
it("shiki-highlights fenced code blocks (produces colored <span> tokens)", async () => {
const { container } = render(
<MarkdownPreview>{"```ts\nconst x: number = 1;\n```"}</MarkdownPreview>,
);
await waitFor(
() => {
const pre = container.querySelector("pre");
// Shiki wraps the output in <pre class="shiki ..."> and tokens are
// <span style="color:#...">.
expect(pre?.className ?? "").toMatch(/shiki/);
const coloredSpans = container.querySelectorAll("pre span[style*='color']");
expect(coloredSpans.length).toBeGreaterThan(0);
},
{ timeout: 8000 },
);
await waitFor(
() => {
const pre = container.querySelector("pre");
// Shiki wraps the output in <pre class="shiki ..."> and tokens are
// <span style="color:#...">.
expect(pre?.className ?? "").toMatch(/shiki/);
const coloredSpans = container.querySelectorAll("pre span[style*='color']");
expect(coloredSpans.length).toBeGreaterThan(0);
},
{ timeout: 8000 },
);
// Raw code text must still be present after highlighting
expect(container.querySelector("pre")?.textContent).toContain("const x");
});
// Raw code text must still be present after highlighting
expect(container.querySelector("pre")?.textContent).toContain("const x");
});
it("leaves the highlight prop honored — highlight={false} renders plain <pre><code>", () => {
const { container } = render(
<MarkdownPreview highlight={false}>{"```ts\nconst x = 1;\n```"}</MarkdownPreview>,
);
const pre = container.querySelector("pre");
// No shiki class, no colored spans
expect(pre?.className ?? "").not.toMatch(/shiki/);
expect(container.querySelectorAll("pre span[style*='color']").length).toBe(0);
expect(pre?.textContent).toContain("const x = 1;");
});
it("leaves the highlight prop honored — highlight={false} renders plain <pre><code>", () => {
const { container } = render(
<MarkdownPreview highlight={false}>{"```ts\nconst x = 1;\n```"}</MarkdownPreview>,
);
const pre = container.querySelector("pre");
// No shiki class, no colored spans
expect(pre?.className ?? "").not.toMatch(/shiki/);
expect(container.querySelectorAll("pre span[style*='color']").length).toBe(0);
expect(pre?.textContent).toContain("const x = 1;");
});
});
describe("MarkdownPreview — sanitization of malicious HTML", () => {
it("strips <script> tags", () => {
const container = renderMarkdown(`hello<script>window.__pwn = 1;</script>world`);
expect(container.querySelector("script")).toBeNull();
expect(container.textContent ?? "").not.toContain("window.__pwn");
});
it("strips <script> tags", () => {
const container = renderMarkdown(`hello<script>window.__pwn = 1;</script>world`);
expect(container.querySelector("script")).toBeNull();
expect(container.textContent ?? "").not.toContain("window.__pwn");
});
it("strips onerror handlers on <img>", () => {
const container = renderMarkdown(`<img src="x" onerror="window.__pwn = 1" alt="x"/>`);
const img = container.querySelector("img");
// The img itself can render; the handler must be gone.
expect(img?.getAttribute("onerror")).toBeNull();
});
it("strips onerror handlers on <img>", () => {
const container = renderMarkdown(`<img src="x" onerror="window.__pwn = 1" alt="x"/>`);
const img = container.querySelector("img");
// The img itself can render; the handler must be gone.
expect(img?.getAttribute("onerror")).toBeNull();
});
it.each([
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
])("strips unsafe hrefs on anchors: %s", (unsafeHref) => {
const container = renderMarkdown(`<a href="${unsafeHref}">click</a>`);
const a = container.querySelector("a");
// Either the href is removed entirely or rewritten; it must not keep an executable scheme.
const href = a?.getAttribute("href") ?? "";
expect(href).not.toMatch(/^\s*(javascript|data|vbscript):/i);
});
it.each([
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
])("strips unsafe hrefs on anchors: %s", (unsafeHref) => {
const container = renderMarkdown(`<a href="${unsafeHref}">click</a>`);
const a = container.querySelector("a");
// Either the href is removed entirely or rewritten; it must not keep an executable scheme.
const href = a?.getAttribute("href") ?? "";
expect(href).not.toMatch(/^\s*(javascript|data|vbscript):/i);
});
});
+1 -6
View File
@@ -33,12 +33,7 @@ function getIcon(kind: MarketplaceIconProps["kind"]) {
}
}
export function MarketplaceIcon({
kind,
label,
imageUrl,
size = "sm",
}: MarketplaceIconProps) {
export function MarketplaceIcon({ kind, label, imageUrl, size = "sm" }: MarketplaceIconProps) {
const Icon = getIcon(kind);
const tone = hashTone(label);
+1 -1
View File
@@ -9,7 +9,7 @@ import { Button } from "./ui/button";
import { Card } from "./ui/card";
const OPENCLAW_PLUGIN_PACKAGE_METADATA_DOCS_URL =
'https://docs.openclaw.ai/plugins/sdk-setup#package-metadata';
"https://docs.openclaw.ai/plugins/sdk-setup#package-metadata";
export function PackageSourceChooser(props: {
files: File[];
+11 -4
View File
@@ -1,8 +1,8 @@
import { Link } from "@tanstack/react-router";
import type { PackageListItem } from "../lib/packageApi";
import { familyLabel } from "../lib/packageLabels";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import { familyLabel } from "../lib/packageLabels";
import type { PackageListItem } from "../lib/packageApi";
type PluginListItemProps = {
item: PackageListItem;
@@ -10,7 +10,12 @@ type PluginListItemProps = {
export function PluginListItem({ item }: PluginListItemProps) {
return (
<Link to="/plugins/$name" params={{ name: item.name }} className="skill-list-item" aria-label={`Plugin: ${item.displayName}`}>
<Link
to="/plugins/$name"
params={{ name: item.name }}
className="skill-list-item"
aria-label={`Plugin: ${item.displayName}`}
>
<MarketplaceIcon kind="plugin" label={item.displayName} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
@@ -24,7 +29,9 @@ export function PluginListItem({ item }: PluginListItemProps) {
<Badge variant="compact">{familyLabel(item.family)}</Badge>
{item.isOfficial ? <Badge variant="accent">Verified</Badge> : null}
</div>
<p className="skill-list-item-summary">{item.summary ?? "Plugin package for agent workflows."}</p>
<p className="skill-list-item-summary">
{item.summary ?? "Plugin package for agent workflows."}
</p>
<div className="skill-list-item-meta">
<span className="skill-list-item-meta-item">Plugin</span>
{item.latestVersion ? (
+2 -8
View File
@@ -10,11 +10,7 @@ type SignInButtonProps = Omit<ButtonProps, "onClick" | "type"> & {
redirectTo?: string;
};
export function SignInButton({
redirectTo,
children = "Sign In",
...props
}: SignInButtonProps) {
export function SignInButton({ redirectTo, children = "Sign In", ...props }: SignInButtonProps) {
const { signIn } = useAuthActions();
return (
@@ -32,9 +28,7 @@ export function SignInButton({
}
})
.catch((error) => {
setAuthError(
getUserFacingAuthError(error, "Sign in failed. Please try again."),
);
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
});
}}
>
+2 -4
View File
@@ -1,8 +1,8 @@
import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react";
import type { PublicSkill } from "../lib/publicUser";
import { MarketplaceIcon } from "./MarketplaceIcon";
import { Badge } from "./ui/badge";
import type { PublicSkill } from "../lib/publicUser";
type SkillCardProps = {
skill: PublicSkill;
@@ -33,9 +33,7 @@ export function SkillCard({
{hasTags ? (
<div className="skill-card-tags">
{badges.map((label) => (
<Badge key={label}>
{label}
</Badge>
<Badge key={label}>{label}</Badge>
))}
{chip ? <Badge variant="accent">{chip}</Badge> : null}
{platformLabels?.map((label) => (
+1 -6
View File
@@ -20,12 +20,7 @@ vi.mock("@monaco-editor/react", () => ({
}: {
className?: string;
options?: { renderSideBySide?: boolean; useInlineViewWhenSpaceIsLimited?: boolean };
}) => (
<MockDiffEditor
className={className}
options={options}
/>
),
}) => <MockDiffEditor className={className} options={options} />,
useMonaco: () => null,
}));
+3 -7
View File
@@ -14,8 +14,8 @@ import {
sortVersionsBySemver,
} from "../lib/diffing";
import { isDarkThemeResolved, onThemeChange } from "../lib/theme";
import { Button } from "./ui/button";
import { ClientOnly } from "./ClientOnly";
import { Button } from "./ui/button";
type SkillDiffCardProps = {
skill: Doc<"skills">;
@@ -283,12 +283,8 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
<div className={containerClass}>
<div className="diff-header">
<div>
<h2 className="section-title text-[1.2rem] m-0">
Compare versions
</h2>
<p className="section-subtitle m-0">
Inline or side-by-side diff for any file.
</p>
<h2 className="section-title text-[1.2rem] m-0">Compare versions</h2>
<p className="section-subtitle m-0">Inline or side-by-side diff for any file.</p>
</div>
{!diffUnavailable ? (
<fieldset className="diff-toggle-group">
+3 -10
View File
@@ -11,10 +11,7 @@ type SkillFilesPanelProps = {
latestFiles: SkillFile[];
};
export function SkillFilesPanel({
versionId,
latestFiles,
}: SkillFilesPanelProps) {
export function SkillFilesPanel({ versionId, latestFiles }: SkillFilesPanelProps) {
const getFileText = useAction(api.skills.getFileText);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string | null>(null);
@@ -89,12 +86,8 @@ export function SkillFilesPanel({
<div className="file-browser">
<div className="file-list">
<div className="file-list-header">
<h3 className="section-title text-[1.05rem] m-0">
Files
</h3>
<span className="section-subtitle m-0">
{latestFiles.length} total
</span>
<h3 className="section-title text-[1.05rem] m-0">Files</h3>
<span className="section-subtitle m-0">{latestFiles.length} total</span>
</div>
<div className="file-list-body">
{latestFiles.length === 0 ? (
+3 -13
View File
@@ -11,13 +11,7 @@ vi.mock("./ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({
children,
onSelect,
}: {
children: ReactNode;
onSelect?: () => void;
}) => (
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
<button type="button" onClick={() => onSelect?.()}>
{children}
</button>
@@ -85,9 +79,7 @@ describe("SkillInstallSurface", () => {
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
expect(screen.queryByText("npx clawhub@latest install weather")).toBeNull();
expect(screen.getByRole("tab", { name: "CLI" }).getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe(
"false",
);
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe("false");
fireEvent.click(screen.getByRole("button", { name: "Copy OpenClaw CLI command" }));
@@ -98,9 +90,7 @@ describe("SkillInstallSurface", () => {
fireEvent.click(screen.getByRole("tab", { name: "Prompt" }));
expect(screen.getByText(/Install the skill "Weather"/i)).toBeTruthy();
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe(
"true",
);
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe("true");
fireEvent.click(screen.getByRole("button", { name: "Copy OpenClaw prompt" }));
+4 -8
View File
@@ -129,8 +129,8 @@ export function SkillInstallSurface({
<p className="skill-install-kicker">OpenClaw Prompt Flow</p>
<h3 className="skill-install-panel-title">Install with OpenClaw</h3>
<p className="skill-install-panel-copy">
Best for remote or guided setup. Copy the exact prompt, then paste it into OpenClaw
for <code translate="no">{installTarget}</code>.
Best for remote or guided setup. Copy the exact prompt, then paste it into OpenClaw for{" "}
<code translate="no">{installTarget}</code>.
</p>
</div>
@@ -207,9 +207,7 @@ export function SkillCommandLineCard({
type="button"
role="tab"
aria-selected={activeInstallTab === "prompt"}
className={`install-switcher-pill${
activeInstallTab === "prompt" ? " is-active" : ""
}`}
className={`install-switcher-pill${activeInstallTab === "prompt" ? " is-active" : ""}`}
onClick={() => setActiveInstallTab("prompt")}
>
Prompt
@@ -230,9 +228,7 @@ export function SkillCommandLineCard({
<InstallCopyButton
text={activeInstallTab === "prompt" ? promptPreview : openClawCommand}
ariaLabel={
activeInstallTab === "prompt"
? "Copy OpenClaw prompt"
: "Copy OpenClaw CLI command"
activeInstallTab === "prompt" ? "Copy OpenClaw prompt" : "Copy OpenClaw CLI command"
}
className="skill-install-command-inline-button"
showLabel={false}
+1 -1
View File
@@ -1,7 +1,7 @@
import { Link } from "@tanstack/react-router";
import type { ReactNode } from "react";
import { MarketplaceIcon } from "./MarketplaceIcon";
import type { PublicSoul } from "../lib/publicUser";
import { MarketplaceIcon } from "./MarketplaceIcon";
type SoulCardProps = {
soul: PublicSoul;
+17 -11
View File
@@ -25,9 +25,8 @@ export function UserBadge({
link = true,
showName = false,
}: UserBadgeProps) {
const userName = hasOwnProperty(user, "name") && typeof user.name === "string"
? user.name.trim()
: undefined;
const userName =
hasOwnProperty(user, "name") && typeof user.name === "string" ? user.name.trim() : undefined;
const displayName = user?.displayName?.trim() || userName || null;
const handle = user?.handle ?? fallbackHandle ?? null;
const href =
@@ -51,8 +50,8 @@ export function UserBadge({
// PublicPublisher has linkedUserId
const userId =
user && hasOwnProperty(user, "kind")
? (user as PublicPublisher).linkedUserId ?? null
: user?._id ?? null;
? ((user as PublicPublisher).linkedUserId ?? null)
: (user?._id ?? null);
const badge = (
<span className={`user-badge user-badge-${size}`}>
@@ -128,24 +127,31 @@ function UserStatsTooltipContent({
{displayName}
</span>
)}
{handle && (
<span className="text-fs-xs text-ink-soft">@{handle}</span>
)}
{handle && <span className="text-fs-xs text-ink-soft">@{handle}</span>}
</div>
<div className="border-t border-line flex items-center gap-space-3 px-3 py-2">
{stats === null ? (
<span className="text-fs-xs text-ink-soft">Loading...</span>
) : (
<>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Published skills">
<span
className="flex items-center gap-1 text-fs-xs text-ink-soft"
title="Published skills"
>
<Package size={12} />
{formatCompactStat(stats.publishedSkills)}
</span>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Stars received">
<span
className="flex items-center gap-1 text-fs-xs text-ink-soft"
title="Stars received"
>
<Star size={12} />
{formatCompactStat(stats.totalStars)}
</span>
<span className="flex items-center gap-1 text-fs-xs text-ink-soft" title="Total downloads">
<span
className="flex items-center gap-1 text-fs-xs text-ink-soft"
title="Total downloads"
>
<Download size={12} />
{formatCompactStat(stats.totalDownloads)}
</span>
+7 -2
View File
@@ -1,6 +1,6 @@
import { Link } from "@tanstack/react-router";
import { MarketplaceIcon } from "./MarketplaceIcon";
import type { PublicUser } from "../lib/publicUser";
import { MarketplaceIcon } from "./MarketplaceIcon";
type UserListItemProps = {
user: PublicUser;
@@ -13,7 +13,12 @@ export function UserListItem({ user }: UserListItemProps) {
const displayName = user.displayName ?? user.name ?? handle;
return (
<Link to="/u/$handle" params={{ handle }} className="skill-list-item user-list-item" aria-label={`User: ${displayName}`}>
<Link
to="/u/$handle"
params={{ handle }}
className="skill-list-item user-list-item"
aria-label={`User: ${displayName}`}
>
<MarketplaceIcon kind="user" label={displayName} imageUrl={user.image} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
+14 -14
View File
@@ -2,23 +2,23 @@ import * as React from "react";
import { cn } from "../../lib/utils";
interface ContainerProps extends React.HTMLAttributes<HTMLDivElement> {
size?: "default" | "narrow" | "wide";
size?: "default" | "narrow" | "wide";
}
const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
({ className, size = "default", ...props }, ref) => (
<div
ref={ref}
className={cn(
"mx-auto w-full px-4 sm:px-6 lg:px-7",
size === "default" && "max-w-page-max",
size === "narrow" && "max-w-page-narrow",
size === "wide" && "w-full",
className,
)}
{...props}
/>
),
({ className, size = "default", ...props }, ref) => (
<div
ref={ref}
className={cn(
"mx-auto w-full px-4 sm:px-6 lg:px-7",
size === "default" && "max-w-page-max",
size === "narrow" && "max-w-page-narrow",
size === "wide" && "w-full",
className,
)}
{...props}
/>
),
);
Container.displayName = "Container";
@@ -59,7 +59,6 @@ export function SkillDetailSkeleton() {
<Skeleton className="h-4 w-2/3" />
</div>
</div>
</div>
</div>
);
+9 -3
View File
@@ -13,7 +13,9 @@ describe("skill detail install helpers", () => {
const ownerPublisherId = "publishers:1" as Id<"publishers">;
it("prefers the owner handle for install targets", () => {
expect(buildSkillInstallTarget("steipete", ownerPublisherId, "weather")).toBe("steipete/weather");
expect(buildSkillInstallTarget("steipete", ownerPublisherId, "weather")).toBe(
"steipete/weather",
);
});
it("falls back to owner id and then plain slug", () => {
@@ -23,11 +25,15 @@ describe("skill detail install helpers", () => {
it("formats the OpenClaw and ClawHub commands", () => {
expect(formatOpenClawInstallCommand("weather")).toBe("openclaw skills install weather");
expect(formatClawHubInstallCommand("weather", "npm")).toBe("npx clawhub@latest install weather");
expect(formatClawHubInstallCommand("weather", "npm")).toBe(
"npx clawhub@latest install weather",
);
expect(formatClawHubInstallCommand("weather", "pnpm")).toBe(
"pnpm dlx clawhub@latest install weather",
);
expect(formatClawHubInstallCommand("weather", "bun")).toBe("bunx clawhub@latest install weather");
expect(formatClawHubInstallCommand("weather", "bun")).toBe(
"bunx clawhub@latest install weather",
);
});
it("builds the install-and-setup prompt from known metadata only", () => {
+10 -5
View File
@@ -15,11 +15,16 @@ const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
// Variant styles — all token-driven, no dark: overrides needed
variant === "default" && "bg-hover-bg px-3 py-1 text-ink-soft border border-line",
variant === "accent" && "bg-active-bg px-3 py-1 text-accent-deep border border-line",
variant === "compact" && "bg-hover-bg px-2.5 py-0.5 text-fs-xs text-ink-soft border border-line",
variant === "pending" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
variant === "success" && "bg-status-success-bg px-3 py-1 text-status-success-fg border border-line",
variant === "warning" && "bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
variant === "destructive" && "bg-status-error-bg px-3 py-1 text-status-error-fg border border-line",
variant === "compact" &&
"bg-hover-bg px-2.5 py-0.5 text-fs-xs text-ink-soft border border-line",
variant === "pending" &&
"bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
variant === "success" &&
"bg-status-success-bg px-3 py-1 text-status-success-fg border border-line",
variant === "warning" &&
"bg-status-warning-bg px-3 py-1 text-status-warning-fg border border-line",
variant === "destructive" &&
"bg-status-error-bg px-3 py-1 text-status-error-fg border border-line",
className,
)}
{...props}
+36 -6
View File
@@ -7,12 +7,42 @@ export type SkillCategory = {
export const SKILL_CATEGORIES: SkillCategory[] = [
{ slug: "mcp-tools", label: "MCP Tools", icon: "plug", keywords: ["mcp", "tool", "server"] },
{ slug: "prompts", label: "Prompts", icon: "message-square", keywords: ["prompt", "template", "system"] },
{ slug: "workflows", label: "Workflows", icon: "git-branch", keywords: ["workflow", "pipeline", "chain"] },
{ slug: "dev-tools", label: "Dev Tools", icon: "wrench", keywords: ["dev", "debug", "lint", "test", "build"] },
{ slug: "data", label: "Data & APIs", icon: "database", keywords: ["api", "data", "fetch", "http", "rest", "graphql"] },
{ slug: "security", label: "Security", icon: "shield", keywords: ["security", "scan", "auth", "encrypt"] },
{ slug: "automation", label: "Automation", icon: "zap", keywords: ["auto", "cron", "schedule", "bot"] },
{
slug: "prompts",
label: "Prompts",
icon: "message-square",
keywords: ["prompt", "template", "system"],
},
{
slug: "workflows",
label: "Workflows",
icon: "git-branch",
keywords: ["workflow", "pipeline", "chain"],
},
{
slug: "dev-tools",
label: "Dev Tools",
icon: "wrench",
keywords: ["dev", "debug", "lint", "test", "build"],
},
{
slug: "data",
label: "Data & APIs",
icon: "database",
keywords: ["api", "data", "fetch", "http", "rest", "graphql"],
},
{
slug: "security",
label: "Security",
icon: "shield",
keywords: ["security", "scan", "auth", "encrypt"],
},
{
slug: "automation",
label: "Automation",
icon: "zap",
keywords: ["auto", "cron", "schedule", "bot"],
},
{ slug: "other", label: "Other", icon: "package", keywords: [] },
];
+1 -4
View File
@@ -28,10 +28,7 @@ export function getUserFacingConvexError(error: unknown, fallback: string) {
if (hasOwnProperty(maybe, "data")) {
if (typeof maybe.data === "string") candidates.push(maybe.data);
if (
hasOwnProperty(maybe.data, "message") &&
typeof maybe.data.message === "string"
) {
if (hasOwnProperty(maybe.data, "message") && typeof maybe.data.message === "string") {
candidates.push(maybe.data.message);
}
}
+3 -1
View File
@@ -2,5 +2,7 @@ export function hasOwnProperty<K extends PropertyKey>(
value: unknown,
key: K,
): value is Record<K, unknown> {
return typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, key);
return (
typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, key)
);
}
+10 -14
View File
@@ -6,33 +6,29 @@ let authError: string | null = null;
const listeners = new Set<() => void>();
function emitChange() {
for (const listener of listeners) listener();
for (const listener of listeners) listener();
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
listeners.add(listener);
return () => listeners.delete(listener);
}
export function getAuthErrorSnapshot() {
return authError;
return authError;
}
export function setAuthError(error: string | null) {
if (authError === error) return;
authError = error;
emitChange();
if (authError === error) return;
authError = error;
emitChange();
}
export function clearAuthError() {
setAuthError(null);
setAuthError(null);
}
export function useAuthError() {
const error = useSyncExternalStore(
subscribe,
getAuthErrorSnapshot,
getAuthErrorSnapshot,
);
return { error, clear: clearAuthError };
const error = useSyncExternalStore(subscribe, getAuthErrorSnapshot, getAuthErrorSnapshot);
return { error, clear: clearAuthError };
}
@@ -1,10 +1,7 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { useQuery } from "convex/react";
import { api } from "../../../../../convex/_generated/api";
import {
SecurityScannerPage,
type ScannerSlug,
} from "../../../../components/SecurityScannerPage";
import { SecurityScannerPage, type ScannerSlug } from "../../../../components/SecurityScannerPage";
import { buildSkillMeta } from "../../../../lib/og";
import { fetchSkillPageData } from "../../../../lib/skillPage";
@@ -242,7 +242,9 @@ describe("Dashboard minimal rows", () => {
expect(screen.getByText("Blocked")).toBeTruthy();
expect(screen.getByRole("button", { name: "Suspicious status reason" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Blocked status reason" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Open actions for Local Flagged Skill" })).toBeTruthy();
expect(
screen.getByRole("button", { name: "Open actions for Local Flagged Skill" }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "Open actions for Local Flagged Runtime Plugin" }),
).toBeTruthy();
@@ -289,5 +291,4 @@ describe("Dashboard minimal rows", () => {
expect(screen.queryByText("2/3 rescans left")).toBeNull();
expect(screen.queryByText("Limit reached (3/3)")).toBeNull();
});
});
+41 -51
View File
@@ -1,23 +1,15 @@
import { createFileRoute, Link } from '@tanstack/react-router';
import type { LucideIcon } from 'lucide-react';
import {
Banknote,
Drama,
Eye,
EyeOff,
ImageOff,
ShieldOff,
UserX,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
import { createFileRoute, Link } from "@tanstack/react-router";
import type { LucideIcon } from "lucide-react";
import { Banknote, Drama, Eye, EyeOff, ImageOff, ShieldOff, UserX } from "lucide-react";
import type { ReactNode } from "react";
import { Badge } from "../components/ui/badge";
import { Button } from "../components/ui/button";
import { getSiteMode, getSiteName, getSiteUrlForMode } from "../lib/site";
export function renderWithInlineCode(text: string): ReactNode[] {
const parts = text.split(/(`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('`') && part.endsWith('`')) {
if (part.startsWith("`") && part.endsWith("`")) {
return (
<code key={i} className="about-inline-code">
{part.slice(1, -1)}
@@ -30,68 +22,68 @@ export function renderWithInlineCode(text: string): ReactNode[] {
const prohibitedCategories: { title: string; icon: LucideIcon; examples: string }[] = [
{
title: 'Bypass and unauthorized access',
title: "Bypass and unauthorized access",
icon: ShieldOff,
examples:
'Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.',
"Auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, reusable session theft, live call or agent takeover.",
},
{
title: 'Platform abuse and ban evasion',
title: "Platform abuse and ban evasion",
icon: UserX,
examples:
'Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.',
"Stealth accounts after bans, account warming/farming, fake engagement, multi-account automation, spam posting, marketplace or social automation built to avoid detection.",
},
{
title: 'Fraud and deception',
title: "Fraud and deception",
icon: Banknote,
examples:
'Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.',
"Fake certificates, fake invoices, deceptive payment flows, fake social proof, scam outreach, or synthetic-identity workflows built to create accounts for fraud.",
},
{
title: 'Privacy-invasive surveillance',
title: "Privacy-invasive surveillance",
icon: Eye,
examples:
'Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.',
"Mass contact scraping for spam, doxxing, stalking, covert monitoring, biometric / face-matching workflows without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.",
},
{
title: 'Non-consensual impersonation',
title: "Non-consensual impersonation",
icon: Drama,
examples:
'Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.',
"Face swap, digital twins, cloned influencers, fake personas, or other identity manipulation used to impersonate or mislead.",
},
{
title: 'Explicit sexual content',
title: "Explicit sexual content",
icon: ImageOff,
examples:
'NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.',
"NSFW image, video, or text generation, especially wrappers around third-party APIs with safety checks disabled.",
},
{
title: 'Hidden or misleading execution',
title: "Hidden or misleading execution",
icon: EyeOff,
examples:
'Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.',
"Obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, or remote `npx @latest` execution without reviewability.",
},
];
const recentPatterns = [
'Create stealth seller accounts after marketplace bans.',
'Modify Telegram pairing so unapproved users automatically receive pairing codes.',
'Cultivate Reddit or Twitter accounts with undetectable automation.',
'Generate professional certificates or invoices for arbitrary use.',
'Generate NSFW content with safety checks disabled.',
'Scrape leads, enrich contacts, and launch cold outreach at scale.',
'Buy, publish, or download leaked data or breach dumps.',
'Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.',
"Create stealth seller accounts after marketplace bans.",
"Modify Telegram pairing so unapproved users automatically receive pairing codes.",
"Cultivate Reddit or Twitter accounts with undetectable automation.",
"Generate professional certificates or invoices for arbitrary use.",
"Generate NSFW content with safety checks disabled.",
"Scrape leads, enrich contacts, and launch cold outreach at scale.",
"Buy, publish, or download leaked data or breach dumps.",
"Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.",
];
export const Route = createFileRoute('/about')({
export const Route = createFileRoute("/about")({
head: () => {
const mode = getSiteMode();
const siteName = getSiteName(mode);
const siteUrl = getSiteUrlForMode(mode);
const title = `About · ${siteName}`;
const description =
'What ClawHub allows, what we do not host, and the abuse patterns that lead to removal or account bans.';
"What ClawHub allows, what we do not host, and the abuse patterns that lead to removal or account bans.";
return {
links: [
@@ -102,11 +94,11 @@ export const Route = createFileRoute('/about')({
],
meta: [
{ title },
{ name: 'description', content: description },
{ property: 'og:title', content: title },
{ property: 'og:description', content: description },
{ property: 'og:type', content: 'website' },
{ property: 'og:url', content: `${siteUrl}/about` },
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:type", content: "website" },
{ property: "og:url", content: `${siteUrl}/about` },
],
};
},
@@ -125,9 +117,9 @@ function AboutPage() {
</div>
<h1 className="about-title">What ClawHub will not host</h1>
<p className="about-lead">
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to
evade defenses, scam people, invade privacy, or enable non-consensual behavior, it
does not belong here.
ClawHub is for useful agent tooling, not abuse workflows. If a skill is built to evade
defenses, scam people, invade privacy, or enable non-consensual behavior, it does not
belong here.
</p>
</div>
</section>
@@ -203,9 +195,7 @@ function AboutPage() {
</p>
<div className="skill-card-tags">
<Button asChild variant="primary">
<Link to="/skills">
Browse Skills
</Link>
<Link to="/skills">Browse Skills</Link>
</Button>
<Button asChild>
<a
+3 -4
View File
@@ -55,10 +55,9 @@ vi.mock("../../components/layout/Container", () => ({
}));
vi.mock("../../components/SignInButton", () => ({
SignInButton: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
SignInButton: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
}));
vi.mock("../../components/ui/card", () => ({
+17 -5
View File
@@ -18,7 +18,9 @@ type CliAuthProps = {
navigate?: (url: string) => void;
};
export function CliAuth({ navigate = (url: string) => window.location.assign(url) }: CliAuthProps = {}) {
export function CliAuth({
navigate = (url: string) => window.location.assign(url),
}: CliAuthProps = {}) {
const { isAuthenticated, isLoading, me } = useAuthStatus();
const { error: authError, clear: clearAuthError } = useAuthError();
const createToken = useMutation(api.tokens.create);
@@ -79,7 +81,17 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
setStatus(message);
setToken(null);
});
}, [createToken, isAuthenticated, label, me, navigate, redirectUri, registry, safeRedirect, state]);
}, [
createToken,
isAuthenticated,
label,
me,
navigate,
redirectUri,
registry,
safeRedirect,
state,
]);
if (!safeRedirect) {
return (
@@ -169,9 +181,9 @@ export function CliAuth({ navigate = (url: string) => window.location.assign(url
{token ? (
<div className="text-sm text-[color:var(--ink-soft)] overflow-x-auto">
<div className="mb-2">
If the redirect did not complete, copy this token and run{" "}
<code>clawhub login --token &lt;token&gt;</code>:
</div>
If the redirect did not complete, copy this token and run{" "}
<code>clawhub login --token &lt;token&gt;</code>:
</div>
<code className="font-mono text-xs">{token}</code>
{callbackUrl ? (
<div className="mt-2">
+2 -8
View File
@@ -228,9 +228,7 @@ export function Dashboard() {
<div className="dashboard-header">
<div>
<h1 className="section-title m-0">Dashboard</h1>
<p className="section-subtitle m-0">
View your published skills and plugins.
</p>
<p className="section-subtitle m-0">View your published skills and plugins.</p>
</div>
</div>
@@ -306,11 +304,7 @@ function SkillRow({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle:
<div className="dashboard-list-row">
<div className="dashboard-list-primary">
<div className="dashboard-list-title">
<Link
to="/$owner/$slug"
params={detailParams}
className="dashboard-skill-name"
>
<Link to="/$owner/$slug" params={detailParams} className="dashboard-skill-name">
{skill.displayName}
</Link>
</div>
+1 -3
View File
@@ -226,9 +226,7 @@ export function ImportGitHub() {
title={isLoading ? "Loading..." : "Sign in to import and publish skills"}
description="You need to be signed in to import skills from GitHub."
>
{!isLoading ? (
<SignInButton />
) : null}
{!isLoading ? <SignInButton /> : null}
</EmptyState>
</Container>
</main>
@@ -1,8 +1,5 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
SecurityScannerPage,
type ScannerSlug,
} from "../../../../components/SecurityScannerPage";
import { SecurityScannerPage, type ScannerSlug } from "../../../../components/SecurityScannerPage";
import {
fetchPackageDetail,
fetchPackageVersion,
+5 -6
View File
@@ -1,7 +1,7 @@
import type { RefObject } from "react";
import { SkillCard } from "../../components/SkillCard";
import { SkillListItem } from "../../components/SkillListItem";
import { getPlatformLabels } from "../../components/skillDetailUtils";
import { SkillListItem } from "../../components/SkillListItem";
import { SkillStatsTripletLine } from "../../components/SkillStats";
import { Button } from "../../components/ui/button";
import { UserBadge } from "../../components/UserBadge";
@@ -52,7 +52,9 @@ export function SkillsResults({
<div className="empty-state">
<p className="empty-state-title">No skills found</p>
<p className="empty-state-body">
{hasQuery ? "Try a different search term or remove filters." : "No skills have been published yet."}
{hasQuery
? "Try a different search term or remove filters."
: "No skills have been published yet."}
</p>
</div>
) : view === "cards" ? (
@@ -108,10 +110,7 @@ export function SkillsResults({
)}
{canLoadMore || isLoadingMore ? (
<div
ref={canAutoLoad ? loadMoreRef : null}
className="card mt-4 flex justify-center"
>
<div ref={canAutoLoad ? loadMoreRef : null} className="card mt-4 flex justify-center">
{canAutoLoad ? (
isLoadingMore ? (
"Loading more..."
+3 -4
View File
@@ -16,7 +16,6 @@ import {
} from "lucide-react";
import type { RefObject } from "react";
import { useMemo } from "react";
import { SKILL_CATEGORIES, type SkillCategory } from "../../lib/categories";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import {
@@ -26,6 +25,7 @@ import {
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import { SKILL_CATEGORIES, type SkillCategory } from "../../lib/categories";
import { type SortDir, type SortKey } from "./-params";
type SkillsToolbarProps = {
@@ -89,9 +89,8 @@ export function SkillsToolbar({
const activeCategory = useMemo(() => {
if (query === "__other__") return "other";
if (!query) return undefined;
return SKILL_CATEGORIES.find((c) =>
c.keywords.some((k) => k === query.trim().toLowerCase()),
)?.slug;
return SKILL_CATEGORIES.find((c) => c.keywords.some((k) => k === query.trim().toLowerCase()))
?.slug;
}, [query]);
const handleCategoryChange = (cat: SkillCategory | undefined) => {
+6 -18
View File
@@ -114,9 +114,7 @@ function UserProfile() {
<>
{published.length > 0 ? (
<>
<h2 className="home-section-title mb-3">
Published ({published.length})
</h2>
<h2 className="home-section-title mb-3">Published ({published.length})</h2>
{isLoadingPublished ? (
<Card>
<div className="loading-indicator">Loading published skills...</div>
@@ -131,9 +129,7 @@ function UserProfile() {
</>
) : null}
<h2 className="home-section-title mb-3">
Stars ({skills.length})
</h2>
<h2 className="home-section-title mb-3">Stars ({skills.length})</h2>
{isLoadingSkills ? (
<Card>
<div className="loading-indicator">Loading stars...</div>
@@ -164,9 +160,7 @@ function InstalledSection(props: {
if (data === undefined) {
return (
<>
<h2 className="section-title text-xl">
Installed
</h2>
<h2 className="section-title text-xl">Installed</h2>
<Card>
<div className="loading-indicator">Loading telemetry</div>
</Card>
@@ -177,9 +171,7 @@ function InstalledSection(props: {
if (data === null) {
return (
<>
<h2 className="section-title text-xl">
Installed
</h2>
<h2 className="section-title text-xl">Installed</h2>
<Card>Sign in to view your installed skills.</Card>
</>
);
@@ -187,9 +179,7 @@ function InstalledSection(props: {
return (
<>
<h2 className="section-title text-xl">
Installed
</h2>
<h2 className="section-title text-xl">Installed</h2>
<p className="section-subtitle max-w-[760px]">
Private view. Only you can see your folders/roots. Everyone else only sees aggregated
install counts per skill.
@@ -214,9 +204,7 @@ function InstalledSection(props: {
{showRaw ? (
<Card className="telemetry-json mb-4">
<pre className="mono m-0 whitespace-pre-wrap">
{JSON.stringify(data, null, 2)}
</pre>
<pre className="mono m-0 whitespace-pre-wrap">{JSON.stringify(data, null, 2)}</pre>
</Card>
) : null}
+17 -6
View File
@@ -41,7 +41,8 @@ function handleRollupWarning(
type SourceReplacement = readonly [from: string, to: string];
const reflectHas = (target: string, key: string) => `Reflect.has(${target}, ${JSON.stringify(key)})`;
const reflectHas = (target: string, key: string) =>
`Reflect.has(${target}, ${JSON.stringify(key)})`;
const arkSafariInOperatorFixes = [
{
@@ -89,15 +90,21 @@ const arkSafariInOperatorFixes = [
},
{
suffix: "/node_modules/@ark/schema/out/node.js",
replacements: [['"value" in transformedInner', reflectHas("transformedInner", "value")]] satisfies SourceReplacement[],
replacements: [
['"value" in transformedInner', reflectHas("transformedInner", "value")],
] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/schema/out/scope.js",
replacements: [['"branches" in schema', reflectHas("schema", "branches")]] satisfies SourceReplacement[],
replacements: [
['"branches" in schema', reflectHas("schema", "branches")],
] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/schema/out/structure/optional.js",
replacements: [['"default" in this.inner', reflectHas("this.inner", "default")]] satisfies SourceReplacement[],
replacements: [
['"default" in this.inner', reflectHas("this.inner", "default")],
] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/schema/out/structure/sequence.js",
@@ -112,11 +119,15 @@ const arkSafariInOperatorFixes = [
},
{
suffix: "/node_modules/@ark/schema/out/structure/prop.js",
replacements: [['"default" in this.inner', reflectHas("this.inner", "default")]] satisfies SourceReplacement[],
replacements: [
['"default" in this.inner', reflectHas("this.inner", "default")],
] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/schema/out/shared/implement.js",
replacements: [['"description" in ctx', reflectHas("ctx", "description")]] satisfies SourceReplacement[],
replacements: [
['"description" in ctx', reflectHas("ctx", "description")],
] satisfies SourceReplacement[],
},
{
suffix: "/node_modules/@ark/schema/out/shared/errors.js",