mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-16 01:42:07 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb66bef7a6 | ||
|
|
ba5410caa0 | ||
|
|
29674a11cf | ||
|
|
4c4ba9b832 | ||
|
|
1e1f5a6e2c | ||
|
|
f9e4b95002 | ||
|
|
b16861f422 | ||
|
|
6896e61fa1 | ||
|
|
cf137aa592 | ||
|
|
a28d014d4f | ||
|
|
b2038fc931 | ||
|
|
4a72b543b2 | ||
|
|
54e99c8cc2 | ||
|
|
2af0b416a8 | ||
|
|
8dff99de19 | ||
|
|
bbbdad73c5 | ||
|
|
18aea044fc | ||
|
|
2a4295c3ac | ||
|
|
d761eb768d | ||
|
|
f274b337bd | ||
|
|
cbdfc327be | ||
|
|
df531f4742 | ||
|
|
21d6d01f13 | ||
|
|
98eb954145 | ||
|
|
adc017aa4b | ||
|
|
d6996379c1 | ||
|
|
c1eb58e51f | ||
|
|
21e0bb16f0 | ||
|
|
5826001795 | ||
|
|
0708a43fde | ||
|
|
311a123fbe | ||
|
|
59e93862ed | ||
|
|
8fcd53f899 | ||
|
|
ba2c73e180 | ||
|
|
fa87dc3509 |
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
|
||||
|
||||
## 0.10.0 - 2026-04-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
# ClawHub Design System
|
||||
|
||||
This document outlines the design rules, patterns, and guidelines for the ClawHub platform to ensure consistency, accessibility, and maintainability across all components.
|
||||
|
||||
---
|
||||
|
||||
## Color System
|
||||
|
||||
### Brand Palette (OpenClaw)
|
||||
|
||||
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 |
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Never exceed 5 colors** without explicit design approval
|
||||
2. **Never use purple/violet prominently** unless explicitly requested
|
||||
3. **Always override text color** when changing background color to ensure contrast
|
||||
4. **Use semantic tokens** (`--accent`, `--ink`, `--surface`) instead of raw colors
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Stack
|
||||
|
||||
```css
|
||||
--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 |
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Maximum 2 font families** per page
|
||||
2. **Line height 1.4-1.6** for body text (use `leading-relaxed`)
|
||||
3. **Never use decorative fonts** for body text
|
||||
4. **Minimum font size: 14px** for readability
|
||||
5. Use `text-balance` or `text-pretty` for titles
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
### Method Priority
|
||||
|
||||
Use this hierarchy for layout decisions:
|
||||
|
||||
1. **Flexbox** - Default for most layouts
|
||||
2. **CSS Grid** - Only for complex 2D layouts (cards, galleries)
|
||||
3. **Never use floats** or absolute positioning unless absolutely necessary
|
||||
|
||||
### 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 */
|
||||
```
|
||||
|
||||
### 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));
|
||||
|
||||
@media (max-width: 860px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
grid-template-columns: 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 |
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
```
|
||||
|
||||
**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 |
|
||||
|
||||
**Rules:**
|
||||
- Always include visible focus state
|
||||
- Minimum touch target: 44x44px on mobile
|
||||
- Include `aria-label` when icon-only
|
||||
|
||||
### Form Controls
|
||||
|
||||
- Labels above inputs (not inline)
|
||||
- Error states use `--status-error-fg`
|
||||
- Focus rings use `--accent` with 0.2 opacity
|
||||
- Minimum input height: 40px
|
||||
|
||||
---
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
```css
|
||||
/* Mobile first - base styles for mobile */
|
||||
|
||||
@media (min-width: 520px) {
|
||||
/* Small tablets, large phones */
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
/* Tablets */
|
||||
}
|
||||
|
||||
@media (min-width: 860px) {
|
||||
/* Small desktops, landscape tablets */
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
/* Desktops */
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
/* Large desktops */
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Mobile-first approach** - Base styles target mobile
|
||||
2. **Progressive enhancement** - Add complexity as viewport increases
|
||||
3. **Test intermediate breakpoints** - Avoid jarring layout jumps
|
||||
4. **Never hide critical content** on mobile
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Color Contrast
|
||||
|
||||
- Normal text: Minimum 4.5:1 ratio
|
||||
- Large text (18px+): Minimum 3:1 ratio
|
||||
- Interactive elements: Minimum 3:1 ratio
|
||||
|
||||
### Focus States
|
||||
|
||||
```css
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
### Screen Readers
|
||||
|
||||
- Use `sr-only` class for visually hidden but accessible text
|
||||
- Always include `alt` text for images (empty `alt=""` for decorative)
|
||||
- Use semantic HTML elements (`main`, `nav`, `article`, `section`)
|
||||
- Proper heading hierarchy (h1 > h2 > h3, no skipping)
|
||||
|
||||
### Motion
|
||||
|
||||
```css
|
||||
/* Respect user preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animation
|
||||
|
||||
### Timing
|
||||
|
||||
```css
|
||||
--transition-fast: 150ms;
|
||||
--transition-base: 200ms;
|
||||
--transition-slow: 300ms;
|
||||
```
|
||||
|
||||
### Easing
|
||||
|
||||
- Use `ease` or `ease-out` for most transitions
|
||||
- Use `ease-in-out` for enter/exit animations
|
||||
- Never use `linear` except for continuous animations
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Subtle by default** - Avoid flashy animations
|
||||
2. **Purpose-driven** - Animation should provide feedback
|
||||
3. **Respect preferences** - Support `prefers-reduced-motion`
|
||||
4. **Performance** - Use `transform` and `opacity` only
|
||||
|
||||
---
|
||||
|
||||
## Icons
|
||||
|
||||
### Usage
|
||||
|
||||
- Use Lucide icons consistently
|
||||
- Standard sizes: 14px, 16px, 20px, 24px
|
||||
- Include `aria-hidden="true"` for decorative icons
|
||||
- Never use emojis as icons
|
||||
|
||||
### Placement
|
||||
|
||||
- Left of labels in buttons and navigation
|
||||
- Right of labels for external links or dropdowns
|
||||
- Centered when used alone with `aria-label`
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode
|
||||
|
||||
### Implementation
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
/* Dark mode overrides */
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. Never use pure white (`#ffffff`) on dark backgrounds
|
||||
2. Reduce shadow intensity in dark mode
|
||||
3. Adjust image brightness if needed
|
||||
4. Test contrast ratios in both modes
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### CSS
|
||||
|
||||
1. Use CSS custom properties for theming
|
||||
2. Avoid deeply nested selectors (max 3 levels)
|
||||
3. Use `will-change` sparingly
|
||||
4. Prefer `transform` over `top/left` for animations
|
||||
|
||||
### Images
|
||||
|
||||
1. Always specify `width` and `height` attributes
|
||||
2. Use `loading="lazy"` for below-fold images
|
||||
3. Use appropriate formats (WebP with fallbacks)
|
||||
4. Include placeholder or skeleton states
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
### CSS Class Naming
|
||||
|
||||
```css
|
||||
/* Component */
|
||||
.component-name { }
|
||||
|
||||
/* Component modifier */
|
||||
.component-name.variant { }
|
||||
|
||||
/* Component child */
|
||||
.component-name-child { }
|
||||
|
||||
/* State */
|
||||
.component-name.is-active { }
|
||||
.component-name[data-state="open"] { }
|
||||
```
|
||||
|
||||
### File Organization
|
||||
|
||||
```
|
||||
src/
|
||||
components/
|
||||
ui/ # Primitive components (Button, Input, Card)
|
||||
layout/ # Layout components (Container, Header)
|
||||
styles.css # Global styles and design tokens
|
||||
lib/
|
||||
theme.ts # Theme utilities
|
||||
preferences.ts # User preference management
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Before shipping any UI changes, verify:
|
||||
|
||||
- [ ] Color contrast meets WCAG AA standards
|
||||
- [ ] Focus states are visible
|
||||
- [ ] Layout works at all breakpoints
|
||||
- [ ] Animations respect `prefers-reduced-motion`
|
||||
- [ ] Text is readable at default browser zoom
|
||||
- [ ] Interactive elements have 44px minimum touch target
|
||||
- [ ] Semantic HTML is used appropriately
|
||||
- [ ] Dark mode has been tested
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "clawhub",
|
||||
@@ -19,6 +20,8 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
@@ -31,6 +34,7 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/router-plugin": "1.167.2",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.34.1",
|
||||
@@ -40,6 +44,8 @@
|
||||
"ignore": "^7.0.5",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nitro": "3.0.260311-beta",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -50,6 +56,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6",
|
||||
@@ -72,7 +79,7 @@
|
||||
"oxlint-tsgolint": "^0.17.4",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.24.7",
|
||||
"vite": "8.0.1",
|
||||
"vite": "8.0.5",
|
||||
"vitest": "^4.1.2",
|
||||
},
|
||||
},
|
||||
@@ -265,6 +272,56 @@
|
||||
|
||||
"@fontsource/manrope": ["@fontsource/manrope@5.2.8", "", {}, "sha512-gJHJmcuUk7qWcNCfcAri/DJQtXtBYqi9yKratr4jXhSo0I3xUtNNKI+igQIcw5c+m95g0vounk8ZnX/kb8o0TA=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -281,6 +338,24 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
|
||||
|
||||
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
||||
|
||||
"@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="],
|
||||
@@ -297,7 +372,7 @@
|
||||
|
||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw=="],
|
||||
|
||||
@@ -439,7 +514,9 @@
|
||||
|
||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||
|
||||
@@ -473,35 +550,35 @@
|
||||
|
||||
"@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", {"os":"android","cpu":"arm64"}, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", {"os":"darwin","cpu":"arm64"}, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", {"os":"darwin","cpu":"x64"}, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", {"os":"freebsd","cpu":"x64"}, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", {"os":"linux","cpu":"arm"}, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", {"os":"linux","cpu":"arm64","libc":"glibc"}, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", {"os":"linux","cpu":"arm64","libc":"musl"}, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", {"os":"linux","cpu":"ppc64","libc":"glibc"}, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", {"os":"linux","cpu":"s390x","libc":"glibc"}, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", {"os":"linux","cpu":"x64","libc":"glibc"}, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", {"os":"linux","cpu":"x64","libc":"musl"}, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", {"os":"openharmony","cpu":"arm64"}, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", {"dependencies":{"@napi-rs/wasm-runtime":"^1.1.1"},"cpu":"wasm32"}, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", {"os":"win32","cpu":"arm64"}, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", {"os":"win32","cpu":"x64"}, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
||||
|
||||
@@ -535,6 +612,8 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
|
||||
@@ -743,6 +822,8 @@
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clawhub": ["clawhub@workspace:packages/clawhub"],
|
||||
|
||||
"clawhub-schema": ["clawhub-schema@workspace:packages/schema"],
|
||||
@@ -751,6 +832,8 @@
|
||||
|
||||
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
@@ -1095,6 +1178,10 @@
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
"nf3": ["nf3@0.3.13", "", {}, "sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw=="],
|
||||
|
||||
"nitro": ["nitro@3.0.260311-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.4", "db0": "^0.3.4", "env-runner": "^0.1.6", "h3": "^2.0.1-rc.16", "hookable": "^6.0.1", "nf3": "^0.3.11", "ocache": "^0.1.2", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.8", "srvx": "^0.11.9", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.6" }, "peerDependencies": { "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.59.0", "vite": "^7 || ^8 || >=8.0.0-0", "xml2js": "^0.6.2", "zephyr-agent": "^0.1.15" }, "optionalPeers": ["dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-0o0fJ9LUh4WKUqJNX012jyieUOtMCnadkNDWr0mHzdraoHpJP/1CGNefjRyZyMXSpoJfwoWdNEZu2iGf35TUvQ=="],
|
||||
@@ -1147,7 +1234,7 @@
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
|
||||
|
||||
@@ -1205,7 +1292,7 @@
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
|
||||
"rolldown": ["rolldown@1.0.0-rc.12", "", {"dependencies":{"@oxc-project/types":"=0.122.0","@rolldown/pluginutils":"1.0.0-rc.12"},"optionalDependencies":{"@rolldown/binding-darwin-x64":"1.0.0-rc.12","@rolldown/binding-freebsd-x64":"1.0.0-rc.12","@rolldown/binding-wasm32-wasi":"1.0.0-rc.12","@rolldown/binding-darwin-arm64":"1.0.0-rc.12","@rolldown/binding-android-arm64":"1.0.0-rc.12","@rolldown/binding-linux-x64-gnu":"1.0.0-rc.12","@rolldown/binding-linux-x64-musl":"1.0.0-rc.12","@rolldown/binding-win32-x64-msvc":"1.0.0-rc.12","@rolldown/binding-linux-arm64-gnu":"1.0.0-rc.12","@rolldown/binding-linux-ppc64-gnu":"1.0.0-rc.12","@rolldown/binding-linux-s390x-gnu":"1.0.0-rc.12","@rolldown/binding-linux-arm64-musl":"1.0.0-rc.12","@rolldown/binding-win32-arm64-msvc":"1.0.0-rc.12","@rolldown/binding-openharmony-arm64":"1.0.0-rc.12","@rolldown/binding-linux-arm-gnueabihf":"1.0.0-rc.12"},"bin":{"rolldown":"bin/cli.mjs"}}, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
|
||||
|
||||
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
||||
|
||||
@@ -1223,6 +1310,8 @@
|
||||
|
||||
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
|
||||
@@ -1263,6 +1352,8 @@
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
@@ -1307,6 +1398,8 @@
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
|
||||
@@ -1345,7 +1438,7 @@
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="],
|
||||
"vite": ["vite@8.0.5", "", {"dependencies":{"postcss":"^8.5.8","rolldown":"1.0.0-rc.12","picomatch":"^4.0.4","tinyglobby":"^0.2.15","lightningcss":"^1.32.0"},"optionalDependencies":{"fsevents":"~2.3.3"},"peerDependencies":{"tsx":"^4.8.1","jiti":">=1.21.0","less":"^4.0.0","sass":"^1.70.0","yaml":"^2.4.2","stylus":">=0.54.8","terser":"^5.16.0","esbuild":"^0.27.0 || ^0.28.0","sugarss":"^5.0.0","@types/node":"^20.19.0 || >=22.12.0","sass-embedded":"^1.70.0","@vitejs/devtools":"^0.1.0"},"bin":{"vite":"bin/vite.js"}}, "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ=="],
|
||||
|
||||
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
|
||||
|
||||
@@ -1399,10 +1492,14 @@
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
@@ -1415,14 +1512,14 @@
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
@@ -1435,6 +1532,12 @@
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
@@ -1449,6 +1552,8 @@
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
@@ -1491,6 +1596,8 @@
|
||||
|
||||
"jsdom/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
@@ -1502,9 +1609,34 @@
|
||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-toggle-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -77,6 +77,7 @@ import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
|
||||
import type * as lib_searchText from "../lib/searchText.js";
|
||||
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
|
||||
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
|
||||
import type * as lib_skillCapabilityTags from "../lib/skillCapabilityTags.js";
|
||||
import type * as lib_skillPublish from "../lib/skillPublish.js";
|
||||
import type * as lib_skillQuality from "../lib/skillQuality.js";
|
||||
import type * as lib_skillSafety from "../lib/skillSafety.js";
|
||||
@@ -193,6 +194,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/searchText": typeof lib_searchText;
|
||||
"lib/securityPrompt": typeof lib_securityPrompt;
|
||||
"lib/skillBackfill": typeof lib_skillBackfill;
|
||||
"lib/skillCapabilityTags": typeof lib_skillCapabilityTags;
|
||||
"lib/skillPublish": typeof lib_skillPublish;
|
||||
"lib/skillQuality": typeof lib_skillQuality;
|
||||
"lib/skillSafety": typeof lib_skillSafety;
|
||||
|
||||
@@ -237,6 +237,74 @@ xuezh snapshot --profile default
|
||||
xuezh review next --limit 10
|
||||
xuezh audio process-voice --file ./utterance.wav
|
||||
\`\`\`
|
||||
`,
|
||||
},
|
||||
{
|
||||
slug: "hanzi-helper",
|
||||
displayName: "汉字助手",
|
||||
summary: "汉字学习与分析工具,支持笔画查询、部首检索和组词生成。",
|
||||
version: "0.1.0",
|
||||
metadata: {
|
||||
clawdbot: {
|
||||
nix: {
|
||||
plugin: "github:example/hanzi-helper",
|
||||
systems: ["aarch64-darwin", "x86_64-linux"],
|
||||
},
|
||||
config: {
|
||||
requiredEnv: ["HANZI_DB_PATH"],
|
||||
stateDirs: [".config/hanzi"],
|
||||
example:
|
||||
'config = { env = { HANZI_DB_PATH = ".config/hanzi/db"; }; stateDirs = [ ".config/hanzi" ]; };',
|
||||
},
|
||||
cliHelp: `汉字助手 - Chinese character learning and analysis
|
||||
|
||||
Usage:
|
||||
hanzi-helper [command]
|
||||
|
||||
Available Commands:
|
||||
lookup 查询汉字信息(笔画、部首、释义)
|
||||
radical 按部首检索汉字
|
||||
stroke 按笔画数筛选汉字
|
||||
words 生成汉字组词
|
||||
practice 练习汉字书写
|
||||
quiz 汉字听写测试
|
||||
|
||||
Flags:
|
||||
-h, --help help for hanzi-helper
|
||||
--json Output JSON
|
||||
`,
|
||||
},
|
||||
},
|
||||
rawSkillMd: `---
|
||||
name: hanzi-helper
|
||||
description: 汉字学习与分析工具,提供笔画查询、部首检索、组词生成和汉字听写练习功能。
|
||||
---
|
||||
|
||||
# 汉字助手
|
||||
|
||||
## 功能介绍
|
||||
|
||||
汉字助手是一个强大的中文汉字学习工具,帮助用户深入了解每个汉字的结构和含义。
|
||||
|
||||
## CLI
|
||||
|
||||
\`\`\`bash
|
||||
hanzi-helper lookup --char 学
|
||||
hanzi-helper radical --name 木
|
||||
hanzi-helper stroke --count 8
|
||||
hanzi-helper words --char 大 --limit 20
|
||||
\`\`\`
|
||||
|
||||
## 使用场景
|
||||
|
||||
- **汉字查询**:输入任意汉字,查看笔画数、部首、繁体形式和基本释义
|
||||
- **部首检索**:按部首浏览相关汉字,了解汉字的分类规律
|
||||
- **组词生成**:输入一个汉字,自动生成常用词语和成语
|
||||
- **听写练习**:随机生成汉字听写测试,巩固学习效果
|
||||
|
||||
## 学习建议
|
||||
|
||||
建议每天学习五个新汉字,结合组词和例句加深记忆。坚持使用听写练习功能可以有效提高汉字识别能力。
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -448,6 +448,22 @@ const EXTRA_SEED_SKILLS: SeedSkillSpec[] = [
|
||||
["SSH_KEY_DIR"],
|
||||
["generate", "rotate", "deploy", "list", "revoke"],
|
||||
),
|
||||
|
||||
// CJK Language Support (2)
|
||||
makeSkill(
|
||||
"nihongo-check",
|
||||
"日本語チェッカー",
|
||||
"日本語文章の文法チェックと翻訳支援ツール。Japanese grammar checker and translation assistant.",
|
||||
["NIHONGO_API_KEY"],
|
||||
["check", "translate", "kanji", "grammar", "vocabulary"],
|
||||
),
|
||||
makeSkill(
|
||||
"hangukgeo-helper",
|
||||
"한국어 도우미",
|
||||
"한국어 학습 보조 도구입니다. Korean language learning assistant with vocabulary and grammar support.",
|
||||
["HANGUL_API_KEY"],
|
||||
["learn", "quiz", "vocabulary", "grammar", "pronunciation"],
|
||||
),
|
||||
];
|
||||
|
||||
function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
|
||||
|
||||
@@ -2552,6 +2552,51 @@ describe("httpApiV1 handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("packages detail returns stats for plugins", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:demo-plugin",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
summary: "Plugin summary",
|
||||
latestVersion: "1.2.3",
|
||||
stats: { downloads: 7, installs: 3, stars: 2, versions: 4 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "users:owner", handle: "owner", displayName: "Owner" },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin"),
|
||||
);
|
||||
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
latestVersion: "1.2.3",
|
||||
stats: { downloads: 7, installs: 3, stars: 2, versions: 4 },
|
||||
},
|
||||
owner: {
|
||||
handle: "owner",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("packages file serves SKILL.md for skill README requests", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) return null;
|
||||
|
||||
@@ -1433,6 +1433,7 @@ type PublicPackageDocLike = {
|
||||
compatibility?: Doc<"packages">["compatibility"];
|
||||
capabilities?: Doc<"packages">["capabilities"];
|
||||
verification?: Doc<"packages">["verification"];
|
||||
stats?: { downloads: number; installs: number; stars: number; versions: number };
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
|
||||
import { applyRateLimit, parseBearerToken } from "../lib/httpRateLimit";
|
||||
import { parseBooleanQueryParam, resolveBooleanQueryParam } from "../lib/httpUtils";
|
||||
import type { LlmEvalDimension } from "../lib/securityPrompt";
|
||||
import { publishVersionForUser } from "../skills";
|
||||
import {
|
||||
MAX_RAW_FILE_BYTES,
|
||||
@@ -206,7 +207,7 @@ type SkillSecuritySnapshot = {
|
||||
normalizedStatus: NormalizedSecurityStatus;
|
||||
confidence: string | null;
|
||||
summary: string | null;
|
||||
dimensions: NonNullable<Doc<"skillVersions">["llmAnalysis"]>["dimensions"] | null;
|
||||
dimensions: LlmEvalDimension[] | null;
|
||||
guidance: string | null;
|
||||
findings: string | null;
|
||||
model: string | null;
|
||||
@@ -262,7 +263,7 @@ function mergeSecurityStatuses(statuses: NormalizedSecurityStatus[]) {
|
||||
}
|
||||
|
||||
function hasLlmDimensionWarnings(
|
||||
dimensions: NonNullable<Doc<"skillVersions">["llmAnalysis"]>["dimensions"] | undefined,
|
||||
dimensions: LlmEvalDimension[] | undefined,
|
||||
) {
|
||||
if (!Array.isArray(dimensions)) return false;
|
||||
return dimensions.some((dimension) => {
|
||||
|
||||
@@ -22,7 +22,10 @@ export async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
skillId: skill._id,
|
||||
});
|
||||
return json(result, 200, rate.headers);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Skill not found") {
|
||||
return text("Skill not found", 404, rate.headers);
|
||||
}
|
||||
return text("Unauthorized", 401, rate.headers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,173 @@ describe("moderationEngine", () => {
|
||||
expect(result.status).toBe("suspicious");
|
||||
});
|
||||
|
||||
it("flags raw user placeholders embedded in generated Python source within markdown", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "word-document-organizer",
|
||||
displayName: "Word Document Organizer",
|
||||
summary: "Organize and restyle Word documents",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 512 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Generate a Python helper like this:",
|
||||
"```python",
|
||||
'doc_path = "${document_path}"',
|
||||
'output_path = "${output_path}" if "${output_path}" else doc_path',
|
||||
'template = "${style_template}"',
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.generated_source_template_injection");
|
||||
expect(result.status).toBe("suspicious");
|
||||
});
|
||||
|
||||
it("does not flag ordinary placeholder usage outside generated source assignments", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-docs",
|
||||
displayName: "API Docs",
|
||||
summary: "Shows users how to call an API",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Use this request template:",
|
||||
"```bash",
|
||||
'curl "https://example.com/search?q=${query}"',
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).not.toContain("suspicious.generated_source_template_injection");
|
||||
expect(result.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("flags hardcoded connection_id UUIDs in markdown examples", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Use this payload:",
|
||||
"```json",
|
||||
'{"connection_id": "21fd90f9-5935-43cd-b6c8-bde9d915ca80"}',
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
|
||||
expect(result.status).toBe("suspicious");
|
||||
expect(
|
||||
result.findings.find((finding) => finding.message.includes("connection_id"))?.message,
|
||||
).toContain("connection_id");
|
||||
});
|
||||
|
||||
it("flags hardcoded Google Sheets spreadsheet IDs in markdown examples", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Call the Sheets bridge like this:",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/122BS1sFN2RKL8AOUQjkLdubzOwgqzPT64KfZ2rvYI4M/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
|
||||
expect(result.status).toBe("suspicious");
|
||||
expect(
|
||||
result.findings.find((finding) => finding.message.includes("spreadsheet ID"))?.message,
|
||||
).toContain("spreadsheet ID");
|
||||
});
|
||||
|
||||
it("does not flag placeholder resource identifiers in markdown examples", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Use placeholders in public docs:",
|
||||
"```json",
|
||||
'{"connection_id": "YOUR_CONNECTION_ID"}',
|
||||
"```",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/YOUR_SPREADSHEET_ID/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).not.toContain("suspicious.exposed_resource_identifier");
|
||||
expect(result.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("flags a real spreadsheet ID even when a placeholder URL appears first", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 512 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Placeholder example first:",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/YOUR_SPREADSHEET_ID/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
"Real leaked URL later:",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/122BS1sFN2RKL8AOUQjkLdubzOwgqzPT64KfZ2rvYI4M/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
|
||||
expect(
|
||||
result.findings.find((finding) => finding.message.includes("spreadsheet ID"))?.line,
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it("blocks obfuscated terminal install payload prompts in markdown", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "evil-installer",
|
||||
|
||||
@@ -50,6 +50,14 @@ const CODE_EXTENSION = /\.(js|ts|mjs|cjs|mts|cts|jsx|tsx|py|sh|bash|zsh|rb|go)$/
|
||||
const STANDARD_PORTS = new Set([80, 443, 8080, 8443, 3000]);
|
||||
const RAW_IP_URL_PATTERN = /https?:\/\/\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:\/|["'])/i;
|
||||
const INSTALL_PACKAGE_PATTERN = /installer-package\s*:\s*https?:\/\/[^\s"'`]+/i;
|
||||
const GENERATED_SOURCE_PLACEHOLDER_PATTERN =
|
||||
/^\s*[A-Za-z_][A-Za-z0-9_]*\s*=.*["']\$\{[A-Za-z_][A-Za-z0-9_-]*\}["']/m;
|
||||
const GENERATED_SOURCE_CONTEXT_PATTERN =
|
||||
/```(?:python|py|javascript|js|typescript|ts|shell|bash|sh)\b|cat\s*(?:>|>>)?\s*[^`\n]*\.(?:py|js|ts|sh)\b|python3?\b|node\b/i;
|
||||
const HARDCODED_CONNECTION_ID_PATTERN =
|
||||
/["']connection_id["']\s*:\s*["'][0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}["']/i;
|
||||
const GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN =
|
||||
/https?:\/\/[^\s"'`]*\/spreadsheets\/([A-Za-z0-9_-]{20,})\/[^\s"'`]*/i;
|
||||
|
||||
function hasMaliciousInstallPrompt(content: string) {
|
||||
const hasTerminalInstruction =
|
||||
@@ -75,6 +83,10 @@ function truncateEvidence(evidence: string, maxLen = 160) {
|
||||
return `${evidence.slice(0, maxLen)}...`;
|
||||
}
|
||||
|
||||
function looksLikePlaceholderIdentifier(identifier: string) {
|
||||
return /^[A-Z0-9_]+$/.test(identifier) || /(your|example|placeholder)/i.test(identifier);
|
||||
}
|
||||
|
||||
function addFinding(
|
||||
findings: ModerationFinding[],
|
||||
finding: Omit<ModerationFinding, "evidence"> & { evidence: string },
|
||||
@@ -92,6 +104,14 @@ function findFirstLine(content: string, pattern: RegExp) {
|
||||
return { line: 1, text: lines[0] ?? "" };
|
||||
}
|
||||
|
||||
function findLineAtIndex(content: string, index: number) {
|
||||
const line = content.slice(0, index).split("\n").length;
|
||||
const lineStart = content.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
|
||||
const nextNewline = content.indexOf("\n", index);
|
||||
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
|
||||
return { line, text: content.slice(lineStart, lineEnd) };
|
||||
}
|
||||
|
||||
function scanCodeFile(path: string, content: string, findings: ModerationFinding[]) {
|
||||
if (!CODE_EXTENSION.test(path)) return;
|
||||
|
||||
@@ -227,6 +247,53 @@ function scanMarkdownFile(path: string, content: string, findings: ModerationFin
|
||||
evidence: match.text,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
GENERATED_SOURCE_PLACEHOLDER_PATTERN.test(content) &&
|
||||
GENERATED_SOURCE_CONTEXT_PATTERN.test(content)
|
||||
) {
|
||||
const match = findFirstLine(content, GENERATED_SOURCE_PLACEHOLDER_PATTERN);
|
||||
addFinding(findings, {
|
||||
code: REASON_CODES.GENERATED_SOURCE_TEMPLATE,
|
||||
severity: "critical",
|
||||
file: path,
|
||||
line: match.line,
|
||||
message: "User-controlled placeholder is embedded directly into generated source code.",
|
||||
evidence: match.text,
|
||||
});
|
||||
}
|
||||
|
||||
if (HARDCODED_CONNECTION_ID_PATTERN.test(content)) {
|
||||
const match = findFirstLine(content, HARDCODED_CONNECTION_ID_PATTERN);
|
||||
addFinding(findings, {
|
||||
code: REASON_CODES.EXPOSED_RESOURCE_IDENTIFIER,
|
||||
severity: "critical",
|
||||
file: path,
|
||||
line: match.line,
|
||||
message: "Example code exposes a concrete connection_id instead of a placeholder.",
|
||||
evidence: match.text,
|
||||
});
|
||||
}
|
||||
|
||||
const spreadsheetUrlPattern = new RegExp(
|
||||
GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN.source,
|
||||
`${GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN.flags.replaceAll("g", "")}g`,
|
||||
);
|
||||
for (const spreadsheetUrlMatch of content.matchAll(spreadsheetUrlPattern)) {
|
||||
const spreadsheetId = spreadsheetUrlMatch[1];
|
||||
if (!spreadsheetId || looksLikePlaceholderIdentifier(spreadsheetId)) continue;
|
||||
|
||||
const match = findLineAtIndex(content, spreadsheetUrlMatch.index ?? 0);
|
||||
addFinding(findings, {
|
||||
code: REASON_CODES.EXPOSED_RESOURCE_IDENTIFIER,
|
||||
severity: "critical",
|
||||
file: path,
|
||||
line: match.line,
|
||||
message: "Example code exposes a concrete Google Sheets spreadsheet ID instead of a placeholder.",
|
||||
evidence: match.text,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function scanManifestFile(path: string, content: string, findings: ModerationFinding[]) {
|
||||
|
||||
@@ -12,11 +12,13 @@ export type ModerationFinding = {
|
||||
evidence: string;
|
||||
};
|
||||
|
||||
export const MODERATION_ENGINE_VERSION = "v2.2.0";
|
||||
export const MODERATION_ENGINE_VERSION = "v2.4.0";
|
||||
|
||||
export const REASON_CODES = {
|
||||
DANGEROUS_EXEC: "suspicious.dangerous_exec",
|
||||
DYNAMIC_CODE: "suspicious.dynamic_code_execution",
|
||||
GENERATED_SOURCE_TEMPLATE: "suspicious.generated_source_template_injection",
|
||||
EXPOSED_RESOURCE_IDENTIFIER: "suspicious.exposed_resource_identifier",
|
||||
CREDENTIAL_HARVEST: "suspicious.env_credential_access",
|
||||
EXFILTRATION: "suspicious.potential_exfiltration",
|
||||
OBFUSCATED_CODE: "suspicious.obfuscated_code",
|
||||
|
||||
@@ -5,6 +5,8 @@ function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys
|
||||
return Object.fromEntries(keys.map((key) => [key, obj[key]])) as Pick<T, K>;
|
||||
}
|
||||
|
||||
type SharedPackageKey = Extract<keyof Doc<"packages">, keyof Doc<"packageSearchDigest">>;
|
||||
|
||||
const SHARED_KEYS = [
|
||||
"name",
|
||||
"normalizedName",
|
||||
@@ -22,7 +24,7 @@ const SHARED_KEYS = [
|
||||
"softDeletedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
] as const satisfies readonly (keyof Doc<"packages"> & keyof Doc<"packageSearchDigest">)[];
|
||||
] as const satisfies readonly SharedPackageKey[];
|
||||
|
||||
const CAPABILITY_SHARED_KEYS = [
|
||||
"packageId",
|
||||
|
||||
@@ -47,4 +47,55 @@ describe("searchText", () => {
|
||||
it("normalize uses lowercase", () => {
|
||||
expect(__test.normalize("AbC")).toBe("abc");
|
||||
});
|
||||
|
||||
// CJK (Chinese, Japanese, Korean) support tests
|
||||
describe("CJK tokenization", () => {
|
||||
it("tokenizes Chinese text using Intl.Segmenter", () => {
|
||||
const tokens = tokenize("中文搜索");
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
expect(tokens).toContain("中文");
|
||||
expect(tokens).toContain("搜索");
|
||||
});
|
||||
|
||||
it("tokenizes mixed Chinese and English text", () => {
|
||||
const tokens = tokenize("React 组件开发");
|
||||
expect(tokens).toContain("react");
|
||||
expect(tokens.some((t) => t.includes("组") || t.includes("件"))).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Chinese query tokens against Chinese skill names", () => {
|
||||
const queryTokens = tokenize("翻译");
|
||||
const skillName = "AI翻译助手";
|
||||
expect(matchesExactTokens(queryTokens, [skillName])).toBe(true);
|
||||
});
|
||||
|
||||
it("matches partial Chinese words", () => {
|
||||
const queryTokens = tokenize("助手");
|
||||
const skillName = "AI翻译助手";
|
||||
expect(matchesExactTokens(queryTokens, [skillName])).toBe(true);
|
||||
});
|
||||
|
||||
it("handles Japanese text", () => {
|
||||
const tokens = tokenize("こんにちは世界");
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles Korean text", () => {
|
||||
const tokens = tokenize("안녕하세요");
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns empty array for empty or whitespace-only input", () => {
|
||||
expect(tokenize("")).toEqual([]);
|
||||
expect(tokenize(" ")).toEqual([]);
|
||||
expect(tokenize("!!!")).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects CJK language correctly", () => {
|
||||
expect(__test.detectCJKLanguage("中文")).toBe("zh");
|
||||
expect(__test.detectCJKLanguage("こんにちは")).toBe("ja");
|
||||
expect(__test.detectCJKLanguage("안녕하세요")).toBe("ko");
|
||||
expect(__test.detectCJKLanguage("hello")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+130
-3
@@ -1,12 +1,135 @@
|
||||
const WORD_RE = /[a-z0-9]+/g;
|
||||
const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf\u3041-\u3096\u30a1-\u30fa\uac00-\ud7af]/;
|
||||
|
||||
const hasSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl;
|
||||
|
||||
let zhSegmenter: Intl.Segmenter | null = null;
|
||||
let jaSegmenter: Intl.Segmenter | null = null;
|
||||
let koSegmenter: Intl.Segmenter | null = null;
|
||||
|
||||
function getZhSegmenter(): Intl.Segmenter {
|
||||
if (!zhSegmenter) {
|
||||
zhSegmenter = new Intl.Segmenter("zh-CN", { granularity: "word" });
|
||||
}
|
||||
return zhSegmenter;
|
||||
}
|
||||
|
||||
function getJaSegmenter(): Intl.Segmenter {
|
||||
if (!jaSegmenter) {
|
||||
jaSegmenter = new Intl.Segmenter("ja", { granularity: "word" });
|
||||
}
|
||||
return jaSegmenter;
|
||||
}
|
||||
|
||||
function getKoSegmenter(): Intl.Segmenter {
|
||||
if (!koSegmenter) {
|
||||
koSegmenter = new Intl.Segmenter("ko", { granularity: "word" });
|
||||
}
|
||||
return koSegmenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: split CJK text into individual characters.
|
||||
* Used when Intl.Segmenter is unavailable (e.g. stripped V8 runtime).
|
||||
*/
|
||||
function segmentCJKByChar(text: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
for (const ch of text) {
|
||||
if (CJK_RE.test(ch)) {
|
||||
tokens.push(ch);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function normalize(value: string) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the primary CJK language in a text
|
||||
* Returns 'zh' for Chinese, 'ja' for Japanese, 'ko' for Korean, or null
|
||||
*/
|
||||
function detectCJKLanguage(text: string): "zh" | "ja" | "ko" | null {
|
||||
const chineseCount = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
|
||||
const hiraganaCount = (text.match(/[\u3040-\u309f]/g) || []).length;
|
||||
const katakanaCount = (text.match(/[\u30a0-\u30ff]/g) || []).length;
|
||||
const hangulCount = (text.match(/[\uac00-\ud7af]/g) || []).length;
|
||||
if (hiraganaCount + katakanaCount > 0) {
|
||||
return "ja";
|
||||
}
|
||||
if (hangulCount > 0) {
|
||||
return "ko";
|
||||
}
|
||||
if (chineseCount > 0) {
|
||||
return "zh";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segment CJK text using Intl.Segmenter, falling back to character-level
|
||||
* tokenization when the API is unavailable.
|
||||
*/
|
||||
function segmentCJK(text: string): string[] {
|
||||
if (!hasSegmenter) return segmentCJKByChar(text);
|
||||
|
||||
const lang = detectCJKLanguage(text);
|
||||
if (!lang) return [];
|
||||
|
||||
let segmenter: Intl.Segmenter;
|
||||
switch (lang) {
|
||||
case "ja":
|
||||
segmenter = getJaSegmenter();
|
||||
break;
|
||||
case "ko":
|
||||
segmenter = getKoSegmenter();
|
||||
break;
|
||||
default:
|
||||
segmenter = getZhSegmenter();
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
for (const { segment, isWordLike } of segmenter.segment(text)) {
|
||||
const trimmed = segment.trim();
|
||||
if (trimmed && isWordLike) {
|
||||
segments.push(trimmed);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize text for search, supporting both English and CJK languages
|
||||
*
|
||||
* For English: uses word boundaries (whitespace, punctuation)
|
||||
* For CJK: uses Intl.Segmenter for proper word segmentation
|
||||
*/
|
||||
export function tokenize(value: string): string[] {
|
||||
if (!value) return [];
|
||||
return normalize(value).match(WORD_RE) ?? [];
|
||||
|
||||
const normalized = normalize(value);
|
||||
|
||||
if (!CJK_RE.test(normalized)) {
|
||||
return normalized.match(/[a-z0-9]+/g) ?? [];
|
||||
}
|
||||
|
||||
const tokens: string[] = [];
|
||||
|
||||
const parts = normalized.split(/([^\u4e00-\u9fff\u3400-\u4dbf\u3041-\u3096\u30a1-\u30fa\uac00-\ud7af]+)/g);
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part.trim()) continue;
|
||||
|
||||
if (CJK_RE.test(part)) {
|
||||
const cjkTokens = segmentCJK(part);
|
||||
tokens.push(...cjkTokens);
|
||||
} else {
|
||||
const asciiTokens = part.match(/[a-z0-9]+/g) ?? [];
|
||||
tokens.push(...asciiTokens);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function matchesExactTokens(
|
||||
@@ -24,4 +147,8 @@ export function matchesExactTokens(
|
||||
);
|
||||
}
|
||||
|
||||
export const __test = { normalize, tokenize, matchesExactTokens };
|
||||
export const __test = {
|
||||
normalize,
|
||||
detectCJKLanguage,
|
||||
segmentCJKByChar,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys
|
||||
return Object.fromEntries(keys.map((k) => [k, obj[k]])) as Pick<T, K>;
|
||||
}
|
||||
|
||||
type SharedSkillKey = Extract<keyof Doc<"skills">, keyof Doc<"skillSearchDigest">>;
|
||||
|
||||
/**
|
||||
* Fields shared 1:1 between `skills` and `skillSearchDigest` (same name,
|
||||
* same type). Used by both `extractDigestFields` and `digestToHydratableSkill`
|
||||
@@ -35,7 +37,7 @@ const SHARED_KEYS = [
|
||||
"moderationReason",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
] as const satisfies readonly (keyof Doc<"skills"> & keyof Doc<"skillSearchDigest">)[];
|
||||
] as const satisfies readonly SharedSkillKey[];
|
||||
|
||||
/** Fields stored in the skillSearchDigest table. */
|
||||
export type SkillSearchDigestFields = Pick<Doc<"skills">, (typeof SHARED_KEYS)[number]> & {
|
||||
|
||||
+25
-11
@@ -4,6 +4,7 @@ import {
|
||||
type PackageChannel,
|
||||
type PackageFamily,
|
||||
type PackagePublishRequest,
|
||||
type PackageVerificationTier,
|
||||
} from "clawhub-schema";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
@@ -127,7 +128,7 @@ type PublicPackageListItem = {
|
||||
latestVersion: string | null;
|
||||
capabilityTags: string[];
|
||||
executesCode: boolean;
|
||||
verificationTier: Doc<"packageSearchDigest">["verificationTier"] | null;
|
||||
verificationTier: PackageVerificationTier | null;
|
||||
};
|
||||
type PackageDigestLike = Pick<
|
||||
Doc<"packageSearchDigest">,
|
||||
@@ -163,6 +164,14 @@ type PublicPageCursorState = {
|
||||
};
|
||||
const PUBLIC_PAGE_CURSOR_PREFIX = "pkgpage:";
|
||||
|
||||
function stringifyId(value: Id<"users"> | Id<"publishers">): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringifyOptionalId(value: Id<"publishers"> | null | undefined): string | null {
|
||||
return value ? stringifyId(value) : null;
|
||||
}
|
||||
|
||||
async function runQueryRef<T>(
|
||||
ctx: { runQuery: (ref: never, args: never) => Promise<unknown> },
|
||||
ref: unknown,
|
||||
@@ -212,6 +221,7 @@ type PublicPackageDoc = {
|
||||
capabilities?: Doc<"packages">["capabilities"];
|
||||
verification?: Doc<"packages">["verification"];
|
||||
scanStatus?: Doc<"packages">["scanStatus"];
|
||||
stats: Doc<"packages">["stats"];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
@@ -317,6 +327,7 @@ function toPublicPackage(
|
||||
capabilities: pkg.capabilities,
|
||||
verification: pkg.verification,
|
||||
scanStatus: pkg.scanStatus,
|
||||
stats: pkg.stats,
|
||||
createdAt: pkg.createdAt,
|
||||
updatedAt: pkg.updatedAt,
|
||||
};
|
||||
@@ -2133,6 +2144,11 @@ export const insertReleaseInternal = internalMutation({
|
||||
args.channel ??
|
||||
(existing?.channel === "private" ? "private" : publisherTrusted ? "official" : "community");
|
||||
const nextIsOfficial = nextChannel === "official";
|
||||
const nextOwnerPublisherId = stringifyOptionalId(args.ownerPublisherId ?? null);
|
||||
const nextOwnerUserId = stringifyId(args.ownerUserId);
|
||||
const nextName = args.name;
|
||||
const nextRuntimeId = args.runtimeId ?? null;
|
||||
const nextVersion = args.version;
|
||||
if (existing) {
|
||||
const existingIsLegacyPersonalPackage =
|
||||
!existing.ownerPublisherId &&
|
||||
@@ -2144,18 +2160,18 @@ export const insertReleaseInternal = internalMutation({
|
||||
const existingOwnerKey = existing.ownerPublisherId
|
||||
? `publisher:${existing.ownerPublisherId}`
|
||||
: existingIsLegacyPersonalPackage
|
||||
? `publisher:${args.ownerPublisherId}`
|
||||
? `publisher:${nextOwnerPublisherId}`
|
||||
: `user:${existing.ownerUserId}`;
|
||||
const nextOwnerKey = args.ownerPublisherId
|
||||
? `publisher:${args.ownerPublisherId}`
|
||||
: `user:${args.ownerUserId}`;
|
||||
const nextOwnerKey = nextOwnerPublisherId
|
||||
? `publisher:${nextOwnerPublisherId}`
|
||||
: `user:${nextOwnerUserId}`;
|
||||
if (existingOwnerKey !== nextOwnerKey) {
|
||||
throw new ConvexError("Package already exists and belongs to another publisher");
|
||||
}
|
||||
}
|
||||
if (existing && existing.family !== args.family) {
|
||||
throw new ConvexError(
|
||||
`Package "${args.name}" already exists as a ${existing.family}; family changes are not allowed`,
|
||||
`Package "${nextName}" already exists as a ${existing.family}; family changes are not allowed`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -2166,7 +2182,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
existing.runtimeId !== args.runtimeId
|
||||
) {
|
||||
throw new ConvexError(
|
||||
`Package "${args.name}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
|
||||
`Package "${nextName}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
|
||||
);
|
||||
}
|
||||
if (args.family === "code-plugin" && args.runtimeId) {
|
||||
@@ -2175,9 +2191,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
.withIndex("by_runtime_id", (q) => q.eq("runtimeId", args.runtimeId))
|
||||
.unique();
|
||||
if (runtimeCollision && runtimeCollision._id !== existing?._id) {
|
||||
throw new ConvexError(
|
||||
`Plugin id "${args.runtimeId}" is already claimed by another package`,
|
||||
);
|
||||
throw new ConvexError(`Plugin id "${nextRuntimeId}" is already claimed by another package`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2214,7 +2228,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
q.eq("packageId", existing._id).eq("version", args.version),
|
||||
)
|
||||
.unique();
|
||||
if (releaseExists) throw new ConvexError(`Version ${args.version} already exists`);
|
||||
if (releaseExists) throw new ConvexError(`Version ${nextVersion} already exists`);
|
||||
}
|
||||
const priorReleases = existing
|
||||
? await ctx.db
|
||||
|
||||
+2
-1
@@ -388,7 +388,8 @@ const souls = defineTable({
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_owner", ["ownerUserId"])
|
||||
.index("by_owner_publisher", ["ownerPublisherId"])
|
||||
.index("by_updated", ["updatedAt"]);
|
||||
.index("by_updated", ["updatedAt"])
|
||||
.index("by_active_updated", ["softDeletedAt", "updatedAt"]);
|
||||
|
||||
const skillVersions = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
|
||||
@@ -5,7 +5,10 @@ vi.mock("@convex-dev/auth/server", () => ({
|
||||
authTables: {},
|
||||
}));
|
||||
|
||||
import { getPendingScanSkillsInternal } from "./skills";
|
||||
import {
|
||||
getActiveSkillBatchForStaticScanBackfillInternal,
|
||||
getPendingScanSkillsInternal,
|
||||
} from "./skills";
|
||||
|
||||
type PendingScanResult = Array<{
|
||||
skillId: string;
|
||||
@@ -25,6 +28,17 @@ const getPendingScanSkillsHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const getStaticScanBackfillBatchHandler = (
|
||||
getActiveSkillBatchForStaticScanBackfillInternal as unknown as WrappedHandler<
|
||||
Record<string, unknown>,
|
||||
{
|
||||
skills: Array<{ skillId: string; versionId: string; slug: string }>;
|
||||
nextCursor: number;
|
||||
done: boolean;
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
|
||||
describe("skills.getPendingScanSkillsInternal", () => {
|
||||
it("includes unresolved VT records from the oldest slice and skips finalized ones", async () => {
|
||||
const recentSkills = [
|
||||
@@ -215,6 +229,114 @@ describe("skills.getPendingScanSkillsInternal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("skills.getActiveSkillBatchForStaticScanBackfillInternal", () => {
|
||||
it("includes latest active skills with missing or stale static scan engine versions", async () => {
|
||||
const skills = [
|
||||
{
|
||||
_id: "skills:missing-static",
|
||||
_creationTime: 10,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
latestVersionId: "skillVersions:missing-static",
|
||||
slug: "missing-static",
|
||||
},
|
||||
{
|
||||
_id: "skills:stale-static",
|
||||
_creationTime: 20,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
latestVersionId: "skillVersions:stale-static",
|
||||
slug: "stale-static",
|
||||
},
|
||||
{
|
||||
_id: "skills:current-static",
|
||||
_creationTime: 30,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
latestVersionId: "skillVersions:current-static",
|
||||
slug: "current-static",
|
||||
},
|
||||
{
|
||||
_id: "skills:hidden-static",
|
||||
_creationTime: 40,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "hidden",
|
||||
latestVersionId: "skillVersions:hidden-static",
|
||||
slug: "hidden-static",
|
||||
},
|
||||
];
|
||||
|
||||
const versions = new Map<string, unknown>([
|
||||
["skillVersions:missing-static", { _id: "skillVersions:missing-static" }],
|
||||
[
|
||||
"skillVersions:stale-static",
|
||||
{
|
||||
_id: "skillVersions:stale-static",
|
||||
staticScan: { engineVersion: "v2.2.0" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"skillVersions:current-static",
|
||||
{
|
||||
_id: "skillVersions:current-static",
|
||||
staticScan: { engineVersion: "v2.4.0" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"skillVersions:hidden-static",
|
||||
{
|
||||
_id: "skillVersions:hidden-static",
|
||||
staticScan: { engineVersion: "v2.2.0" },
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "skills") throw new Error(`unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (
|
||||
indexName: string,
|
||||
builder: (q: { gt: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
builder({ gt: () => ({}) });
|
||||
if (indexName !== "by_creation_time") {
|
||||
throw new Error(`unexpected index ${indexName}`);
|
||||
}
|
||||
return {
|
||||
order: () => ({
|
||||
take: async () => skills,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}),
|
||||
get: vi.fn(async (id: string) => versions.get(id) ?? null),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getStaticScanBackfillBatchHandler(ctx, {
|
||||
batchSize: 10,
|
||||
cursor: 0,
|
||||
});
|
||||
|
||||
expect(result.skills).toEqual([
|
||||
{
|
||||
skillId: "skills:missing-static",
|
||||
versionId: "skillVersions:missing-static",
|
||||
slug: "missing-static",
|
||||
},
|
||||
{
|
||||
skillId: "skills:stale-static",
|
||||
versionId: "skillVersions:stale-static",
|
||||
slug: "stale-static",
|
||||
},
|
||||
]);
|
||||
expect(result.done).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function makeSkill(
|
||||
id: string,
|
||||
versionId: string,
|
||||
|
||||
+218
-3
@@ -38,6 +38,7 @@ import { deriveModerationFlags } from "./lib/moderation";
|
||||
import { buildModerationSnapshot } from "./lib/moderationEngine";
|
||||
import {
|
||||
legacyFlagsFromVerdict,
|
||||
MODERATION_ENGINE_VERSION,
|
||||
summarizeReasonCodes,
|
||||
verdictFromCodes,
|
||||
} from "./lib/moderationReasonCodes";
|
||||
@@ -73,6 +74,7 @@ import {
|
||||
publishVersionForUser,
|
||||
queueHighlightedWebhook,
|
||||
} from "./lib/skillPublish";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
|
||||
import { computeIsSuspicious, isSkillSuspicious } from "./lib/skillSafety";
|
||||
import {
|
||||
@@ -460,7 +462,7 @@ async function syncSkillModerationFromLatestVersion(
|
||||
|
||||
function buildConflictingSkillUrl(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
owner: SkillOwnerRef,
|
||||
) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null;
|
||||
const ownerParam = owner.handle?.trim() || String(owner._id);
|
||||
@@ -470,7 +472,7 @@ function buildConflictingSkillUrl(
|
||||
|
||||
function buildSlugTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
owner: SkillOwnerRef,
|
||||
) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
return (
|
||||
@@ -486,7 +488,7 @@ function buildSlugTakenErrorMessage(
|
||||
|
||||
function buildAliasTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
owner: SkillOwnerRef,
|
||||
) {
|
||||
const base = "Slug redirects to an existing skill. Choose a different slug.";
|
||||
const url = buildConflictingSkillUrl(skill, owner);
|
||||
@@ -498,6 +500,16 @@ function normalizeSkillSlugKey(slug: string) {
|
||||
return slug.trim().toLowerCase();
|
||||
}
|
||||
|
||||
type SkillOwnerRef =
|
||||
| {
|
||||
_id: Id<"users"> | Id<"publishers">;
|
||||
handle?: string | null;
|
||||
deletedAt?: number | null;
|
||||
deactivatedAt?: number | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
function normalizeSkillSlugForWrite(slug: string) {
|
||||
const normalized = normalizeSkillSlugKey(slug);
|
||||
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
|
||||
@@ -3686,6 +3698,56 @@ export const getActiveSkillBatchForLlmBackfillInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get active latest skill versions whose static scan is missing or uses an older engine version.
|
||||
* Used to backfill new static rules onto already-published skills.
|
||||
*/
|
||||
export const getActiveSkillBatchForStaticScanBackfillInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.number()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = args.batchSize ?? 25;
|
||||
const cursor = args.cursor ?? 0;
|
||||
|
||||
const candidates = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_creation_time", (q) => q.gt("_creationTime", cursor))
|
||||
.order("asc")
|
||||
.take(batchSize * 4);
|
||||
|
||||
const results: Array<{
|
||||
skillId: Id<"skills">;
|
||||
versionId: Id<"skillVersions">;
|
||||
slug: string;
|
||||
}> = [];
|
||||
let nextCursor = cursor;
|
||||
|
||||
for (const skill of candidates) {
|
||||
nextCursor = skill._creationTime;
|
||||
if (results.length >= batchSize) break;
|
||||
|
||||
if (skill.softDeletedAt) continue;
|
||||
if ((skill.moderationStatus ?? "active") !== "active") continue;
|
||||
if (!skill.latestVersionId) continue;
|
||||
|
||||
const version = await ctx.db.get(skill.latestVersionId);
|
||||
if (!version) continue;
|
||||
if (version.staticScan?.engineVersion === MODERATION_ENGINE_VERSION) continue;
|
||||
|
||||
results.push({
|
||||
skillId: skill._id,
|
||||
versionId: version._id,
|
||||
slug: skill.slug,
|
||||
});
|
||||
}
|
||||
|
||||
const done = candidates.length < batchSize * 4;
|
||||
return { skills: results, nextCursor, done };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get skills with stale moderationReason that have vtAnalysis cached.
|
||||
* Used to sync moderationReason with cached VT results.
|
||||
@@ -3784,6 +3846,159 @@ export const getPendingVTSkillsInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const updateSkillVersionStaticScanInternal = internalMutation({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
staticScan: v.object({
|
||||
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
|
||||
reasonCodes: v.array(v.string()),
|
||||
findings: v.array(
|
||||
v.object({
|
||||
code: v.string(),
|
||||
severity: v.union(v.literal("info"), v.literal("warn"), v.literal("critical")),
|
||||
file: v.string(),
|
||||
line: v.number(),
|
||||
message: v.string(),
|
||||
evidence: v.string(),
|
||||
}),
|
||||
),
|
||||
summary: v.string(),
|
||||
engineVersion: v.string(),
|
||||
checkedAt: v.number(),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const version = await ctx.db.get(args.versionId);
|
||||
if (!version || version.skillId !== args.skillId) return { ok: true as const, skipped: "missing" as const };
|
||||
|
||||
await ctx.db.patch(version._id, {
|
||||
staticScan: args.staticScan,
|
||||
});
|
||||
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) return { ok: true as const, skipped: "missing" as const };
|
||||
if (skill.latestVersionId !== version._id) {
|
||||
return { ok: true as const, skipped: "not_latest" as const };
|
||||
}
|
||||
|
||||
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null;
|
||||
const now = Date.now();
|
||||
const updatedVersion = { ...version, staticScan: args.staticScan };
|
||||
const basePatch = buildScannerModerationPatchFromVersion({
|
||||
owner,
|
||||
version: updatedVersion,
|
||||
now,
|
||||
});
|
||||
const patch = applySkillManualOverrideToSkillPatch({
|
||||
skill,
|
||||
basePatch: {
|
||||
...basePatch,
|
||||
updatedAt: now,
|
||||
},
|
||||
now,
|
||||
});
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
if (patch.moderationVerdict === "malicious" && skill.ownerUserId) {
|
||||
await ctx.scheduler.runAfter(0, internal.users.placeUserUnderModerationInternal, {
|
||||
ownerUserId: skill.ownerUserId,
|
||||
slug: skill.slug,
|
||||
reason:
|
||||
patch.moderationReasonCodes?.find((code) => code.startsWith("malicious.")) ??
|
||||
"malicious.static_scan",
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true as const, status: args.staticScan.status };
|
||||
},
|
||||
});
|
||||
|
||||
export const scanSkillVersionStaticallyInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const [skill, version] = await Promise.all([
|
||||
ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId: args.skillId }),
|
||||
ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId: args.versionId }),
|
||||
]);
|
||||
|
||||
if (!skill || !version) {
|
||||
return { ok: true as const, skipped: "missing" as const };
|
||||
}
|
||||
|
||||
const staticScan = await runStaticPublishScan(ctx, {
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary ?? undefined,
|
||||
frontmatter: version.parsed?.frontmatter ?? {},
|
||||
metadata: version.parsed?.metadata,
|
||||
files: version.files,
|
||||
});
|
||||
|
||||
return await ctx.runMutation(internal.skills.updateSkillVersionStaticScanInternal, {
|
||||
skillId: skill._id,
|
||||
versionId: version._id,
|
||||
staticScan,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const backfillSkillStaticScansInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
cursor: v.optional(v.number()),
|
||||
batchSize: v.optional(v.number()),
|
||||
rescanned: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = Math.max(1, Math.min(args.batchSize ?? 25, 100));
|
||||
const batch = await ctx.runQuery(internal.skills.getActiveSkillBatchForStaticScanBackfillInternal, {
|
||||
cursor: args.cursor,
|
||||
batchSize,
|
||||
});
|
||||
|
||||
let rescanned = args.rescanned ?? 0;
|
||||
for (const skill of batch.skills) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.scanSkillVersionStaticallyInternal, {
|
||||
skillId: skill.skillId,
|
||||
versionId: skill.versionId,
|
||||
});
|
||||
rescanned += 1;
|
||||
}
|
||||
|
||||
if (!batch.done) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.backfillSkillStaticScansInternal, {
|
||||
cursor: batch.nextCursor,
|
||||
batchSize,
|
||||
rescanned,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rescanned,
|
||||
nextCursor: batch.nextCursor,
|
||||
done: batch.done,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const backfillSkillStaticScans: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUserFromAction(ctx);
|
||||
assertAdmin(user);
|
||||
return await ctx.runAction(internal.skills.backfillSkillStaticScansInternal, {
|
||||
batchSize: args.batchSize,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Emergency escalation by skillId for legacy rows without sha256hash.
|
||||
* Rebuilds the full moderation snapshot so legacy rows stay in sync with structured fields.
|
||||
|
||||
+74
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getSoulBySlugInternal, insertVersion } from "./souls";
|
||||
import { getSoulBySlugInternal, insertVersion, list } from "./souls";
|
||||
|
||||
type WrappedHandler<TArgs> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>;
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
|
||||
describe("souls.insertVersion", () => {
|
||||
it("throws a soul-specific ownership error for non-owners", async () => {
|
||||
@@ -139,3 +140,75 @@ describe("souls.insertVersion", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("souls.list", () => {
|
||||
it("uses the active browse index and only takes the requested limit", async () => {
|
||||
let requestedIndex: string | null = null;
|
||||
let requestedSoftDeletedAt: number | undefined;
|
||||
let requestedLimit: number | null = null;
|
||||
|
||||
const result = await listHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "souls") throw new Error(`unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (
|
||||
name: string,
|
||||
build:
|
||||
| ((q: { eq: (field: string, value: undefined) => unknown }) => unknown)
|
||||
| undefined,
|
||||
) => {
|
||||
requestedIndex = name;
|
||||
const q = {
|
||||
eq: (field: string, value: undefined) => {
|
||||
if (field !== "softDeletedAt") throw new Error(`unexpected field ${field}`);
|
||||
requestedSoftDeletedAt = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
build?.(q);
|
||||
return {
|
||||
order: () => ({
|
||||
take: async (limit: number) => {
|
||||
requestedLimit = limit;
|
||||
return [
|
||||
{
|
||||
_id: "souls:1",
|
||||
_creationTime: 1,
|
||||
slug: "demo-soul",
|
||||
displayName: "Demo Soul",
|
||||
summary: "A demo soul",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
softDeletedAt: undefined,
|
||||
stats: { downloads: 1, stars: 2, versions: 3, comments: 4 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
{ limit: 7 } as never,
|
||||
);
|
||||
|
||||
expect(requestedIndex).toBe("by_active_updated");
|
||||
expect(requestedSoftDeletedAt).toBeUndefined();
|
||||
expect(requestedLimit).toBe(7);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
_id: "souls:1",
|
||||
slug: "demo-soul",
|
||||
displayName: "Demo Soul",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+2
-3
@@ -138,11 +138,10 @@ export const list = query({
|
||||
}
|
||||
const entries = await ctx.db
|
||||
.query("souls")
|
||||
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
|
||||
.order("desc")
|
||||
.take(limit * 5);
|
||||
.take(limit);
|
||||
return entries
|
||||
.filter((soul) => !soul.softDeletedAt)
|
||||
.slice(0, limit)
|
||||
.map((soul) => toPublicSoul(soul))
|
||||
.filter((soul): soul is NonNullable<typeof soul> => Boolean(soul));
|
||||
},
|
||||
|
||||
+3
-1
@@ -34,6 +34,8 @@ export const toggle = mutation({
|
||||
return { starred: false };
|
||||
}
|
||||
|
||||
if (skill.softDeletedAt) throw new Error("Skill not found");
|
||||
|
||||
await ctx.db.insert("stars", {
|
||||
skillId: args.skillId,
|
||||
userId,
|
||||
@@ -70,7 +72,7 @@ export const addStarInternal = internalMutation({
|
||||
args: { userId: v.id("users"), skillId: v.id("skills") },
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) throw new Error("Skill not found");
|
||||
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
|
||||
const existing = await ctx.db
|
||||
.query("stars")
|
||||
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", args.userId))
|
||||
|
||||
@@ -230,8 +230,11 @@ export const reconcileSkillStarCounts = internalMutation({
|
||||
.order("asc")
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
|
||||
let scanned = 0;
|
||||
let patched = 0;
|
||||
for (const skill of page) {
|
||||
if (skill.softDeletedAt) continue;
|
||||
scanned += 1;
|
||||
// Count actual star records for this skill
|
||||
const starRecords = await ctx.db
|
||||
.query("stars")
|
||||
@@ -263,7 +266,7 @@ export const reconcileSkillStarCounts = internalMutation({
|
||||
}
|
||||
|
||||
return {
|
||||
scanned: page.length,
|
||||
scanned,
|
||||
patched,
|
||||
cursor: isDone ? null : continueCursor,
|
||||
isDone,
|
||||
|
||||
@@ -1075,6 +1075,81 @@ describe("users.list", () => {
|
||||
expect(result.items[0]?.handle).toBe("alice");
|
||||
});
|
||||
|
||||
it("includes an exact older handle match outside the bounded scan", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const users = [
|
||||
...Array.from({ length: 500 }, (_value, index) => ({
|
||||
_id: `users:recent-${index}`,
|
||||
_creationTime: 10_000 - index,
|
||||
handle: `recent-${index}`,
|
||||
role: "user",
|
||||
})),
|
||||
{ _id: "users:older", _creationTime: 1, handle: "alice", role: "user" },
|
||||
];
|
||||
const { ctx, take, collect } = makeListCtx(users);
|
||||
const listHandler = (
|
||||
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler;
|
||||
|
||||
const result = (await listHandler(ctx, { limit: 50, search: "alice" })) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
expect(take).toHaveBeenCalledWith(500);
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.items[0]?._id).toBe("users:older");
|
||||
});
|
||||
|
||||
it("includes an exact personal publisher handle match without a full collect", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const users = [{ _id: "users:1", _creationTime: 2, handle: "alice", role: "user" }];
|
||||
const { ctx, take, collect } = makeListCtx(users, {
|
||||
publishersByHandle: {
|
||||
lmlukef: {
|
||||
_id: "publishers:lmlukef",
|
||||
kind: "user",
|
||||
handle: "lmlukef",
|
||||
linkedUserId: "users:owner",
|
||||
},
|
||||
},
|
||||
usersById: {
|
||||
"users:owner": {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "luke",
|
||||
name: "different-gh-login",
|
||||
displayName: "Luke",
|
||||
role: "user",
|
||||
},
|
||||
},
|
||||
});
|
||||
const listHandler = (
|
||||
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler;
|
||||
|
||||
const result = (await listHandler(ctx, { limit: 50, search: "lmLukeF" })) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
expect(take).toHaveBeenCalledWith(500);
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
_id: "users:owner",
|
||||
handle: "luke",
|
||||
displayName: "Luke",
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps large limit and search scan size", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
|
||||
@@ -62,6 +62,9 @@ When no proxy variable is set, behavior is unchanged (direct connections).
|
||||
Stores your API token + cached registry URL.
|
||||
|
||||
- macOS: `~/Library/Application Support/clawhub/config.json`
|
||||
- Linux/XDG: `$XDG_CONFIG_HOME/clawhub/config.json` or `~/.config/clawhub/config.json`
|
||||
- Windows: `%APPDATA%\\clawhub\\config.json`
|
||||
- Legacy fallback: if `clawhub/config.json` does not exist yet but `clawdhub/config.json` does, the CLI reuses the legacy path
|
||||
- override: `CLAWHUB_CONFIG_PATH` (legacy `CLAWDHUB_CONFIG_PATH`)
|
||||
|
||||
## Commands
|
||||
|
||||
+8
-1
@@ -7,6 +7,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun --bun vite build && bun scripts/copy-og-assets.ts",
|
||||
"check": "bun run lint",
|
||||
"check:peers": "bun scripts/check-peer-deps.ts",
|
||||
"check:secrets": "bun scripts/check-staged-secrets.mjs",
|
||||
"convex:deploy": "bunx convex deploy --typecheck=disable --yes",
|
||||
@@ -45,6 +46,8 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
@@ -57,6 +60,7 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/router-plugin": "1.167.2",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.34.1",
|
||||
@@ -66,6 +70,8 @@
|
||||
"ignore": "^7.0.5",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nitro": "3.0.260311-beta",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -76,6 +82,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
@@ -98,7 +105,7 @@
|
||||
"oxlint-tsgolint": "^0.17.4",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.24.7",
|
||||
"vite": "8.0.1",
|
||||
"vite": "8.0.5",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,12 @@ clawhub login --token clh_...
|
||||
Notes:
|
||||
|
||||
- Browser login opens `https://clawhub.ai/cli/auth` and completes via a loopback callback.
|
||||
- Token stored in `~/Library/Application Support/clawhub/config.json` on macOS (override via `CLAWHUB_CONFIG_PATH`, legacy `CLAWDHUB_CONFIG_PATH`).
|
||||
- Default config path:
|
||||
- macOS: `~/Library/Application Support/clawhub/config.json`
|
||||
- Linux/XDG: `$XDG_CONFIG_HOME/clawhub/config.json` or `~/.config/clawhub/config.json`
|
||||
- Windows: `%APPDATA%\\clawhub\\config.json`
|
||||
- Legacy fallback: if `clawhub/config.json` does not exist yet but `clawdhub/config.json` does, the CLI reuses the legacy path.
|
||||
- Override via `CLAWHUB_CONFIG_PATH` (legacy `CLAWDHUB_CONFIG_PATH`).
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -57,6 +57,14 @@ export const PackageVerificationSummarySchema = type({
|
||||
});
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
|
||||
export const PackageStatsSchema = type({
|
||||
downloads: "number",
|
||||
installs: "number",
|
||||
stars: "number",
|
||||
versions: "number",
|
||||
});
|
||||
export type PackageStats = (typeof PackageStatsSchema)[inferred];
|
||||
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
@@ -188,6 +196,7 @@ export const ApiV1PackageResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
stats: PackageStatsSchema.optional(),
|
||||
}).or("null"),
|
||||
owner: type({
|
||||
handle: "string|null",
|
||||
|
||||
Vendored
+13
@@ -46,6 +46,13 @@ export declare const PackageVerificationSummarySchema: import("arktype/internal/
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
|
||||
}, {}>;
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
export declare const PackageStatsSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
downloads: number;
|
||||
installs: number;
|
||||
stars: number;
|
||||
versions: number;
|
||||
}, {}>;
|
||||
export type PackageStats = (typeof PackageStatsSchema)[inferred];
|
||||
export declare const PackageVtAnalysisSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
@@ -261,6 +268,12 @@ export declare const ApiV1PackageResponseSchema: import("arktype/internal/varian
|
||||
hasProvenance?: boolean | undefined;
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
|
||||
} | null | undefined;
|
||||
stats?: {
|
||||
downloads: number;
|
||||
installs: number;
|
||||
stars: number;
|
||||
versions: number;
|
||||
} | undefined;
|
||||
} | null;
|
||||
owner: {
|
||||
handle: string | null;
|
||||
|
||||
Vendored
+7
@@ -40,6 +40,12 @@ export const PackageVerificationSummarySchema = type({
|
||||
hasProvenance: "boolean?",
|
||||
scanStatus: '"clean"|"suspicious"|"malicious"|"pending"|"not-run"?',
|
||||
});
|
||||
export const PackageStatsSchema = type({
|
||||
downloads: "number",
|
||||
installs: "number",
|
||||
stars: "number",
|
||||
versions: "number",
|
||||
});
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
@@ -151,6 +157,7 @@ export const ApiV1PackageResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
stats: PackageStatsSchema.optional(),
|
||||
}).or("null"),
|
||||
owner: type({
|
||||
handle: "string|null",
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -57,6 +57,14 @@ export const PackageVerificationSummarySchema = type({
|
||||
});
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
|
||||
export const PackageStatsSchema = type({
|
||||
downloads: "number",
|
||||
installs: "number",
|
||||
stars: "number",
|
||||
versions: "number",
|
||||
});
|
||||
export type PackageStats = (typeof PackageStatsSchema)[inferred];
|
||||
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
@@ -190,6 +198,7 @@ export const ApiV1PackageResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
stats: PackageStatsSchema.optional(),
|
||||
}).or("null"),
|
||||
owner: type({
|
||||
handle: "string|null",
|
||||
|
||||
@@ -277,6 +277,12 @@ describe("plugins publish route", () => {
|
||||
|
||||
expect(screen.getByText(/openclaw\.compat\.pluginApi/i)).toBeTruthy();
|
||||
expect(screen.getByText(/openclaw\.build\.openclawVersion/i)).toBeTruthy();
|
||||
const docsLink = screen.getByRole("link", { name: /Plugin Setup and Config/i });
|
||||
expect(docsLink.getAttribute("href")).toBe(
|
||||
"https://docs.openclaw.ai/plugins/sdk-setup#package-metadata",
|
||||
);
|
||||
expect(docsLink.getAttribute("target")).toBe("_blank");
|
||||
expect(docsLink.getAttribute("rel")).toBe("noopener noreferrer");
|
||||
expect(screen.getByRole("button", { name: "Publish" }).getAttribute("disabled")).not.toBeNull();
|
||||
expect(publishRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
+22
-11
@@ -1,5 +1,5 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
@@ -42,6 +42,7 @@ export default function Header() {
|
||||
const isSoulMode = siteMode === "souls";
|
||||
const clawHubUrl = getClawHubSiteUrl();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined);
|
||||
const handle = me?.handle ?? me?.displayName ?? "user";
|
||||
@@ -277,12 +278,16 @@ export default function Header() {
|
||||
) : null}
|
||||
{primaryItems.map((item) => {
|
||||
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
|
||||
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
|
||||
location.pathname.startsWith(prefix)
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={item.to + item.label}
|
||||
to={item.to}
|
||||
className="navbar-tab"
|
||||
search={item.search ?? {}}
|
||||
data-status={isActiveByPrefix ? "active" : undefined}
|
||||
>
|
||||
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
|
||||
{item.label}
|
||||
@@ -291,16 +296,22 @@ export default function Header() {
|
||||
})}
|
||||
</div>
|
||||
<div className="navbar-tabs-secondary">
|
||||
{secondaryItems.map((item) => (
|
||||
<Link
|
||||
key={item.to + item.label}
|
||||
to={item.to}
|
||||
search={item.search ?? {}}
|
||||
className="navbar-tab navbar-tab-secondary"
|
||||
>
|
||||
{item.label === "Management" ? "Manage" : item.label}
|
||||
</Link>
|
||||
))}
|
||||
{secondaryItems.map((item) => {
|
||||
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
|
||||
location.pathname.startsWith(prefix)
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={item.to + item.label}
|
||||
to={item.to}
|
||||
search={item.search ?? {}}
|
||||
className="navbar-tab navbar-tab-secondary"
|
||||
data-status={isActiveByPrefix ? "active" : undefined}
|
||||
>
|
||||
{item.label === "Management" ? "Manage" : item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,9 @@ import { Badge } from "./ui/badge";
|
||||
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';
|
||||
|
||||
export function PackageSourceChooser(props: {
|
||||
files: File[];
|
||||
totalBytes: number;
|
||||
@@ -155,7 +158,12 @@ export function PackageSourceChooser(props: {
|
||||
<Badge variant="accent">
|
||||
Missing required OpenClaw package metadata: {props.codePluginFieldIssues.join(", ")}. Add
|
||||
these fields to <code>package.json</code> before publishing. See{" "}
|
||||
<a href="/plugins/sdk-setup#package-metadata" className="underline">
|
||||
<a
|
||||
href={OPENCLAW_PLUGIN_PACKAGE_METADATA_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Plugin Setup and Config
|
||||
</a>
|
||||
.
|
||||
|
||||
@@ -401,78 +401,76 @@ export function SkillDetailPage({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="detail-layout">
|
||||
<div className="detail-main">
|
||||
{nixSnippet ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Install via Nix
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{nixSnippet}
|
||||
</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
<SkillMetadataSidebar
|
||||
skill={skill}
|
||||
latestVersion={latestVersion}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
tagEntries={tagEntries}
|
||||
isMalwareBlocked={modInfo?.isMalwareBlocked}
|
||||
isRemoved={modInfo?.isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
/>
|
||||
|
||||
{configExample ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Config example
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{configExample}
|
||||
</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
<div className="detail-content-full">
|
||||
{nixSnippet ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Install via Nix
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{nixSnippet}
|
||||
</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<SkillDetailTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
onCompareIntent={() => setShouldPrefetchCompare(true)}
|
||||
readmeContent={readmeContent}
|
||||
readmeError={readmeError}
|
||||
latestFiles={latestFiles}
|
||||
latestVersionId={latestVersion?._id ?? null}
|
||||
skill={skill as Doc<"skills">}
|
||||
diffVersions={diffVersions}
|
||||
versions={versions}
|
||||
nixPlugin={Boolean(nixPlugin)}
|
||||
suppressVersionScanResults={suppressVersionScanResults}
|
||||
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
{configExample ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Config example
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{configExample}
|
||||
</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Comments
|
||||
</h2>
|
||||
<p className="section-subtitle mt-3 mb-0">
|
||||
Loading comments...
|
||||
</p>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<SkillCommentsPanel
|
||||
skillId={skill._id}
|
||||
isAuthenticated={isAuthenticated}
|
||||
me={me ?? null}
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<SkillMetadataSidebar
|
||||
skill={skill}
|
||||
latestVersion={latestVersion}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
tagEntries={tagEntries}
|
||||
isMalwareBlocked={modInfo?.isMalwareBlocked}
|
||||
isRemoved={modInfo?.isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
<SkillDetailTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
onCompareIntent={() => setShouldPrefetchCompare(true)}
|
||||
readmeContent={readmeContent}
|
||||
readmeError={readmeError}
|
||||
latestFiles={latestFiles}
|
||||
latestVersionId={latestVersion?._id ?? null}
|
||||
skill={skill as Doc<"skills">}
|
||||
diffVersions={diffVersions}
|
||||
versions={versions}
|
||||
nixPlugin={Boolean(nixPlugin)}
|
||||
suppressVersionScanResults={suppressVersionScanResults}
|
||||
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Comments
|
||||
</h2>
|
||||
<p className="section-subtitle mt-3 mb-0">
|
||||
Loading comments...
|
||||
</p>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<SkillCommentsPanel
|
||||
skillId={skill._id}
|
||||
isAuthenticated={isAuthenticated}
|
||||
me={me ?? null}
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
} from "clawhub-schema/licenseConstants";
|
||||
import { Package, Star } from "lucide-react";
|
||||
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
@@ -39,104 +39,83 @@ export function SkillMetadataSidebar({
|
||||
nixPlugin,
|
||||
}: SkillMetadataSidebarProps) {
|
||||
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
|
||||
const showDownload = !nixPlugin && !isMalwareBlocked && !isRemoved;
|
||||
|
||||
return (
|
||||
<aside className="detail-sidebar">
|
||||
{/* Download / Install */}
|
||||
{!nixPlugin && !isMalwareBlocked && !isRemoved ? (
|
||||
<div className="sidebar-card">
|
||||
<h3 className="sidebar-card-title">Download</h3>
|
||||
<Button asChild variant="primary" className="w-full justify-center">
|
||||
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
|
||||
Download zip
|
||||
</a>
|
||||
</Button>
|
||||
<div className="detail-meta-bar">
|
||||
{/* Stats row */}
|
||||
<div className="meta-bar-stats">
|
||||
<div className="meta-stat">
|
||||
<Download size={14} aria-hidden="true" />
|
||||
<span className="meta-stat-value">{formatCompactStat(skill.stats.downloads)}</span>
|
||||
<span className="meta-stat-label">downloads</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="sidebar-card">
|
||||
<h3 className="sidebar-card-title">Stats</h3>
|
||||
<div className="sidebar-stat-grid">
|
||||
<div className="sidebar-stat">
|
||||
<span className="sidebar-stat-value">
|
||||
<Package size={14} aria-hidden="true" />
|
||||
{formatCompactStat(skill.stats.downloads)}
|
||||
</span>
|
||||
<span className="sidebar-stat-label">Downloads</span>
|
||||
</div>
|
||||
<div className="sidebar-stat">
|
||||
<span className="sidebar-stat-value">
|
||||
<Star size={14} aria-hidden="true" />
|
||||
{formatCompactStat(skill.stats.stars)}
|
||||
</span>
|
||||
<span className="sidebar-stat-label">Stars</span>
|
||||
</div>
|
||||
<div className="sidebar-stat">
|
||||
<span className="sidebar-stat-value">
|
||||
{formatCompactStat(skill.stats.versions ?? 0)}
|
||||
</span>
|
||||
<span className="sidebar-stat-label">Versions</span>
|
||||
</div>
|
||||
<div className="meta-stat">
|
||||
<Star size={14} aria-hidden="true" />
|
||||
<span className="meta-stat-value">{formatCompactStat(skill.stats.stars)}</span>
|
||||
<span className="meta-stat-label">stars</span>
|
||||
</div>
|
||||
<div className="meta-stat">
|
||||
<Package size={14} aria-hidden="true" />
|
||||
<span className="meta-stat-value">{formatCompactStat(skill.stats.versions ?? 0)}</span>
|
||||
<span className="meta-stat-label">versions</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="sidebar-card">
|
||||
<h3 className="sidebar-card-title">Details</h3>
|
||||
<dl className="sidebar-metadata">
|
||||
<div className="sidebar-metadata-row">
|
||||
<dt>Updated</dt>
|
||||
<dd>{timeAgo(skill.updatedAt)}</dd>
|
||||
{/* Details row */}
|
||||
<div className="meta-bar-details">
|
||||
<div className="meta-detail">
|
||||
<Calendar size={12} aria-hidden="true" />
|
||||
<span>Updated {timeAgo(skill.updatedAt)}</span>
|
||||
</div>
|
||||
{latestVersion?.version ? (
|
||||
<div className="meta-detail">
|
||||
<Tag size={12} aria-hidden="true" />
|
||||
<span>v{latestVersion.version}</span>
|
||||
</div>
|
||||
<div className="sidebar-metadata-row">
|
||||
<dt>Created</dt>
|
||||
<dd>{timeAgo(skill.createdAt)}</dd>
|
||||
) : null}
|
||||
<div className="meta-detail">
|
||||
<Scale size={12} aria-hidden="true" />
|
||||
<span>{PLATFORM_SKILL_LICENSE}</span>
|
||||
</div>
|
||||
{osLabels.length > 0 ? (
|
||||
<div className="meta-detail">
|
||||
<span>{osLabels.join(", ")}</span>
|
||||
</div>
|
||||
{latestVersion?.version ? (
|
||||
<div className="sidebar-metadata-row">
|
||||
<dt>Version</dt>
|
||||
<dd>v{latestVersion.version}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="sidebar-metadata-row">
|
||||
<dt>License</dt>
|
||||
<dd>{PLATFORM_SKILL_LICENSE} ({PLATFORM_SKILL_LICENSE_SUMMARY})</dd>
|
||||
</div>
|
||||
{osLabels.length ? (
|
||||
<div className="sidebar-metadata-row">
|
||||
<dt>Platforms</dt>
|
||||
<dd>{osLabels.join(", ")}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{tagEntries.length > 0 ? (
|
||||
<div className="sidebar-card">
|
||||
<h3 className="sidebar-card-title">Tags</h3>
|
||||
<div className="sidebar-tags">
|
||||
{/* Tags and Publisher row */}
|
||||
<div className="meta-bar-footer">
|
||||
<div className="meta-bar-publisher">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
size="sm"
|
||||
showName
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tagEntries.length > 0 ? (
|
||||
<div className="meta-bar-tags">
|
||||
{tagEntries.map(([tag]) => (
|
||||
<Badge key={tag} variant="compact">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
{/* Owner */}
|
||||
<div className="sidebar-card">
|
||||
<h3 className="sidebar-card-title">Publisher</h3>
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
size="md"
|
||||
showName
|
||||
/>
|
||||
{showDownload ? (
|
||||
<Button asChild variant="primary" size="sm">
|
||||
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 overflow-x-hidden break-words",
|
||||
size === "default" && "max-w-page-max",
|
||||
size === "narrow" && "max-w-page-narrow",
|
||||
size === "wide" && "w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Container.displayName = "Container";
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent",
|
||||
"transition-colors duration-200 ease-out",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:bg-[color:var(--accent)] data-[state=unchecked]:bg-[color:var(--surface-muted)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0",
|
||||
"transition-transform duration-200 ease-out",
|
||||
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -23,6 +23,8 @@ export interface NavItem {
|
||||
soulModeOnly: boolean;
|
||||
/** Link hidden when siteMode === "souls" */
|
||||
soulModeHide: boolean;
|
||||
/** Additional path prefixes that should also highlight this nav item (e.g. /skill for /skills) */
|
||||
activePathPrefixes?: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -68,6 +70,7 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: true,
|
||||
activePathPrefixes: ["/skill/"],
|
||||
},
|
||||
{
|
||||
label: "Plugins",
|
||||
@@ -77,6 +80,7 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: true,
|
||||
activePathPrefixes: ["/plugin/"],
|
||||
},
|
||||
{
|
||||
label: "Souls",
|
||||
@@ -88,6 +92,7 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
|
||||
soulModeOnly: false,
|
||||
// In soul-mode this is the primary tab; in skills-mode it is also shown.
|
||||
soulModeHide: false,
|
||||
activePathPrefixes: ["/soul/"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -162,6 +162,45 @@ describe("fetchPackages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves package stats from package detail responses", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
tags: {},
|
||||
stats: {
|
||||
downloads: 7,
|
||||
installs: 3,
|
||||
stars: 2,
|
||||
versions: 4,
|
||||
},
|
||||
},
|
||||
owner: null,
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchPackageDetail("demo-plugin")).resolves.toMatchObject({
|
||||
package: {
|
||||
stats: {
|
||||
downloads: 7,
|
||||
installs: 3,
|
||||
stars: 2,
|
||||
versions: 4,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards request cookies and includes credentials for package detail fetches", async () => {
|
||||
vi.stubEnv("VITE_CONVEX_SITE_URL", "https://app.example");
|
||||
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
|
||||
|
||||
+3
-25
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ApiV1PackageResponse,
|
||||
PackageCapabilitySummary,
|
||||
PackageCompatibility,
|
||||
PackageVerificationSummary,
|
||||
@@ -24,30 +25,7 @@ export type PackageListItem = {
|
||||
verificationTier?: string | null;
|
||||
};
|
||||
|
||||
export type PackageDetailResponse = {
|
||||
package: {
|
||||
_id?: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: "skill" | "code-plugin" | "bundle-plugin";
|
||||
runtimeId?: string | null;
|
||||
channel: "official" | "community" | "private";
|
||||
isOfficial: boolean;
|
||||
summary?: string | null;
|
||||
latestVersion?: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
tags: Record<string, string>;
|
||||
compatibility?: PackageCompatibility | null;
|
||||
capabilities?: PackageCapabilitySummary | null;
|
||||
verification?: PackageVerificationSummary | null;
|
||||
} | null;
|
||||
owner: {
|
||||
handle?: string | null;
|
||||
displayName?: string | null;
|
||||
image?: string | null;
|
||||
} | null;
|
||||
};
|
||||
export type PackageDetailResponse = ApiV1PackageResponse;
|
||||
|
||||
export type PackageVersionDetail = {
|
||||
package: {
|
||||
@@ -353,7 +331,7 @@ export async function fetchPluginCatalog(params: {
|
||||
return await fetchJson<PluginCatalogResult>(url);
|
||||
}
|
||||
|
||||
export async function fetchPackageDetail(name: string) {
|
||||
export async function fetchPackageDetail(name: string): Promise<PackageDetailResponse> {
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}`);
|
||||
const response = await packageFetch(url, "application/json");
|
||||
if (response.status === 404) {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
|
||||
const PREFERENCES_KEY = "clawhub-preferences";
|
||||
|
||||
export type LayoutDensity = "comfortable" | "compact";
|
||||
export type ListViewMode = "grid" | "list";
|
||||
export type SidebarPosition = "left" | "right";
|
||||
export type CodeFontSize = "small" | "medium" | "large";
|
||||
export type AnimationLevel = "full" | "reduced" | "none";
|
||||
|
||||
export interface UserPreferences {
|
||||
layoutDensity: LayoutDensity;
|
||||
listViewMode: ListViewMode;
|
||||
showDescriptions: boolean;
|
||||
showStats: boolean;
|
||||
showTags: boolean;
|
||||
advancedMode: boolean;
|
||||
sidebarPosition: SidebarPosition;
|
||||
stickyHeader: boolean;
|
||||
codeFontSize: CodeFontSize;
|
||||
lineNumbers: boolean;
|
||||
wordWrap: boolean;
|
||||
animationLevel: AnimationLevel;
|
||||
reducedMotion: boolean;
|
||||
highContrast: boolean;
|
||||
emailNotifications: boolean;
|
||||
browserNotifications: boolean;
|
||||
experimentalFeatures: boolean;
|
||||
}
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
layoutDensity: "comfortable",
|
||||
listViewMode: "grid",
|
||||
showDescriptions: true,
|
||||
showStats: true,
|
||||
showTags: true,
|
||||
advancedMode: false,
|
||||
sidebarPosition: "right",
|
||||
stickyHeader: true,
|
||||
codeFontSize: "medium",
|
||||
lineNumbers: true,
|
||||
wordWrap: true,
|
||||
animationLevel: "full",
|
||||
reducedMotion: false,
|
||||
highContrast: false,
|
||||
emailNotifications: true,
|
||||
browserNotifications: false,
|
||||
experimentalFeatures: false,
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let cachedStoredPreferences: string | null = null;
|
||||
let cachedSnapshot = defaultPreferences;
|
||||
|
||||
function readPreferencesSnapshot(stored: string | null): UserPreferences {
|
||||
if (!stored) return defaultPreferences;
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as Partial<UserPreferences>;
|
||||
return { ...defaultPreferences, ...parsed };
|
||||
} catch {
|
||||
return defaultPreferences;
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredPreferences(): UserPreferences {
|
||||
if (typeof window === "undefined") return defaultPreferences;
|
||||
const stored = window.localStorage.getItem(PREFERENCES_KEY);
|
||||
if (stored === cachedStoredPreferences) {
|
||||
return cachedSnapshot;
|
||||
}
|
||||
cachedStoredPreferences = stored;
|
||||
cachedSnapshot = readPreferencesSnapshot(stored);
|
||||
return cachedSnapshot;
|
||||
}
|
||||
|
||||
function notifyListeners() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== PREFERENCES_KEY) return;
|
||||
cachedStoredPreferences = event.newValue;
|
||||
cachedSnapshot = readPreferencesSnapshot(event.newValue);
|
||||
listener();
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", handleStorage);
|
||||
}
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function savePreferences(prefs: UserPreferences) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const serialized = JSON.stringify(prefs);
|
||||
window.localStorage.setItem(PREFERENCES_KEY, serialized);
|
||||
cachedStoredPreferences = serialized;
|
||||
cachedSnapshot = prefs;
|
||||
notifyListeners();
|
||||
} catch {
|
||||
// Storage might be full or disabled.
|
||||
}
|
||||
}
|
||||
|
||||
function getServerSnapshot(): UserPreferences {
|
||||
return defaultPreferences;
|
||||
}
|
||||
|
||||
export function usePreferences() {
|
||||
const preferences = useSyncExternalStore(subscribe, getStoredPreferences, getServerSnapshot);
|
||||
|
||||
const updatePreference = useCallback(
|
||||
<K extends keyof UserPreferences>(key: K, value: UserPreferences[K]) => {
|
||||
const current = getStoredPreferences();
|
||||
const updated = { ...current, [key]: value };
|
||||
savePreferences(updated);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updatePreferences = useCallback((updates: Partial<UserPreferences>) => {
|
||||
const current = getStoredPreferences();
|
||||
const updated = { ...current, ...updates };
|
||||
savePreferences(updated);
|
||||
}, []);
|
||||
|
||||
const resetPreferences = useCallback(() => {
|
||||
savePreferences(defaultPreferences);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
|
||||
root.dataset.density = preferences.layoutDensity;
|
||||
root.dataset.animation = preferences.animationLevel;
|
||||
root.classList.toggle("high-contrast", preferences.highContrast);
|
||||
root.classList.toggle(
|
||||
"reduce-motion",
|
||||
preferences.reducedMotion || preferences.animationLevel === "none",
|
||||
);
|
||||
root.style.setProperty(
|
||||
"--code-font-size",
|
||||
preferences.codeFontSize === "small"
|
||||
? "12px"
|
||||
: preferences.codeFontSize === "large"
|
||||
? "16px"
|
||||
: "14px",
|
||||
);
|
||||
}, [preferences]);
|
||||
|
||||
return {
|
||||
preferences,
|
||||
updatePreference,
|
||||
updatePreferences,
|
||||
resetPreferences,
|
||||
isAdvancedMode: preferences.advancedMode,
|
||||
};
|
||||
}
|
||||
|
||||
export { defaultPreferences };
|
||||
+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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createTokenMock = vi.fn();
|
||||
const clearAuthErrorMock = vi.fn();
|
||||
let mockSearch: {
|
||||
redirect_uri?: string;
|
||||
label?: string;
|
||||
label_b64?: string;
|
||||
state?: string;
|
||||
} = {};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component: unknown }) => ({
|
||||
...config,
|
||||
useSearch: () => mockSearch,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useMutation: () => createTokenMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../../convex/_generated/api", () => ({
|
||||
api: {
|
||||
tokens: {
|
||||
create: "tokens.create",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => ({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "user_123" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/useAuthError", () => ({
|
||||
useAuthError: () => ({
|
||||
error: null,
|
||||
clear: clearAuthErrorMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/site", () => ({
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
normalizeClawHubSiteOrigin: () => "https://clawhub.ai",
|
||||
}));
|
||||
|
||||
vi.mock("../../components/layout/Container", () => ({
|
||||
Container: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/SignInButton", () => ({
|
||||
SignInButton: ({
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
|
||||
vi.mock("../../components/ui/card", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CardContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CardHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
CardTitle: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
|
||||
}));
|
||||
|
||||
const { CliAuth } = await import("./auth");
|
||||
|
||||
describe("CliAuth", () => {
|
||||
const assignSpy = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
createTokenMock.mockReset();
|
||||
clearAuthErrorMock.mockReset();
|
||||
assignSpy.mockReset();
|
||||
mockSearch = {
|
||||
redirect_uri: "http://127.0.0.1:43110/callback",
|
||||
state: "state_123",
|
||||
label: "CLI token",
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the fallback token and retry link before attempting redirect", async () => {
|
||||
createTokenMock.mockResolvedValue({ token: "clh_test_token" });
|
||||
|
||||
render(<CliAuth navigate={assignSpy} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createTokenMock).toHaveBeenCalledWith({ label: "CLI token" });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(assignSpy).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:43110/callback#token=clh_test_token®istry=https%3A%2F%2Fclawhub.ai&state=state_123",
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/copy this token and run/i)).toBeTruthy();
|
||||
expect(screen.getByText("clh_test_token")).toBeTruthy();
|
||||
expect(screen.getByText(/Redirecting to CLI/i)).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: /Retry redirect to CLI/i }).getAttribute("href")).toBe(
|
||||
"http://127.0.0.1:43110/callback#token=clh_test_token®istry=https%3A%2F%2Fclawhub.ai&state=state_123",
|
||||
);
|
||||
});
|
||||
});
|
||||
+28
-6
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { Container } from "../../components/layout/Container";
|
||||
import { SignInButton } from "../../components/SignInButton";
|
||||
@@ -13,7 +14,11 @@ export const Route = createFileRoute("/cli/auth")({
|
||||
component: CliAuth,
|
||||
});
|
||||
|
||||
function CliAuth() {
|
||||
type CliAuthProps = {
|
||||
navigate?: (url: string) => void;
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -26,6 +31,7 @@ function CliAuth() {
|
||||
};
|
||||
const [status, setStatus] = useState<string>("Preparing...");
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [callbackUrl, setCallbackUrl] = useState<string | null>(null);
|
||||
const hasRun = useRef(false);
|
||||
|
||||
const redirectUri = search.redirect_uri ?? "";
|
||||
@@ -51,13 +57,21 @@ function CliAuth() {
|
||||
const run = async () => {
|
||||
setStatus("Creating token...");
|
||||
const result = await createToken({ label });
|
||||
setToken(result.token);
|
||||
setStatus("Redirecting to CLI...");
|
||||
const hash = new URLSearchParams();
|
||||
hash.set("token", result.token);
|
||||
hash.set("registry", registry);
|
||||
hash.set("state", state);
|
||||
window.location.assign(`${redirectUri}#${hash.toString()}`);
|
||||
const redirectUrl = `${redirectUri}#${hash.toString()}`;
|
||||
// Render the fallback token before attempting navigation so it is
|
||||
// always visible if the browser blocks or fails the http:// redirect
|
||||
// (e.g. ERR_CONNECTION_REFUSED when the CLI server has already shut
|
||||
// down, or Chrome's HTTPS-first mode interfering with localhost).
|
||||
flushSync(() => {
|
||||
setToken(result.token);
|
||||
setCallbackUrl(redirectUrl);
|
||||
setStatus("Redirecting to CLI…");
|
||||
});
|
||||
navigate(redirectUrl);
|
||||
};
|
||||
|
||||
void run().catch((error) => {
|
||||
@@ -65,7 +79,7 @@ function CliAuth() {
|
||||
setStatus(message);
|
||||
setToken(null);
|
||||
});
|
||||
}, [createToken, isAuthenticated, label, me, redirectUri, registry, safeRedirect, state]);
|
||||
}, [createToken, isAuthenticated, label, me, navigate, redirectUri, registry, safeRedirect, state]);
|
||||
|
||||
if (!safeRedirect) {
|
||||
return (
|
||||
@@ -159,8 +173,16 @@ function CliAuth() {
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">{status}</p>
|
||||
{token ? (
|
||||
<div className="text-sm text-[color:var(--ink-soft)] overflow-x-auto">
|
||||
<div className="mb-2">If redirect fails, copy this token:</div>
|
||||
<div className="mb-2">
|
||||
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">
|
||||
<a href={callbackUrl}>Retry redirect to CLI</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
+248
-83
@@ -1,5 +1,22 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import {
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Brain,
|
||||
Code2,
|
||||
Ghost,
|
||||
Image,
|
||||
Palette,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { SkillCard } from "../components/SkillCard";
|
||||
@@ -7,7 +24,6 @@ import { SkillListItem } from "../components/SkillListItem";
|
||||
import { SkillStatsTripletLine } from "../components/SkillStats";
|
||||
import { SoulCard } from "../components/SoulCard";
|
||||
import { SoulStatsTripletLine } from "../components/SoulStats";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
import { UserBadge } from "../components/UserBadge";
|
||||
import { convexHttp } from "../convex/client";
|
||||
@@ -25,6 +41,19 @@ function Home() {
|
||||
return mode === "souls" ? <OnlyCrabsHome /> : <SkillsHome />;
|
||||
}
|
||||
|
||||
const popularSearches = ["AI Writing", "Screenshot", "Productivity", "Analytics", "Automation"];
|
||||
|
||||
const categories = [
|
||||
{ name: "Productivity", icon: Zap, count: 324, className: "productivity" },
|
||||
{ name: "AI & ML", icon: Brain, count: 218, className: "ai" },
|
||||
{ name: "Developer Tools", icon: Code2, count: 456, className: "developer" },
|
||||
{ name: "Design", icon: Palette, count: 189, className: "design" },
|
||||
{ name: "Analytics", icon: BarChart3, count: 142, className: "analytics" },
|
||||
{ name: "Security", icon: Shield, count: 98, className: "security" },
|
||||
{ name: "Automation", icon: Settings, count: 276, className: "automation" },
|
||||
{ name: "Media", icon: Image, count: 167, className: "media" },
|
||||
];
|
||||
|
||||
function SkillsHome() {
|
||||
type SkillPageEntry = {
|
||||
skill: PublicSkill;
|
||||
@@ -37,6 +66,8 @@ function SkillsHome() {
|
||||
const [trending, setTrending] = useState<SkillPageEntry[]>([]);
|
||||
const [recent, setRecent] = useState<SkillPageEntry[]>([]);
|
||||
const [skillCount, setSkillCount] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -44,13 +75,13 @@ function SkillsHome() {
|
||||
Promise.all([
|
||||
convexHttp.query(api.skills.listHighlightedPublic, { limit: 6 }),
|
||||
convexHttp.query(api.skills.listPublicPageV4, {
|
||||
numItems: 8,
|
||||
numItems: 6,
|
||||
sort: "downloads",
|
||||
dir: "desc",
|
||||
nonSuspiciousOnly: true,
|
||||
}),
|
||||
convexHttp.query(api.skills.listPublicPageV4, {
|
||||
numItems: 8,
|
||||
numItems: 6,
|
||||
sort: "updated",
|
||||
dir: "desc",
|
||||
nonSuspiciousOnly: true,
|
||||
@@ -71,51 +102,79 @@ function SkillsHome() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = searchQuery.trim();
|
||||
if (!q) return;
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q, type: undefined },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* Hero Section */}
|
||||
<section className="home-hero">
|
||||
<div className="home-hero-inner">
|
||||
<div className="home-hero-grid">
|
||||
<div className="home-hero-copy">
|
||||
<div className="home-hero-kicker">Discovery hub</div>
|
||||
<h1 className="home-hero-title">The collaborative hub for agent skills</h1>
|
||||
<p className="home-hero-subtitle">
|
||||
{skillCount != null
|
||||
? `${formatCompactStat(skillCount)} public skill bundles, plugin packages, and builder profiles in one shared index. Browse fast, fork the good stuff, ship your own.`
|
||||
: "Public skill bundles, plugin packages, and builder profiles in one shared index. Browse fast, fork the good stuff, ship your own."}
|
||||
</p>
|
||||
<div className="home-hero-actions">
|
||||
<Button asChild variant="primary">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
>
|
||||
Browse All Skills & Plugins
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="home-hero-publish-btn">
|
||||
<Link
|
||||
to="/publish-skill"
|
||||
search={{ updateSlug: undefined }}
|
||||
>
|
||||
+ Publish Yours
|
||||
</Link>
|
||||
</Button>
|
||||
{/* Badge */}
|
||||
<div className="home-hero-kicker">
|
||||
<Sparkles size={14} className="home-hero-kicker-icon" />
|
||||
<span>
|
||||
{skillCount != null
|
||||
? `${formatCompactStat(skillCount)} curated tools`
|
||||
: "Thousands of curated tools"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="home-hero-explainer">
|
||||
Sharp filters. Clean listings. Discovery that feels more like a real index and less
|
||||
like a sad spreadsheet.
|
||||
|
||||
{/* Headline */}
|
||||
<h1 className="home-hero-title">
|
||||
Discover tools that <span className="home-hero-title-accent">power your work</span>
|
||||
</h1>
|
||||
|
||||
{/* Subheadline */}
|
||||
<p className="home-hero-subtitle">
|
||||
The modern marketplace for internet tools. Find, compare, and install the best
|
||||
software to supercharge your productivity.
|
||||
</p>
|
||||
|
||||
{/* Search */}
|
||||
<form className="home-hero-search" onSubmit={handleSearch}>
|
||||
<div className="home-hero-search-wrapper">
|
||||
<Search size={20} className="home-hero-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="home-hero-search-input"
|
||||
placeholder="Search for tools, categories, or features..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="home-hero-search-btn">
|
||||
<span>Search</span>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Popular searches */}
|
||||
<div className="home-hero-popular">
|
||||
<span className="home-hero-popular-label">Popular:</span>
|
||||
{popularSearches.map((search) => (
|
||||
<button
|
||||
key={search}
|
||||
type="button"
|
||||
className="home-hero-popular-tag"
|
||||
onClick={() => setSearchQuery(search)}
|
||||
>
|
||||
{search}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discovery Panels */}
|
||||
<div className="home-hero-panels" id="home-discovery">
|
||||
<Link
|
||||
to="/skills"
|
||||
@@ -130,19 +189,25 @@ function SkillsHome() {
|
||||
}}
|
||||
className="home-hero-panel"
|
||||
>
|
||||
<span className="home-hero-panel-label">Skills</span>
|
||||
<strong>Browse ranked skill bundles</strong>
|
||||
<span>Popular installs, fresh updates, staff picks.</span>
|
||||
<div className="home-hero-panel-icon">
|
||||
<Zap size={20} />
|
||||
</div>
|
||||
<strong>Skills</strong>
|
||||
<span>Browse ranked skill bundles</span>
|
||||
</Link>
|
||||
<Link to="/plugins" className="home-hero-panel">
|
||||
<span className="home-hero-panel-label">Plugins</span>
|
||||
<strong>Find agent-ready packages</strong>
|
||||
<span>Code plugins, bundles, and verified publishers.</span>
|
||||
<div className="home-hero-panel-icon">
|
||||
<Code2 size={20} />
|
||||
</div>
|
||||
<strong>Plugins</strong>
|
||||
<span>Agent-ready packages</span>
|
||||
</Link>
|
||||
<Link to="/users" search={{ q: undefined }} className="home-hero-panel">
|
||||
<span className="home-hero-panel-label">Users</span>
|
||||
<strong>Meet the builders</strong>
|
||||
<span>Profiles, bios, and the people shipping useful stuff.</span>
|
||||
<div className="home-hero-panel-icon">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<strong>Builders</strong>
|
||||
<span>Meet the creators</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/souls"
|
||||
@@ -155,20 +220,39 @@ function SkillsHome() {
|
||||
}}
|
||||
className="home-hero-panel"
|
||||
>
|
||||
<span className="home-hero-panel-label">Souls</span>
|
||||
<strong>SOUL.md discovery is coming</strong>
|
||||
<span>Holding page for the next catalog surface.</span>
|
||||
<div className="home-hero-panel-icon">
|
||||
<Ghost size={20} />
|
||||
</div>
|
||||
<strong>Souls</strong>
|
||||
<span>SOUL.md discovery</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{skillCount != null ? (
|
||||
<section className="home-section">
|
||||
<div className="home-stats">
|
||||
<div className="home-stat">
|
||||
<div className="home-stat-value">{formatCompactStat(skillCount)}+</div>
|
||||
<div className="home-stat-label">Curated Tools</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Trending */}
|
||||
{trending.length > 0 ? (
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Trending</h2>
|
||||
<h2 className="home-section-title">
|
||||
<span className="home-section-title-icon trending">
|
||||
<TrendingUp size={16} />
|
||||
</span>
|
||||
Trending Now
|
||||
</h2>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
@@ -182,7 +266,8 @@ function SkillsHome() {
|
||||
}}
|
||||
className="home-section-link"
|
||||
>
|
||||
See all
|
||||
View all
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="results-list">
|
||||
@@ -202,7 +287,12 @@ function SkillsHome() {
|
||||
{recent.length > 0 ? (
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Recently updated</h2>
|
||||
<h2 className="home-section-title">
|
||||
<span className="home-section-title-icon recent">
|
||||
<Sparkles size={16} />
|
||||
</span>
|
||||
Recently Updated
|
||||
</h2>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
@@ -216,7 +306,8 @@ function SkillsHome() {
|
||||
}}
|
||||
className="home-section-link"
|
||||
>
|
||||
See all
|
||||
View all
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="results-list">
|
||||
@@ -236,7 +327,12 @@ function SkillsHome() {
|
||||
{highlighted.length > 0 ? (
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Staff picks</h2>
|
||||
<h2 className="home-section-title">
|
||||
<span className="home-section-title-icon featured">
|
||||
<Star size={16} />
|
||||
</span>
|
||||
Staff Picks
|
||||
</h2>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
@@ -250,12 +346,12 @@ function SkillsHome() {
|
||||
}}
|
||||
className="home-section-link"
|
||||
>
|
||||
See all
|
||||
View all
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid">
|
||||
{
|
||||
highlighted.map((entry) => (
|
||||
{highlighted.map((entry) => (
|
||||
<SkillCard
|
||||
key={entry.skill._id}
|
||||
skill={entry.skill}
|
||||
@@ -280,41 +376,96 @@ function SkillsHome() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Categories */}
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Browse by Category</h2>
|
||||
</div>
|
||||
<div className="home-categories">
|
||||
{categories.map((category) => (
|
||||
<Link
|
||||
key={category.name}
|
||||
to="/skills"
|
||||
search={{
|
||||
q: category.name,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-category-card"
|
||||
>
|
||||
<div className={`home-category-icon ${category.className}`}>
|
||||
<category.icon size={20} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="home-category-content">
|
||||
<div className="home-category-name">{category.name}</div>
|
||||
<div className="home-category-count">{category.count} tools</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick links */}
|
||||
<section className="home-section">
|
||||
<div className="home-quick-links">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{ q: undefined, sort: "stars" as const, dir: "desc" as const, highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: "stars" as const,
|
||||
dir: "desc" as const,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-quick-link"
|
||||
>
|
||||
<Star size={14} className="home-quick-link-icon" />
|
||||
Most starred
|
||||
</Link>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{ q: undefined, sort: "newest" as const, dir: undefined, highlighted: undefined, nonSuspicious: true, view: undefined, focus: undefined }}
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: "newest" as const,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-quick-link"
|
||||
>
|
||||
<Sparkles size={14} className="home-quick-link-icon" />
|
||||
New this week
|
||||
</Link>
|
||||
<Link to="/plugins" className="home-quick-link">
|
||||
<Code2 size={14} className="home-quick-link-icon" />
|
||||
Browse plugins
|
||||
</Link>
|
||||
<Link to="/users" search={{ q: undefined }} className="home-quick-link">
|
||||
<Users size={14} className="home-quick-link-icon" />
|
||||
Browse users
|
||||
</Link>
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{ q: undefined, sort: undefined, dir: undefined, view: undefined, focus: undefined }}
|
||||
className="home-quick-link"
|
||||
>
|
||||
Souls coming soon
|
||||
</Link>
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{ q: undefined, sort: undefined, dir: undefined, highlighted: true, nonSuspicious: undefined, view: undefined, focus: undefined }}
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: true,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="home-quick-link"
|
||||
>
|
||||
<Star size={14} className="home-quick-link-icon" />
|
||||
Staff picks
|
||||
</Link>
|
||||
</div>
|
||||
@@ -323,7 +474,6 @@ function SkillsHome() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function OnlyCrabsHome() {
|
||||
const navigate = Route.useNavigate();
|
||||
const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds);
|
||||
@@ -344,8 +494,13 @@ function OnlyCrabsHome() {
|
||||
<div className="home-hero-inner">
|
||||
<div className="home-hero-grid">
|
||||
<div className="home-hero-copy">
|
||||
<div className="home-hero-kicker">OnlyCrabs</div>
|
||||
<h1 className="home-hero-title">SoulHub, where system lore lives.</h1>
|
||||
<div className="home-hero-kicker">
|
||||
<Ghost size={14} className="home-hero-kicker-icon" />
|
||||
<span>OnlyCrabs</span>
|
||||
</div>
|
||||
<h1 className="home-hero-title">
|
||||
<span className="home-hero-title-accent">SoulHub</span>, where system lore lives.
|
||||
</h1>
|
||||
<p className="home-hero-subtitle">
|
||||
Share SOUL.md bundles, version them like docs, and keep personal system lore in one
|
||||
public place.
|
||||
@@ -366,16 +521,20 @@ function OnlyCrabsHome() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="home-hero-search-input"
|
||||
type="text"
|
||||
placeholder="Search souls, prompts, or lore"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<Button variant="primary" type="submit">
|
||||
Search
|
||||
</Button>
|
||||
<div className="home-hero-search-wrapper">
|
||||
<Search size={20} className="home-hero-search-icon" />
|
||||
<input
|
||||
className="home-hero-search-input"
|
||||
type="text"
|
||||
placeholder="Search souls, prompts, or lore"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<button type="submit" className="home-hero-search-btn">
|
||||
<span>Search</span>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -384,7 +543,12 @@ function OnlyCrabsHome() {
|
||||
|
||||
<section className="home-section">
|
||||
<div className="home-section-header">
|
||||
<h2 className="home-section-title">Latest souls</h2>
|
||||
<h2 className="home-section-title">
|
||||
<span className="home-section-title-icon recent">
|
||||
<Sparkles size={16} />
|
||||
</span>
|
||||
Latest Souls
|
||||
</h2>
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
@@ -396,7 +560,8 @@ function OnlyCrabsHome() {
|
||||
}}
|
||||
className="home-section-link"
|
||||
>
|
||||
See all
|
||||
View all
|
||||
<ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid">
|
||||
|
||||
+287
-275
@@ -20,12 +20,10 @@ import {
|
||||
} from "../../lib/packageApi";
|
||||
import { familyLabel } from "../../lib/packageLabels";
|
||||
|
||||
type PluginDetailRateLimitState =
|
||||
| {
|
||||
scope: "detail" | "metadata";
|
||||
retryAfterSeconds: number | null;
|
||||
}
|
||||
| null;
|
||||
type PluginDetailRateLimitState = {
|
||||
scope: "detail" | "metadata";
|
||||
retryAfterSeconds: number | null;
|
||||
} | null;
|
||||
|
||||
type PluginDetailLoaderData = {
|
||||
detail: PackageDetailResponse;
|
||||
@@ -297,286 +295,300 @@ function PluginDetailRoute() {
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Header card */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
<Badge>{familyLabel(pkg.family)}</Badge>
|
||||
{verification?.tier ? (
|
||||
<Badge variant="compact">{verification.tier.replace(/-/g, " ")}</Badge>
|
||||
) : null}
|
||||
{rateLimited?.scope === "metadata" ? (
|
||||
<Badge variant="compact">Some metadata is temporarily unavailable</Badge>
|
||||
) : null}
|
||||
{pkg.isOfficial ? (
|
||||
<Badge className="bg-[rgba(59,130,246,0.15)] text-[#3b82f6]">
|
||||
<VerifiedBadge />
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-1">
|
||||
{pkg.displayName}
|
||||
{pkg.latestVersion ? (
|
||||
<span className="ml-2 inline-block rounded-[var(--radius-pill)] bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--ink-soft)]">
|
||||
v{pkg.latestVersion}
|
||||
</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<p className="text-sm text-[color:var(--ink-soft)] mb-2">
|
||||
{pkg.summary ?? "No summary provided."}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="font-mono text-xs">{pkg.name}</span>
|
||||
{pkg.runtimeId ? (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span>
|
||||
runtime <span className="font-mono text-xs">{pkg.runtimeId}</span>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{owner?.handle ? (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<Link
|
||||
to="/u/$handle"
|
||||
params={{ handle: owner.handle }}
|
||||
className="text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
by @{owner.handle}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
|
||||
<Badge variant="accent" className="mt-3 self-start">
|
||||
Community code plugin. Review compatibility and verification before install.
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Header card */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
<Badge>{familyLabel(pkg.family)}</Badge>
|
||||
{verification?.tier ? (
|
||||
<Badge variant="compact">{verification.tier.replace(/-/g, " ")}</Badge>
|
||||
) : null}
|
||||
{rateLimited?.scope === "metadata" ? (
|
||||
<Badge variant="compact">Some metadata is temporarily unavailable</Badge>
|
||||
) : null}
|
||||
{pkg.isOfficial ? (
|
||||
<Badge className="bg-[rgba(59,130,246,0.15)] text-[#3b82f6]">
|
||||
<VerifiedBadge />
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
{/* Install */}
|
||||
<div className="mt-4">
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3 sm:flex-row sm:items-center sm:gap-2">
|
||||
<pre className="min-w-0 flex-1 overflow-x-auto font-mono text-xs text-[color:var(--ink)]">
|
||||
<code>{installSnippet}</code>
|
||||
</pre>
|
||||
<CopyButton text={installSnippet} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latest Release */}
|
||||
</div>
|
||||
<h1 className="font-display text-2xl font-bold text-[color:var(--ink)] mb-1">
|
||||
{pkg.displayName}
|
||||
{pkg.latestVersion ? (
|
||||
<div className="mt-3 flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2">
|
||||
<span className="text-sm">
|
||||
Latest release: <strong>v{pkg.latestVersion}</strong>
|
||||
</span>
|
||||
<Button asChild variant="outline" size="sm" className="w-full no-underline sm:w-auto">
|
||||
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Download zip
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<span className="ml-2 inline-block rounded-[var(--radius-pill)] bg-[color:var(--surface-muted)] px-2 py-0.5 text-xs font-semibold text-[color:var(--ink-soft)]">
|
||||
v{pkg.latestVersion}
|
||||
</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<p className="text-sm text-[color:var(--ink-soft)] mb-2">
|
||||
{pkg.summary ?? "No summary provided."}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-[color:var(--ink-soft)]">
|
||||
<span className="font-mono text-xs">{pkg.name}</span>
|
||||
{pkg.runtimeId ? (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span>
|
||||
runtime <span className="font-mono text-xs">{pkg.runtimeId}</span>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{owner?.handle ? (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<Link
|
||||
to="/u/$handle"
|
||||
params={{ handle: owner.handle }}
|
||||
className="text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
by @{owner.handle}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{pkg.family === "code-plugin" && !pkg.isOfficial ? (
|
||||
<Badge variant="accent" className="mt-3 self-start">
|
||||
Community code plugin. Review compatibility and verification before install.
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
{/* Install */}
|
||||
<div className="mt-4">
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3 sm:flex-row sm:items-center sm:gap-2">
|
||||
<pre className="min-w-0 flex-1 overflow-x-auto font-mono text-xs text-[color:var(--ink)]">
|
||||
<code>{installSnippet}</code>
|
||||
</pre>
|
||||
<CopyButton text={installSnippet} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latest Release */}
|
||||
{pkg.latestVersion ? (
|
||||
<div className="mt-3 flex flex-col gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-2">
|
||||
<span className="text-sm">
|
||||
Latest release: <strong>v{pkg.latestVersion}</strong>
|
||||
</span>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full no-underline sm:w-auto"
|
||||
>
|
||||
<a href={getPackageDownloadPath(name, pkg.latestVersion)}>
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Download zip
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Capabilities */}
|
||||
{capEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Capabilities</CardTitle>
|
||||
<CopyButton text={JSON.stringify(capabilities, null, 2)} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{capEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{CAPABILITY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{key === "capabilityTags" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((tag) => (
|
||||
<Link key={tag} to="/plugins" search={{ q: tag }}>
|
||||
<Badge variant="compact">{tag}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : key === "hostTargets" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((target) => (
|
||||
<Badge key={target} variant="compact">
|
||||
{target}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
formatCapabilityValue(value)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Capabilities */}
|
||||
{capEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Capabilities</CardTitle>
|
||||
<CopyButton text={JSON.stringify(capabilities, null, 2)} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{capEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{CAPABILITY_LABELS[key] ?? key}
|
||||
</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{key === "capabilityTags" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((tag) => (
|
||||
<Link key={tag} to="/plugins" search={{ q: tag }}>
|
||||
<Badge variant="compact">{tag}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : key === "hostTargets" && Array.isArray(value) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(value as string[]).map((target) => (
|
||||
<Badge key={target} variant="compact">
|
||||
{target}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
formatCapabilityValue(value)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{/* Compatibility */}
|
||||
{compatEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Compatibility</CardTitle>
|
||||
<CopyButton text={JSON.stringify(compatibility, null, 2)} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{compatEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
|
||||
</dt>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Compatibility */}
|
||||
{compatEntries.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle>Compatibility</CardTitle>
|
||||
<CopyButton text={JSON.stringify(compatibility, null, 2)} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{compatEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)] sm:pr-2">
|
||||
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
|
||||
</dt>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{/* Security Scan */}
|
||||
{latestRelease ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<SecurityScanResults
|
||||
sha256hash={latestRelease.sha256hash ?? undefined}
|
||||
vtAnalysis={latestRelease.vtAnalysis ?? undefined}
|
||||
llmAnalysis={latestRelease.llmAnalysis ?? undefined}
|
||||
staticFindings={latestRelease.staticScan?.findings ?? []}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Security Scan */}
|
||||
{latestRelease ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<SecurityScanResults
|
||||
sha256hash={latestRelease.sha256hash ?? undefined}
|
||||
vtAnalysis={latestRelease.vtAnalysis ?? undefined}
|
||||
llmAnalysis={latestRelease.llmAnalysis ?? undefined}
|
||||
staticFindings={latestRelease.staticScan?.findings ?? []}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{/* Verification */}
|
||||
{verification && !isEmptyObject(verification) ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verification</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{verification.tier ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.tier.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scope ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.scope.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.summary ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceRepo
|
||||
? (() => {
|
||||
const raw = verification.sourceRepo;
|
||||
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
|
||||
const display = href.replace(/^https?:\/\//, "");
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
{display}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
{verification.sourceCommit ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceCommit.slice(0, 12)}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceTag ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceTag}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.hasProvenance !== undefined ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.hasProvenance ? "Yes" : "No"}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scanStatus ? (
|
||||
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Verification */}
|
||||
{verification && !isEmptyObject(verification) ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verification</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{verification.tier ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tier</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.tier.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scope ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scope</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.scope.replace(/-/g, " ")}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.summary ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Summary</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.summary}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceRepo
|
||||
? (() => {
|
||||
const raw = verification.sourceRepo;
|
||||
const href = /^https?:\/\//.test(raw) ? raw : `https://github.com/${raw}`;
|
||||
const display = href.replace(/^https?:\/\//, "");
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Source</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
{display}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
{verification.sourceCommit ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Commit</dt>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceCommit.slice(0, 12)}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.sourceTag ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Tag</dt>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">
|
||||
{verification.sourceTag}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.hasProvenance !== undefined ? (
|
||||
<div className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Provenance</dt>
|
||||
<dd className="text-[color:var(--ink)]">
|
||||
{verification.hasProvenance ? "Yes" : "No"}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{verification.scanStatus ? (
|
||||
<div className="flex flex-col gap-1.5 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">Scan status</dt>
|
||||
<dd className="text-[color:var(--ink)]">{verification.scanStatus}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{/* Tags */}
|
||||
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{Object.entries(pkg.tags).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0"
|
||||
>
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Tags */}
|
||||
{pkg.tags && Object.keys(pkg.tags).length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3 text-sm">
|
||||
{Object.entries(pkg.tags).map(([key, value]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid sm:grid-cols-[minmax(140px,220px)_1fr] sm:gap-x-4 sm:gap-y-0">
|
||||
<dt className="font-semibold text-[color:var(--ink-soft)]">{key}</dt>
|
||||
<dd className="font-mono text-xs text-[color:var(--ink)]">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Readme */}
|
||||
{readme ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<MarkdownPreview>{readme}</MarkdownPreview>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Readme */}
|
||||
{readme ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<MarkdownPreview>{readme}</MarkdownPreview>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,9 +47,7 @@ export const Route = createFileRoute("/plugins/")({
|
||||
? true
|
||||
: undefined,
|
||||
executesCode:
|
||||
search.executesCode === true ||
|
||||
search.executesCode === "true" ||
|
||||
search.executesCode === "1"
|
||||
search.executesCode === true || search.executesCode === "true" || search.executesCode === "1"
|
||||
? true
|
||||
: undefined,
|
||||
}),
|
||||
@@ -76,7 +74,7 @@ export const Route = createFileRoute("/plugins/")({
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
rateLimited: true,
|
||||
retryAfterSeconds: (error as { retryAfterSeconds?: number }).retryAfterSeconds ?? null,
|
||||
retryAfterSeconds: error.retryAfterSeconds,
|
||||
} satisfies PluginsLoaderData;
|
||||
}
|
||||
throw error;
|
||||
@@ -118,8 +116,7 @@ export function PluginsIndex() {
|
||||
};
|
||||
|
||||
const handleFamilySort = (value: string) => {
|
||||
const family =
|
||||
value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
|
||||
const family = value === "code-plugin" || value === "bundle-plugin" ? value : undefined;
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
@@ -205,9 +202,7 @@ export function PluginsIndex() {
|
||||
<div className="empty-state">
|
||||
<AlertTriangle size={20} aria-hidden="true" />
|
||||
<p className="empty-state-title">Plugin catalog is temporarily unavailable</p>
|
||||
<p className="empty-state-body">
|
||||
Try again {formatRetryDelay(retryAfterSeconds)}.
|
||||
</p>
|
||||
<p className="empty-state-body">Try again {formatRetryDelay(retryAfterSeconds)}.</p>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
|
||||
+351
-1
@@ -1,15 +1,28 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import {
|
||||
Eye,
|
||||
Grid3X3,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Monitor,
|
||||
Moon,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { Container } from "../components/layout/Container";
|
||||
import { SignInButton } from "../components/SignInButton";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { SignInButton } from "../components/SignInButton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -21,9 +34,25 @@ import {
|
||||
} from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../components/ui/select";
|
||||
import { Separator } from "../components/ui/separator";
|
||||
import { Switch } from "../components/ui/switch";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { gravatarUrl } from "../lib/gravatar";
|
||||
import {
|
||||
type AnimationLevel,
|
||||
type CodeFontSize,
|
||||
type LayoutDensity,
|
||||
type ListViewMode,
|
||||
usePreferences,
|
||||
} from "../lib/preferences";
|
||||
import { useThemeMode } from "../lib/theme";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: Settings,
|
||||
@@ -33,6 +62,8 @@ export function Settings() {
|
||||
const me = useQuery(api.users.me);
|
||||
const updateProfile = useMutation(api.users.updateProfile);
|
||||
const deleteAccount = useMutation(api.users.deleteAccount);
|
||||
const { mode: themeMode, setMode: setThemeMode } = useThemeMode();
|
||||
const { preferences, updatePreference, resetPreferences, isAdvancedMode } = usePreferences();
|
||||
const tokens = useQuery(api.tokens.listMine, me ? {} : "skip") as
|
||||
| Array<{
|
||||
_id: Id<"apiTokens">;
|
||||
@@ -208,6 +239,325 @@ export function Settings() {
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Customization */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings2 size={18} />
|
||||
Customization
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Personalize your ClawHub experience
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="advanced-mode" className="text-sm text-[color:var(--ink-soft)]">
|
||||
Advanced
|
||||
</Label>
|
||||
<Switch
|
||||
id="advanced-mode"
|
||||
checked={isAdvancedMode}
|
||||
onCheckedChange={(checked) => updatePreference("advancedMode", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Theme Section */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)]">Theme</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={themeMode === "light" ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setThemeMode("light")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Sun size={14} />
|
||||
Light
|
||||
</Button>
|
||||
<Button
|
||||
variant={themeMode === "dark" ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setThemeMode("dark")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Moon size={14} />
|
||||
Dark
|
||||
</Button>
|
||||
<Button
|
||||
variant={themeMode === "system" ? "primary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setThemeMode("system")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Monitor size={14} />
|
||||
System
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Layout Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)]">Layout</Label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="layout-density" className="text-xs text-[color:var(--ink-soft)]">
|
||||
Density
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.layoutDensity}
|
||||
onValueChange={(value) => updatePreference("layoutDensity", value as LayoutDensity)}
|
||||
>
|
||||
<SelectTrigger id="layout-density">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="comfortable">
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid size={14} />
|
||||
Comfortable
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="compact">
|
||||
<span className="flex items-center gap-2">
|
||||
<Grid3X3 size={14} />
|
||||
Compact
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="list-view" className="text-xs text-[color:var(--ink-soft)]">
|
||||
Default view
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.listViewMode}
|
||||
onValueChange={(value) => updatePreference("listViewMode", value as ListViewMode)}
|
||||
>
|
||||
<SelectTrigger id="list-view">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="grid">
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid size={14} />
|
||||
Grid
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="list">
|
||||
<span className="flex items-center gap-2">
|
||||
<List size={14} />
|
||||
List
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-descriptions" className="text-sm">Show descriptions</Label>
|
||||
<Switch
|
||||
id="show-descriptions"
|
||||
checked={preferences.showDescriptions}
|
||||
onCheckedChange={(checked) => updatePreference("showDescriptions", checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-stats" className="text-sm">Show statistics</Label>
|
||||
<Switch
|
||||
id="show-stats"
|
||||
checked={preferences.showStats}
|
||||
onCheckedChange={(checked) => updatePreference("showStats", checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-tags" className="text-sm">Show tags</Label>
|
||||
<Switch
|
||||
id="show-tags"
|
||||
checked={preferences.showTags}
|
||||
onCheckedChange={(checked) => updatePreference("showTags", checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="sticky-header" className="text-sm">Sticky header</Label>
|
||||
<Switch
|
||||
id="sticky-header"
|
||||
checked={preferences.stickyHeader}
|
||||
onCheckedChange={(checked) => updatePreference("stickyHeader", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdvancedMode ? (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* Code & Content Section - Advanced */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
|
||||
<Sparkles size={14} className="text-[color:var(--accent)]" />
|
||||
Code & Content
|
||||
</Label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code-font-size" className="text-xs text-[color:var(--ink-soft)]">
|
||||
Code font size
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.codeFontSize}
|
||||
onValueChange={(value) => updatePreference("codeFontSize", value as CodeFontSize)}
|
||||
>
|
||||
<SelectTrigger id="code-font-size">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">Small (12px)</SelectItem>
|
||||
<SelectItem value="medium">Medium (14px)</SelectItem>
|
||||
<SelectItem value="large">Large (16px)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="animation-level" className="text-xs text-[color:var(--ink-soft)]">
|
||||
Animation level
|
||||
</Label>
|
||||
<Select
|
||||
value={preferences.animationLevel}
|
||||
onValueChange={(value) => updatePreference("animationLevel", value as AnimationLevel)}
|
||||
>
|
||||
<SelectTrigger id="animation-level">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full">
|
||||
<span className="flex items-center gap-2">
|
||||
<Zap size={14} />
|
||||
Full
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="reduced">Reduced</SelectItem>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="line-numbers" className="text-sm">Line numbers in code</Label>
|
||||
<Switch
|
||||
id="line-numbers"
|
||||
checked={preferences.lineNumbers}
|
||||
onCheckedChange={(checked) => updatePreference("lineNumbers", checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="word-wrap" className="text-sm">Word wrap in code</Label>
|
||||
<Switch
|
||||
id="word-wrap"
|
||||
checked={preferences.wordWrap}
|
||||
onCheckedChange={(checked) => updatePreference("wordWrap", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Accessibility Section - Advanced */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
|
||||
<Eye size={14} className="text-[color:var(--accent)]" />
|
||||
Accessibility
|
||||
</Label>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="reduced-motion" className="text-sm">Reduced motion</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Minimize animations</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="reduced-motion"
|
||||
checked={preferences.reducedMotion}
|
||||
onCheckedChange={(checked) => updatePreference("reducedMotion", checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="high-contrast" className="text-sm">High contrast</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Increase color contrast</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="high-contrast"
|
||||
checked={preferences.highContrast}
|
||||
onCheckedChange={(checked) => updatePreference("highContrast", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Experimental Features - Advanced */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-semibold text-[color:var(--ink)] flex items-center gap-2">
|
||||
<Sparkles size={14} className="text-[color:var(--gold)]" />
|
||||
Experimental
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="experimental-features" className="text-sm">Enable experimental features</Label>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Try new features before they're released</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="experimental-features"
|
||||
checked={preferences.experimentalFeatures}
|
||||
onCheckedChange={(checked) => updatePreference("experimentalFeatures", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Reset Section */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[color:var(--ink)]">Reset preferences</p>
|
||||
<p className="text-xs text-[color:var(--ink-soft)]">Restore all settings to defaults</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
resetPreferences();
|
||||
toast.success("Preferences reset to defaults");
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Organizations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
+831
-373
File diff suppressed because it is too large
Load Diff
+2
-5
@@ -6,7 +6,6 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
import { nitro } from "nitro/vite";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import viteTsConfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -166,6 +165,8 @@ const config = defineConfig({
|
||||
"convex/values": convexValuesPath,
|
||||
"@convex-dev/auth/react": convexAuthReactPath,
|
||||
},
|
||||
// Use native Vite tsconfig paths resolution instead of the plugin
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ["convex/react", "convex/browser"],
|
||||
@@ -179,10 +180,6 @@ const config = defineConfig({
|
||||
onwarn: handleRollupWarning,
|
||||
},
|
||||
}),
|
||||
// this is the plugin that enables path aliases
|
||||
viteTsConfigPaths({
|
||||
projects: ["./tsconfig.json"],
|
||||
}),
|
||||
tailwindcss(),
|
||||
tanstackStart(),
|
||||
viteReact(),
|
||||
|
||||
Reference in New Issue
Block a user