mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
chore(ci): enforce formatting
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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/"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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  images through /_vercel/image", () => {
|
||||
const container = renderMarkdown(``);
|
||||
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  images through /_vercel/image", () => {
|
||||
const container = renderMarkdown(``);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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."));
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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: [] },
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 <token></code>:
|
||||
</div>
|
||||
If the redirect did not complete, copy this token and run{" "}
|
||||
<code>clawhub login --token <token></code>:
|
||||
</div>
|
||||
<code className="font-mono text-xs">{token}</code>
|
||||
{callbackUrl ? (
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user