Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7c72bb56c | ||
|
|
4787be4eb1 | ||
|
|
89246f1927 | ||
|
|
f4ddccbead | ||
|
|
3cafcbf873 | ||
|
|
13064a7897 | ||
|
|
194c22f4dd | ||
|
|
a693b945fa | ||
|
|
9bef672541 | ||
|
|
9551cac37b | ||
|
|
eb4138fbb3 | ||
|
|
5fbead624b | ||
|
|
35094177e6 | ||
|
|
faa5c9f2b5 | ||
|
|
c3314c2d01 | ||
|
|
7dfa19157c | ||
|
|
44acf86ac1 | ||
|
|
a0ebc1b50a | ||
|
|
88dbb69a23 | ||
|
|
df9acd27e4 | ||
|
|
dbd5d4042c | ||
|
|
ebe82b7e18 | ||
|
|
4c566268a9 | ||
|
|
e54fc1939a | ||
|
|
530e39eedc | ||
|
|
8b87c31a99 | ||
|
|
f7bc8b6349 | ||
|
|
5b8f09167a | ||
|
|
17fbd13bc9 | ||
|
|
aab7dc9ba4 | ||
|
|
dde8796790 | ||
|
|
acc6d292de | ||
|
|
2236ed7be1 | ||
|
|
f6fb7ccfc0 | ||
|
|
b73758c7c8 | ||
|
|
fbc07c5617 | ||
|
|
80e5aec577 | ||
|
|
f869b31ad6 | ||
|
|
9a853f2fcc | ||
|
|
b4a7540157 | ||
|
|
0ea1127a2b | ||
|
|
aeab23a6d6 | ||
|
|
15bc4440cc | ||
|
|
11a20f5755 | ||
|
|
05f8674628 | ||
|
|
d2b2252770 | ||
|
|
a2387253ec | ||
|
|
411260767b | ||
|
|
731d0ce0c5 | ||
|
|
361f2affde | ||
|
|
a17f7bb07e | ||
|
|
7980788ea6 | ||
|
|
2d03b827d3 | ||
|
|
835094ea2c | ||
|
|
7bd7e4c99e | ||
|
|
1722a48055 | ||
|
|
29178898bb | ||
|
|
b16861f422 | ||
|
|
6896e61fa1 | ||
|
|
cf137aa592 | ||
|
|
a28d014d4f | ||
|
|
b2038fc931 | ||
|
|
4a72b543b2 | ||
|
|
54e99c8cc2 | ||
|
|
9a45c371fc | ||
|
|
298cbdd6db | ||
|
|
f636b31fca | ||
|
|
b255b5865f | ||
|
|
5003c1bec8 | ||
|
|
a8a6242f87 | ||
|
|
39c0fa2531 | ||
|
|
051b1dafcd | ||
|
|
383844cacf | ||
|
|
b7923edbfd | ||
|
|
522fa22026 | ||
|
|
e41d5d6314 | ||
|
|
c62ab8bc96 | ||
|
|
6df3a3b9ef | ||
|
|
7452cf6f69 | ||
|
|
b4e8a26eb4 | ||
|
|
7ff601bcb8 | ||
|
|
70af109cb2 | ||
|
|
1504708208 |
@@ -27,9 +27,13 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: bun run test
|
||||
env:
|
||||
VITE_CONVEX_URL: https://example.invalid
|
||||
|
||||
- name: Coverage
|
||||
run: bun run coverage
|
||||
env:
|
||||
VITE_CONVEX_URL: https://example.invalid
|
||||
|
||||
- name: ClawHub CLI Verify
|
||||
run: bun run --cwd packages/clawhub verify
|
||||
|
||||
@@ -24,3 +24,7 @@ coverage
|
||||
playwright-report
|
||||
test-results
|
||||
.playwright
|
||||
convex/_generated/
|
||||
skills-lock.json
|
||||
*/skills/*
|
||||
skills/*
|
||||
@@ -87,3 +87,31 @@
|
||||
- **32K document limit per query.** Split `.collect()` calls by a partition field (e.g., one day at a time instead of a 7-day range). See `rebuildTrendingLeaderboardAction` in `convex/leaderboards.ts` for an example.
|
||||
- **Common mistakes**: `.filter().collect()` without an index; `ctx.db.get()` on large docs in a loop for list views; while loops that paginate the whole table to find filtered results.
|
||||
- **Before writing or reviewing Convex queries, check deployment health.** Run `bunx convex insights` to check for OCC conflicts, `bytesReadLimit`, and `documentsReadLimit` errors. Run `bunx convex logs --failure` to see individual error messages and stack traces. This helps identify which functions are causing bandwidth issues so you can prioritize fixes.
|
||||
|
||||
<!-- convex-ai-start -->
|
||||
This project uses [Convex](https://convex.dev) as its backend.
|
||||
|
||||
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
|
||||
|
||||
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
|
||||
<!-- convex-ai-end -->
|
||||
|
||||
## Stat Field Migration Rules
|
||||
|
||||
The `skills` table maintains two parallel sets of stat fields as part of an in-progress field migration:
|
||||
|
||||
| Legacy (nested, `@deprecated`) | Top-level (source of truth, indexable) |
|
||||
|---|---|
|
||||
| `stats.downloads` | `statsDownloads` |
|
||||
| `stats.stars` | `statsStars` |
|
||||
| `stats.installsCurrent` | `statsInstallsCurrent` |
|
||||
| `stats.installsAllTime` | `statsInstallsAllTime` |
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Always use `readCanonicalStat(skill, field)` (`convex/lib/skillStats.ts`) to read** any of the four migrated fields. It prefers the top-level field and falls back to the nested field for pre-migration documents. Never access `skill.stats.downloads` / `.stars` / `.installsCurrent` / `.installsAllTime` directly.
|
||||
- **Always use `applySkillStatDeltas()` to write** stat deltas. It writes both the top-level and nested fields in the same patch to keep them in sync.
|
||||
- **Both sets of fields must be written together** in any patch that touches stat values (see the return shape of `applySkillStatDeltas`).
|
||||
- **Nested-only reads are acceptable only for** `stats.comments` and `stats.versions` — no top-level field exists for these yet.
|
||||
- The four legacy nested fields are marked `@deprecated` in `statsValidator` (schema.ts). Any IDE access to `skill.stats.downloads` etc. will show a strikethrough warning — treat this as a signal to use `readCanonicalStat()` instead.
|
||||
- When adding new stat fields, follow the same dual-write pattern and add a cursor-based backfill mutation (see `backfillSkillStatFieldsInternal` for an example).
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
### Changed
|
||||
|
||||
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
|
||||
- Skill ownership transfers now support org targets: `transfer request` and `transfer accept` accept a `--publisher` flag to send or receive a skill on behalf of an org. Org admins can see incoming org-targeted transfers via `GET /api/v1/transfers/incoming`. (#1603 thanks @TommYDeeee)
|
||||
- Stats: centralize migrated skill stat fallback reads through `readCanonicalStat()` and add schema/agent guardrails to discourage direct legacy nested-field access (#1709) (thanks @momothemage).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Stats maintenance: keep skill stat migration fields synchronized by treating top-level stat fields as canonical during backfill/reconcile fallback reads (#1704) (thanks @momothemage).
|
||||
|
||||
## 0.10.0 - 2026-04-05
|
||||
|
||||
|
||||
@@ -45,3 +45,11 @@
|
||||
|
||||
- Tests use `._handler` to call mutation handlers directly with mock `db` objects.
|
||||
- Mock `db` objects MUST include `normalizeId: vi.fn()` for trigger wrapper compatibility.
|
||||
|
||||
<!-- convex-ai-start -->
|
||||
This project uses [Convex](https://convex.dev) as its backend.
|
||||
|
||||
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
|
||||
|
||||
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
|
||||
<!-- convex-ai-end -->
|
||||
|
||||
@@ -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
|
||||
@@ -10,7 +10,7 @@
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
ClawHub is the **public skill registry for Clawdbot**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
|
||||
ClawHub is the **public skill registry for OpenClaw**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
|
||||
It's designed for fast browsing + a CLI-friendly API, with moderation hooks and vector search.
|
||||
It also now exposes a native **OpenClaw package catalog** for code plugins and bundle plugins.
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "clawhub",
|
||||
@@ -20,6 +19,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",
|
||||
@@ -32,6 +33,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",
|
||||
@@ -41,6 +43,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",
|
||||
@@ -51,6 +55,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",
|
||||
@@ -118,9 +123,9 @@
|
||||
|
||||
"@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.6" } }, "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw=="],
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.10", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww=="],
|
||||
|
||||
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.0.4", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7" } }, "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w=="],
|
||||
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.0.9", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg=="],
|
||||
|
||||
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
|
||||
|
||||
@@ -170,33 +175,33 @@
|
||||
|
||||
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.1.0", "", { "dependencies": { "sisteransi": "^1.0.5" } }, "sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA=="],
|
||||
"@clack/core": ["@clack/core@1.2.0", "", { "dependencies": { "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg=="],
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@1.1.0", "", { "dependencies": { "@clack/core": "1.1.0", "sisteransi": "^1.0.5" } }, "sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g=="],
|
||||
"@clack/prompts": ["@clack/prompts@1.2.0", "", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="],
|
||||
|
||||
"@convex-dev/auth": ["@convex-dev/auth@0.0.91", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "cookie": "^1.0.1", "is-network-error": "^1.1.0", "jose": "^5.2.2", "jwt-decode": "^4.0.0", "lucia": "^3.2.0", "oauth4webapi": "^3.1.2", "path-to-regexp": "^6.3.0", "server-only": "^0.0.1" }, "peerDependencies": { "@auth/core": "^0.37.0", "convex": "^1.17.0", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["react"], "bin": { "auth": "dist/bin.cjs" } }, "sha512-wLD4hszo3IhhMkwPs6ozWf0cUauwmhOvjUVn0g//kC338n/jApOjeDYWKCrn/qYUkveyDsbag5zrY8mVzA09Qg=="],
|
||||
|
||||
"@create-markdown/core": ["@create-markdown/core@2.0.2", "", {}, "sha512-maA3zw9HkdOZORpKyvmxcRFTTOCpClLW01oAuVtzW7LvafHippRz67VHngIBEsIPKEO5j4COItwXKFnzo9/dfA=="],
|
||||
"@create-markdown/core": ["@create-markdown/core@2.0.3", "", {}, "sha512-qAYukvE603z42OGZF1LzwxxkOVDksB76wXu+fnlKBzGizhR7uN3xHQO8PFFZDqjkZpaTrmtDd768qzl+Ir+3pQ=="],
|
||||
|
||||
"@create-markdown/preview": ["@create-markdown/preview@2.0.2", "", { "peerDependencies": { "@create-markdown/core": ">=2.0.2", "mermaid": ">=10.0.0", "shiki": ">=1.0.0" }, "optionalPeers": ["@create-markdown/core", "mermaid", "shiki"] }, "sha512-ty1mp7qXVI0Bap8M0jiDiJsAqZkP3oaYNp0JX0wiY4K+KfWgK4IqeB8R2W+9vLpRxzxhX6Rggf5Qj2Sv5p75Eg=="],
|
||||
"@create-markdown/preview": ["@create-markdown/preview@2.0.3", "", { "peerDependencies": { "@create-markdown/core": ">=2.0.3", "shiki": ">=1.0.0" }, "optionalPeers": ["@create-markdown/core", "shiki"] }, "sha512-Vrp8DyuiouryZ3E4NQ7tBgoYQdoekd0+DzN64mZ48QYCw3V+MCb/H2q10SW8KC8XPr931XOMDvKX4I83qpQh3g=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.1.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="],
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="],
|
||||
|
||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.2", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.1.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw=="],
|
||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.0", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ=="],
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.1", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w=="],
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
"@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
|
||||
|
||||
@@ -266,6 +271,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=="],
|
||||
@@ -280,7 +335,25 @@
|
||||
|
||||
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
|
||||
|
||||
"@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=="],
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -350,43 +423,43 @@
|
||||
|
||||
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.17.4", "", { "os": "win32", "cpu": "x64" }, "sha512-JxT81aEUBNA/s01Ql2OQ2DLAsuM0M+mK9iLHunukOdPMhjA6NvFE/GtTablBYJKScK21d/xTvnoSLgQU3l22Cw=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.58.0", "", { "os": "android", "cpu": "arm" }, "sha512-1T7UN3SsWWxpWyWGn1cT3ASNJOo+pI3eUkmEl7HgtowapcV8kslYpFQcYn431VuxghXakPNlbjRwhqmR37PFOg=="],
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.58.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GryzujxuiRv2YFF7bRy8mKcxlbuAN+euVUtGJt9KKbLT8JBUIosamVhcthLh+VEr6KE6cjeVMAQxKAzJcoN7dg=="],
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.58.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7/bRSJIwl4GxeZL9rPZ11anNTyUO9epZrfEJH/ZMla3+/gbQ6xZixh9nOhsZ0QwsTW7/5J2A/fHbD1udC5DQQA=="],
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pJsgd9AfplLGBm1fIr25V6V14vMrayhx4uIQvlfH7jWs2SZwSrvi3TfgfJySB8T+hvyEH8K2zXljQiUnkgUnfQ=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.58.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EqdtJSiHweS2vfILNrpyJ6HUwpEq2g7+4Zx1FPi4hu3Hu7tC3znF6ufbXO8Ub2LD4mGgznjI7kSdku9NDD1Mkg=="],
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ue1aXHX49ivwflKqGJc7zcd/LeLgbhaTcDCQStgx5x06AXgjEAZmvrlMuIkWd4AL4FHQe6QJ9f33z04Cg448VQ=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.58.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-VQt5TH4M42mY20F545G637RKxV/yjwVtKk2vfXuazfReSIiuvWBnv+FVSvIV5fKVTJNjt3GSJibh6JecbhGdBw=="],
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YCyQzsQtusQw+gNRW9rRTifSO+Dt/+dtCl2NHoDMZqJlRTEZ/Oht9YnuporI9yiTx7+cB+eqzX3MtHHVHGIWhg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-fBYcj4ucwpAtjJT3oeBdFBYKvNyjRSK+cyuvBOTQjh0jvKp4yeA4S/D0IsCHus/VPaNG5L48qQkh+Vjy3HL2/Q=="],
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-c7dxM2Zksa45Qw16i2iGY3Fti2NirJ38FrsBsKw+qcJ0OtqTsBgKJLF0xV+yLG56UH01Z8WRPgsw31e0MoRoGQ=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0BeuFfwlUHlJ1xpEdSD1YO3vByEFGPg36uLjK1JgFaxFb4W6w17F8ET8sz5cheZ4+x5f2xzdnRrrWv83E3Yd8g=="],
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ZWALoA42UYqBEP1Tbw9OWURgFGS1nWj2AAvLdY6ZcGx/Gj93qVCBKjcvwXMupZibYwFbi9s/rzqkZseb/6gVtQ=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TXlZgnPTlxrQzxG9ZXU7BNwx1Ilrr17P3GwZY0If2EzrinqRH3zXPc3HrRcBJgcsoZNMuNL5YivtkJYgp467UQ=="],
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tpy+1w4p9hN5CicMCxqNy6ymfRtV5ayE573vFNjp1k1TN/qhLFgflveZoE/0++RlkHikBz2vY545NWm/hp7big=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zSoYRo5dxHLcUx93Stl2hW3hSNjPt99O70eRVWt5A1zwJ+FPjeCCANCD2a9R4JbHsdcl11TIQOjyigcRVOH2mw=="],
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eDYDXZGhQAXyn6GwtwiX/qcLS0HlOLPJ/+iiIY8RYr+3P8oKBmgKxADLlniL6FtWfE7pPk7IGN9/xvDEvDvFeg=="],
|
||||
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.58.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NQ0U/lqxH2/VxBYeAIvMNUK1y0a1bJ3ZicqkF2c6wfakbEciP9jvIE4yNzCFpZaqeIeRYaV7AVGqEO1yrfVPjA=="],
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nxehly5XYBHUWI9VJX1bqCf9j/B43DaK/aS/T1fcxCpX3PA4Rm9BB54nPD1CKayT8xg6REN1ao+01hSRNgy8OA=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-X9J+kr3gIC9FT8GuZt0ekzpNUtkBVzMVU4KiKDSlocyQuEgi3gBbXYN8UkQiV77FTusLDPsovjo95YedHr+3yg=="],
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-j1qf/NaUfOWQutjeoooNG1Q0zsK0XGmSu1uDLq3cctquRF3j7t9Hxqf/76ehCc5GEUAanth2W4Fa+XT1RFg/nw=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-CDze3pi1OO3Wvb/QsXjmLEY4XPKGM6kIo82ssNOgmcl1IdndF9VSGAE38YLhADWmOac7fjqhBw82LozuUVxD0Q=="],
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-YELKPRefQ/q/h3RUmeRfPCUhh2wBvgV1RyZ/F9M9u8cDyXsQW2ojv1DeWQTt466yczDITjZnIOg/s05pk7Ve2A=="],
|
||||
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.58.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-b/89glbxFaEAcA6Uf1FvCNecBJEgcUTsV1quzrqXM/o4R1M4u+2KCVuyGCayN2UpsRWtGGLb+Ver0tBBpxaPog=="],
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JkO3C6Gki7Y6h/MiIkFKvHFOz98/YWvQ4WYbK9DLXACMP2rjULzkeGyAzorJE5S1dzLQGFgeqvN779kSFwoV1g=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0/yYpkq9VJFCEcuRlrViGj8pJUFFvNS4EkEREaN7CB1EcLXJIaVSSa5eCihwBGXtOZxhnblWgxks9juRdNQI7w=="],
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XjKHdFVCpZZZSWBCKyyqCq65s2AKXykMXkjLoKYODrD+f5toLhlwsMESscu8FbgnJQ4Y/dpR/zdazsahmgBJIA=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hr6FNvmcAXiH+JxSvaJ4SJ1HofkdqEElXICW9sm3/Rd5eC3t7kzvmLyRAB3NngKO2wzXRCAm4Z/mGWfrsS4X8w=="],
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-js29ZWIuPhNWzY8NC7KoffEMEeWG105vbmm+8EOJsC+T/jHBiKIJEUF78+F/IrgEWMMP9N0kRND4Pp75+xAhKg=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.58.0", "", { "os": "none", "cpu": "arm64" }, "sha512-R+O368VXgRql1K6Xar+FEo7NEwfo13EibPMoTv3sesYQedRXd6m30Dh/7lZMxnrQVFfeo4EOfYIP4FpcgWQNHg=="],
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-H+PUITKHk04stFpWj3x3Kg08Afp/bcXSBi0EhasR5a0Vw7StXHTzdl655PUI0fB4qdh2Wsu6Dsi+3ACxPoyQnA=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.58.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q0FZiAY/3c4YRj4z3h9K1PgaByrifrfbBoODSeX7gy97UtB7pySPUQfC2B/GbxWU6k7CzQrRy5gME10PltLAFQ=="],
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-WA/yc7f7ZfCefBXVzNHn1Ztulb1EFwNBb4jMZ6pjML0zz6pHujlF3Q3jySluz3XHl/GNeMTntG1seUBWVMlMag=="],
|
||||
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.58.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Y8FKBABrSPp9H0QkRLHDHOSUgM/309a3IvOVgPcVxYcX70wxJrk608CuTg7w+C6vEd724X5wJoNkBcGYfH7nNQ=="],
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-33YxL1sqwYNZXtn3MD/4dno6s0xeedXOJlT1WohkVD565WvohClZUr7vwKdAk954n4xiEWJkewiCr+zLeq7AeA=="],
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.58.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bCn5rbiz5My+Bj7M09sDcnqW0QJyINRVxdZ65x1/Y2tGrMwherwK/lpk+HRQCKvXa8pcaQdF5KY5j54VGZLwNg=="],
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-JOro4ZcfBLamJCyfURQmOQByoorgOdx3ZjAkSqnb/CyG/i+lN3KoV5LAgk5ZAW6DPq7/Cx7n23f8DuTWXTWgyQ=="],
|
||||
|
||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||
|
||||
@@ -440,7 +513,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=="],
|
||||
|
||||
@@ -536,6 +611,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=="],
|
||||
@@ -592,7 +669,7 @@
|
||||
|
||||
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.166.16", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-router": "1.168.1", "@tanstack/router-core": "1.168.1", "@tanstack/start-client-core": "1.167.1", "@tanstack/start-server-core": "1.167.1" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-YEuM5XSxNQhLr30e6uyep7m5yZHtZwCeEeQVyo7CSWKmUpkBtN60+bg4T2/nLY0MXrwo6DTK1Crsu80ZZLkPAA=="],
|
||||
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="],
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
|
||||
|
||||
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
|
||||
|
||||
@@ -616,7 +693,7 @@
|
||||
|
||||
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.166.15", "", { "dependencies": { "@tanstack/router-core": "1.168.1" } }, "sha512-mGDNfJo/eFtwgFFBrJ85rNdIBNTroE3zy5zbwHZ/FV0HPYOawnev7KscDjKBuVxBGY2jl0fQLrRNUO/Sjqy3cg=="],
|
||||
|
||||
"@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="],
|
||||
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
|
||||
|
||||
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||
|
||||
@@ -646,7 +723,7 @@
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
@@ -664,21 +741,21 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.2", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.2", "vitest": "4.1.2" }, "optionalPeers": ["@vitest/browser"] }, "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg=="],
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.4", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.4", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.4", "vitest": "4.1.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ=="],
|
||||
"@vitest/expect": ["@vitest/expect@4.1.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.2", "", { "dependencies": { "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q=="],
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.4", "", { "dependencies": { "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="],
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.4", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.2", "", { "dependencies": { "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ=="],
|
||||
"@vitest/runner": ["@vitest/runner@4.1.4", "", { "dependencies": { "@vitest/utils": "4.1.4", "pathe": "^2.0.3" } }, "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A=="],
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.4", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.2", "", {}, "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA=="],
|
||||
"@vitest/spy": ["@vitest/spy@4.1.4", "", {}, "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="],
|
||||
"@vitest/utils": ["@vitest/utils@4.1.4", "", { "dependencies": { "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
@@ -710,7 +787,7 @@
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ=="],
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
@@ -720,9 +797,9 @@
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="],
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
@@ -744,6 +821,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"],
|
||||
@@ -752,6 +831,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=="],
|
||||
@@ -762,15 +843,15 @@
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"convex": ["convex@1.34.1", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-ooyFnZVVq0u6b5zt0Ptq8QB2ixhf/2vXe+PIcUtdtrs0lq/TwpkmmruHdqkFmWgMd6N+Tmfy8AGkz6QnZUYZBA=="],
|
||||
"convex": ["convex@1.35.1", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "@clerk/react": "^6.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "@clerk/react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-g23KrTjBiXqRHzWIN0PVFagKjrmFxWUaOSiBsAWPTpXX2rXl0L1F4PR0YpAcMJEzMgfZR9AGymJvLTM+KA6lsQ=="],
|
||||
|
||||
"convex-helpers": ["convex-helpers@0.1.114", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.32.0", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-elEdh+gG6BDv2dWIWVvBeJPbHnDQS5+WexUuwlGVJXz1EbMkXz/UIQwFIfLMZIXUwW6ot4JYf/1JJKNStrE6lg=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
|
||||
"cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="],
|
||||
|
||||
"crossws": ["crossws@0.4.4", "", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg=="],
|
||||
"crossws": ["crossws@0.4.5", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
@@ -800,7 +881,7 @@
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
@@ -814,7 +895,7 @@
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.321", "", {}, "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ=="],
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.336", "", {}, "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ=="],
|
||||
|
||||
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
|
||||
|
||||
@@ -822,7 +903,7 @@
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"env-runner": ["env-runner@0.1.6", "", { "dependencies": { "crossws": "^0.4.4", "httpxy": "^0.3.1", "srvx": "^0.11.9" }, "peerDependencies": { "miniflare": "^4.0.0" }, "optionalPeers": ["miniflare"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-fSb7X1zdda8k6611a6/SdSQpDe7a/bqMz2UWdbHjk9YWzpUR4/fn9YtE/hqgGQ2nhvVN0zUtcL1SRMKwIsDbAA=="],
|
||||
"env-runner": ["env-runner@0.1.7", "", { "dependencies": { "crossws": "^0.4.4", "exsolve": "^1.0.8", "httpxy": "^0.5.0", "srvx": "^0.11.13" }, "peerDependencies": { "@netlify/runtime": "^4", "miniflare": "^4.20260317.3" }, "optionalPeers": ["@netlify/runtime", "miniflare"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-i7h96jxETJYhXy5grgHNJ9xNzCzWIn9Ck/VkkYgOlE4gOqknsLX3CmlVb5LmwNex8sOoLFVZLz+TIw/+b5rktA=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="],
|
||||
|
||||
@@ -844,6 +925,12 @@
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="],
|
||||
|
||||
"fast-string-width": ["fast-string-width@1.1.0", "", { "dependencies": { "fast-string-truncated-width": "^1.2.0" } }, "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ=="],
|
||||
|
||||
"fast-wrap-ansi": ["fast-wrap-ansi@0.1.6", "", { "dependencies": { "fast-string-width": "^1.1.0" } }, "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
@@ -892,7 +979,7 @@
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
|
||||
"httpxy": ["httpxy@0.3.1", "", {}, "sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw=="],
|
||||
"httpxy": ["httpxy@0.5.0", "", {}, "sha512-qwX7QX/rK2visT10/b7bSeZWQOMlSm3svTD0pZpU+vJjNUP0YHtNv4c3z+MO+MSnGuRFWJFdCZiV+7F7dXIOzg=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
@@ -926,7 +1013,7 @@
|
||||
|
||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"isbot": ["isbot@5.1.36", "", {}, "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ=="],
|
||||
"isbot": ["isbot@5.1.38", "", {}, "sha512-Cus2702JamTNMEY4zTP+TShgq/3qzjvGcBC4XMOV45BLaxD4iUFENkqu7ZhFeSzwNsCSZLjnGlihDQznnpnEEA=="],
|
||||
|
||||
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
|
||||
|
||||
@@ -942,7 +1029,7 @@
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"jsdom": ["jsdom@29.0.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.0.1", "@asamuzakjp/dom-selector": "^7.0.3", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg=="],
|
||||
"jsdom": ["jsdom@29.0.2", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.5", "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
@@ -980,7 +1067,7 @@
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
|
||||
"lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
|
||||
|
||||
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
|
||||
|
||||
@@ -1096,11 +1183,15 @@
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"nf3": ["nf3@0.3.13", "", {}, "sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw=="],
|
||||
"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.16", "", {}, "sha512-Gs0xRPpUm2nDkqbi40NJ9g7qDIcjcJzgExiydnq6LAyqhI2jfno8wG3NKTL+IiJsx799UHOb1CnSd4Wg4SG4Pw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
@@ -1128,7 +1219,7 @@
|
||||
|
||||
"oxfmt": ["oxfmt@0.41.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.41.0", "@oxfmt/binding-android-arm64": "0.41.0", "@oxfmt/binding-darwin-arm64": "0.41.0", "@oxfmt/binding-darwin-x64": "0.41.0", "@oxfmt/binding-freebsd-x64": "0.41.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.41.0", "@oxfmt/binding-linux-arm-musleabihf": "0.41.0", "@oxfmt/binding-linux-arm64-gnu": "0.41.0", "@oxfmt/binding-linux-arm64-musl": "0.41.0", "@oxfmt/binding-linux-ppc64-gnu": "0.41.0", "@oxfmt/binding-linux-riscv64-gnu": "0.41.0", "@oxfmt/binding-linux-riscv64-musl": "0.41.0", "@oxfmt/binding-linux-s390x-gnu": "0.41.0", "@oxfmt/binding-linux-x64-gnu": "0.41.0", "@oxfmt/binding-linux-x64-musl": "0.41.0", "@oxfmt/binding-openharmony-arm64": "0.41.0", "@oxfmt/binding-win32-arm64-msvc": "0.41.0", "@oxfmt/binding-win32-ia32-msvc": "0.41.0", "@oxfmt/binding-win32-x64-msvc": "0.41.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-sKLdJZdQ3bw6x9qKiT7+eID4MNEXlDHf5ZacfIircrq6Qwjk0L6t2/JQlZZrVHTXJawK3KaMuBoJnEJPcqCEdg=="],
|
||||
|
||||
"oxlint": ["oxlint@1.58.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.58.0", "@oxlint/binding-android-arm64": "1.58.0", "@oxlint/binding-darwin-arm64": "1.58.0", "@oxlint/binding-darwin-x64": "1.58.0", "@oxlint/binding-freebsd-x64": "1.58.0", "@oxlint/binding-linux-arm-gnueabihf": "1.58.0", "@oxlint/binding-linux-arm-musleabihf": "1.58.0", "@oxlint/binding-linux-arm64-gnu": "1.58.0", "@oxlint/binding-linux-arm64-musl": "1.58.0", "@oxlint/binding-linux-ppc64-gnu": "1.58.0", "@oxlint/binding-linux-riscv64-gnu": "1.58.0", "@oxlint/binding-linux-riscv64-musl": "1.58.0", "@oxlint/binding-linux-s390x-gnu": "1.58.0", "@oxlint/binding-linux-x64-gnu": "1.58.0", "@oxlint/binding-linux-x64-musl": "1.58.0", "@oxlint/binding-openharmony-arm64": "1.58.0", "@oxlint/binding-win32-arm64-msvc": "1.58.0", "@oxlint/binding-win32-ia32-msvc": "1.58.0", "@oxlint/binding-win32-x64-msvc": "1.58.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-t4s9leczDMqlvOSjnbCQe7gtoLkWgBGZ7sBdCJ9EOj5IXFSG/X7OAzK4yuH4iW+4cAYe8kLFbC8tuYMwWZm+Cg=="],
|
||||
"oxlint": ["oxlint@1.60.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.60.0", "@oxlint/binding-android-arm64": "1.60.0", "@oxlint/binding-darwin-arm64": "1.60.0", "@oxlint/binding-darwin-x64": "1.60.0", "@oxlint/binding-freebsd-x64": "1.60.0", "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", "@oxlint/binding-linux-arm-musleabihf": "1.60.0", "@oxlint/binding-linux-arm64-gnu": "1.60.0", "@oxlint/binding-linux-arm64-musl": "1.60.0", "@oxlint/binding-linux-ppc64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-musl": "1.60.0", "@oxlint/binding-linux-s390x-gnu": "1.60.0", "@oxlint/binding-linux-x64-gnu": "1.60.0", "@oxlint/binding-linux-x64-musl": "1.60.0", "@oxlint/binding-openharmony-arm64": "1.60.0", "@oxlint/binding-win32-arm64-msvc": "1.60.0", "@oxlint/binding-win32-ia32-msvc": "1.60.0", "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw=="],
|
||||
|
||||
"oxlint-tsgolint": ["oxlint-tsgolint@0.17.4", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.17.4", "@oxlint-tsgolint/darwin-x64": "0.17.4", "@oxlint-tsgolint/linux-arm64": "0.17.4", "@oxlint-tsgolint/linux-x64": "0.17.4", "@oxlint-tsgolint/win32-arm64": "0.17.4", "@oxlint-tsgolint/win32-x64": "0.17.4" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-4F/NXJiK2KnK4LQiULUPXRzVq0LOfextGvwCVRW1VKQbF5epI3MDMEGVAl5XjAGL6IFc7xBc/eVA95wczPeEQg=="],
|
||||
|
||||
@@ -1148,19 +1239,19 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
"postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
|
||||
|
||||
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
|
||||
|
||||
"preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
|
||||
|
||||
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||
"prettier": ["prettier@3.8.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q=="],
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
@@ -1168,9 +1259,9 @@
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||
|
||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
@@ -1218,12 +1309,14 @@
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"seroval": ["seroval@1.5.1", "", {}, "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA=="],
|
||||
"seroval": ["seroval@1.5.2", "", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="],
|
||||
|
||||
"seroval-plugins": ["seroval-plugins@1.5.1", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw=="],
|
||||
"seroval-plugins": ["seroval-plugins@1.5.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg=="],
|
||||
|
||||
"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=="],
|
||||
@@ -1234,7 +1327,7 @@
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
|
||||
"solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="],
|
||||
|
||||
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||
|
||||
@@ -1244,7 +1337,7 @@
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"srvx": ["srvx@0.11.12", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-AQfrGqntqVPXgP03pvBDN1KyevHC+KmYVqb8vVf4N+aomQqdhaZxjvoVp+AOm4u6x+GgNQY3MVzAUIn+TqwkOA=="],
|
||||
"srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
@@ -1264,6 +1357,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=="],
|
||||
@@ -1272,7 +1367,7 @@
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
@@ -1280,17 +1375,17 @@
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
|
||||
"tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"tldts": ["tldts@7.0.27", "", { "dependencies": { "tldts-core": "^7.0.27" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg=="],
|
||||
"tldts": ["tldts@7.0.28", "", { "dependencies": { "tldts-core": "^7.0.28" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.27", "", {}, "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg=="],
|
||||
"tldts-core": ["tldts-core@7.0.28", "", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
@@ -1308,13 +1403,15 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"undici": ["undici@7.24.7", "", {}, "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ=="],
|
||||
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||
|
||||
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
||||
|
||||
@@ -1350,9 +1447,9 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
|
||||
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||
|
||||
"vitest": ["vitest@4.1.2", "", { "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", "@vitest/pretty-format": "4.1.2", "@vitest/runner": "4.1.2", "@vitest/snapshot": "4.1.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.2", "@vitest/browser-preview": "4.1.2", "@vitest/browser-webdriverio": "4.1.2", "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg=="],
|
||||
"vitest": ["vitest@4.1.4", "", { "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", "@vitest/pretty-format": "4.1.4", "@vitest/runner": "4.1.4", "@vitest/snapshot": "4.1.4", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.4", "@vitest/browser-preview": "4.1.4", "@vitest/browser-webdriverio": "4.1.4", "@vitest/coverage-istanbul": "4.1.4", "@vitest/coverage-v8": "4.1.4", "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
@@ -1400,10 +1497,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=="],
|
||||
@@ -1416,14 +1517,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=="],
|
||||
@@ -1436,6 +1537,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=="],
|
||||
@@ -1450,15 +1557,17 @@
|
||||
|
||||
"@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=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@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" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
@@ -1476,23 +1585,19 @@
|
||||
|
||||
"@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
|
||||
|
||||
"cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"cheerio/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
|
||||
|
||||
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"jsdom/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
|
||||
|
||||
"nitro/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=="],
|
||||
"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=="],
|
||||
|
||||
@@ -1502,7 +1607,7 @@
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
@@ -1510,78 +1615,30 @@
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
"@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=="],
|
||||
|
||||
"vitest/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=="],
|
||||
"@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=="],
|
||||
|
||||
"nitro/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
"@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=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
|
||||
"@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=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
|
||||
"@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=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
|
||||
"@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=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
|
||||
"@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=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
|
||||
"@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=="],
|
||||
|
||||
"nitro/rolldown/@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=="],
|
||||
"@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=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
|
||||
"@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=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
|
||||
"@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=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
|
||||
"@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=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
|
||||
|
||||
"nitro/rolldown/@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=="],
|
||||
|
||||
"nitro/rolldown/@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=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
|
||||
|
||||
"vitest/vite/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=="],
|
||||
|
||||
"vitest/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
|
||||
|
||||
"vitest/vite/rolldown/@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=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
|
||||
|
||||
"vitest/vite/rolldown/@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=="],
|
||||
|
||||
"vitest/vite/rolldown/@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=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
|
||||
"@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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ import type * as publishers from "../publishers.js";
|
||||
import type * as rateLimits from "../rateLimits.js";
|
||||
import type * as search from "../search.js";
|
||||
import type * as seed from "../seed.js";
|
||||
import type * as seedDemo from "../seedDemo.js";
|
||||
import type * as seedSouls from "../seedSouls.js";
|
||||
import type * as skillStatEvents from "../skillStatEvents.js";
|
||||
import type * as skillTransfers from "../skillTransfers.js";
|
||||
@@ -216,6 +217,7 @@ declare const fullApi: ApiFromModules<{
|
||||
rateLimits: typeof rateLimits;
|
||||
search: typeof search;
|
||||
seed: typeof seed;
|
||||
seedDemo: typeof seedDemo;
|
||||
seedSouls: typeof seedSouls;
|
||||
skillStatEvents: typeof skillStatEvents;
|
||||
skillTransfers: typeof skillTransfers;
|
||||
|
||||
@@ -469,6 +469,11 @@ export const seedSkillMutation = internalMutation({
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.patch(userId, {
|
||||
publishedSkills: 1,
|
||||
totalStars: 0,
|
||||
totalDownloads: 0,
|
||||
});
|
||||
|
||||
const versionId = await ctx.db.insert("skillVersions", {
|
||||
skillId,
|
||||
|
||||
@@ -1197,6 +1197,154 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(json.version.security.virustotalUrl).toContain("virustotal.com/gui/file/");
|
||||
});
|
||||
|
||||
it("surfaces static-scan suspicious status in version security snapshot", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("slug" in args) {
|
||||
return {
|
||||
skill: { _id: "skills:1", slug: "demo", displayName: "Demo" },
|
||||
latestVersion: null,
|
||||
owner: { handle: "owner", displayName: "Owner", image: null },
|
||||
};
|
||||
}
|
||||
if ("skillId" in args && "version" in args) {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "c",
|
||||
changelogSource: "auto",
|
||||
sha256hash: "a".repeat(64),
|
||||
staticScan: {
|
||||
status: "suspicious",
|
||||
reasonCodes: ["suspicious.dangerous_exec"],
|
||||
summary: "Detected: suspicious.dangerous_exec",
|
||||
engineVersion: "v2.4.0",
|
||||
checkedAt: 555,
|
||||
},
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
checkedAt: 111,
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "completed",
|
||||
verdict: "benign",
|
||||
checkedAt: 222,
|
||||
},
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/demo/versions/1.0.0"),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.version.security.status).toBe("suspicious");
|
||||
expect(json.version.security.hasWarnings).toBe(true);
|
||||
expect(json.version.security.hasScanResult).toBe(true);
|
||||
expect(json.version.security.scanners.static.normalizedStatus).toBe("suspicious");
|
||||
expect(json.version.security.scanners.vt.normalizedStatus).toBe("clean");
|
||||
expect(json.version.security.scanners.llm.normalizedStatus).toBe("clean");
|
||||
});
|
||||
|
||||
it("lets static-scan malicious status dominate benign vt and llm results", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("slug" in args) {
|
||||
return {
|
||||
skill: { _id: "skills:1", slug: "demo", displayName: "Demo" },
|
||||
latestVersion: null,
|
||||
owner: { handle: "owner", displayName: "Owner", image: null },
|
||||
};
|
||||
}
|
||||
if ("skillId" in args && "version" in args) {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "c",
|
||||
changelogSource: "auto",
|
||||
sha256hash: "a".repeat(64),
|
||||
staticScan: {
|
||||
status: "malicious",
|
||||
reasonCodes: ["malicious.credential_harvest"],
|
||||
summary: "Detected: malicious.credential_harvest",
|
||||
engineVersion: "v2.4.0",
|
||||
checkedAt: 555,
|
||||
},
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
checkedAt: 111,
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "completed",
|
||||
verdict: "benign",
|
||||
checkedAt: 222,
|
||||
},
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/demo/versions/1.0.0"),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.version.security.status).toBe("malicious");
|
||||
expect(json.version.security.hasWarnings).toBe(true);
|
||||
expect(json.version.security.hasScanResult).toBe(true);
|
||||
expect(json.version.security.checkedAt).toBe(555);
|
||||
expect(json.version.security.scanners.static.normalizedStatus).toBe("malicious");
|
||||
});
|
||||
|
||||
it("treats a static scan by itself as a definitive scan result", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("slug" in args) {
|
||||
return {
|
||||
skill: { _id: "skills:1", slug: "demo", displayName: "Demo" },
|
||||
latestVersion: null,
|
||||
owner: { handle: "owner", displayName: "Owner", image: null },
|
||||
};
|
||||
}
|
||||
if ("skillId" in args && "version" in args) {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "c",
|
||||
changelogSource: "auto",
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
summary: "No issues found",
|
||||
engineVersion: "v2.4.0",
|
||||
checkedAt: 555,
|
||||
},
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/demo/versions/1.0.0"),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.version.security.status).toBe("clean");
|
||||
expect(json.version.security.hasWarnings).toBe(false);
|
||||
expect(json.version.security.hasScanResult).toBe(true);
|
||||
expect(json.version.security.virustotalUrl).toBeNull();
|
||||
expect(json.version.security.scanners.static.normalizedStatus).toBe("clean");
|
||||
expect(json.version.security.scanners.vt).toBeNull();
|
||||
expect(json.version.security.scanners.llm).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps hasWarnings true when llm dimensions include non-ok ratings", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("slug" in args) {
|
||||
@@ -2552,6 +2700,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;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CliPublishRequestSchema, parseArk } from "clawhub-schema";
|
||||
import { CliPublishRequestSchema, normalizeTextContentType, parseArk } from "clawhub-schema";
|
||||
import { internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
@@ -25,7 +25,9 @@ export function safeTextFileResponse(params: {
|
||||
size: number;
|
||||
headers?: HeadersInit;
|
||||
}) {
|
||||
const isSvg = isSvgLike(params.contentType, params.path);
|
||||
const contentType =
|
||||
normalizeTextContentType(params.path, params.contentType) ?? params.contentType;
|
||||
const isSvg = isSvgLike(contentType, params.path);
|
||||
|
||||
// For any text response that a browser might try to render, lock it down.
|
||||
// In particular, this prevents SVG <foreignObject> script execution from reading
|
||||
@@ -33,8 +35,8 @@ export function safeTextFileResponse(params: {
|
||||
const headers = mergeHeaders(
|
||||
params.headers,
|
||||
{
|
||||
"Content-Type": params.contentType
|
||||
? `${params.contentType}; charset=utf-8`
|
||||
"Content-Type": contentType
|
||||
? `${contentType}; charset=utf-8`
|
||||
: "text/plain; charset=utf-8",
|
||||
"Cache-Control": "private, max-age=60",
|
||||
ETag: params.sha256,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api, internal } from "../_generated/api";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
|
||||
@@ -70,6 +71,11 @@ type PublicSkillVersionParsed = {
|
||||
clawdis?: { os?: string[]; nix?: { plugin?: boolean; systems?: string[] } };
|
||||
};
|
||||
|
||||
type PublicSkillVersionStaticScan = Pick<
|
||||
NonNullable<Doc<"skillVersions">["staticScan"]>,
|
||||
"status" | "reasonCodes" | "summary" | "engineVersion" | "checkedAt"
|
||||
>;
|
||||
|
||||
type PublicSkillVersionResponse = {
|
||||
_id: Id<"skillVersions">;
|
||||
version: string;
|
||||
@@ -82,6 +88,7 @@ type PublicSkillVersionResponse = {
|
||||
sha256hash?: string;
|
||||
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
|
||||
llmAnalysis?: Doc<"skillVersions">["llmAnalysis"];
|
||||
staticScan?: PublicSkillVersionStaticScan;
|
||||
capabilityTags?: string[];
|
||||
};
|
||||
|
||||
@@ -193,6 +200,14 @@ type SkillSecuritySnapshot = {
|
||||
virustotalUrl: string | null;
|
||||
capabilityTags: string[];
|
||||
scanners: {
|
||||
static: {
|
||||
status: string;
|
||||
normalizedStatus: NormalizedSecurityStatus;
|
||||
reasonCodes: string[];
|
||||
summary: string | null;
|
||||
engineVersion: string | null;
|
||||
checkedAt: number | null;
|
||||
} | null;
|
||||
vt: {
|
||||
status: string;
|
||||
verdict: string | null;
|
||||
@@ -276,30 +291,35 @@ function hasLlmDimensionWarnings(
|
||||
function buildSkillSecuritySnapshot(
|
||||
version: Pick<
|
||||
PublicSkillVersionResponse,
|
||||
"sha256hash" | "vtAnalysis" | "llmAnalysis" | "capabilityTags"
|
||||
"sha256hash" | "vtAnalysis" | "llmAnalysis" | "staticScan" | "capabilityTags"
|
||||
>,
|
||||
): SkillSecuritySnapshot | null {
|
||||
const capabilityTags = version.capabilityTags ?? [];
|
||||
const sha256hash = version.sha256hash ?? null;
|
||||
const vt = version.vtAnalysis;
|
||||
const llm = version.llmAnalysis;
|
||||
const staticScan = version.staticScan;
|
||||
|
||||
if (!sha256hash && !vt && !llm && capabilityTags.length === 0) return null;
|
||||
if (!sha256hash && !vt && !llm && !staticScan && capabilityTags.length === 0) return null;
|
||||
|
||||
const staticStatus = staticScan ? normalizeSecurityStatus(staticScan.status) : null;
|
||||
const vtStatus = vt ? normalizeSecurityStatus(vt.verdict ?? vt.status) : null;
|
||||
const llmStatus = llm ? normalizeSecurityStatus(llm.verdict ?? llm.status) : null;
|
||||
|
||||
const statuses: NormalizedSecurityStatus[] = [];
|
||||
if (staticStatus) statuses.push(staticStatus);
|
||||
if (vtStatus) statuses.push(vtStatus);
|
||||
if (llmStatus) statuses.push(llmStatus);
|
||||
if (statuses.length === 0 && sha256hash) statuses.push("pending");
|
||||
const status = mergeSecurityStatuses(statuses);
|
||||
const hasScanResult =
|
||||
isDefinitiveSecurityStatus(vtStatus) || isDefinitiveSecurityStatus(llmStatus);
|
||||
isDefinitiveSecurityStatus(staticStatus) ||
|
||||
isDefinitiveSecurityStatus(vtStatus) ||
|
||||
isDefinitiveSecurityStatus(llmStatus);
|
||||
const hasWarnings =
|
||||
status === "suspicious" || status === "malicious" || hasLlmDimensionWarnings(llm?.dimensions);
|
||||
|
||||
const checkedAtCandidates = [vt?.checkedAt, llm?.checkedAt].filter(
|
||||
const checkedAtCandidates = [staticScan?.checkedAt, vt?.checkedAt, llm?.checkedAt].filter(
|
||||
(value): value is number => typeof value === "number",
|
||||
);
|
||||
const checkedAt = checkedAtCandidates.length > 0 ? Math.max(...checkedAtCandidates) : null;
|
||||
@@ -314,6 +334,16 @@ function buildSkillSecuritySnapshot(
|
||||
virustotalUrl: sha256hash ? `https://www.virustotal.com/gui/file/${sha256hash}` : null,
|
||||
capabilityTags,
|
||||
scanners: {
|
||||
static: staticScan
|
||||
? {
|
||||
status: staticScan.status,
|
||||
normalizedStatus: staticStatus ?? "pending",
|
||||
reasonCodes: staticScan.reasonCodes ?? [],
|
||||
summary: staticScan.summary ?? null,
|
||||
engineVersion: staticScan.engineVersion ?? null,
|
||||
checkedAt: staticScan.checkedAt ?? null,
|
||||
}
|
||||
: null,
|
||||
vt: vt
|
||||
? {
|
||||
status: vt.status,
|
||||
@@ -734,7 +764,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
contentType: file.contentType ?? null,
|
||||
contentType: normalizeTextContentType(file.path, file.contentType) ?? null,
|
||||
})),
|
||||
security: security ?? undefined,
|
||||
},
|
||||
@@ -969,26 +999,11 @@ async function handleTransferRequest(
|
||||
if (!toUserHandleRaw) return text("toUserHandle required", 400, headers);
|
||||
const message = typeof parsed.payload.message === "string" ? parsed.payload.message : undefined;
|
||||
|
||||
// Resolve optional publisher handle to a publisher ID
|
||||
const toPublisherHandleRaw =
|
||||
typeof parsed.payload.toPublisherHandle === "string"
|
||||
? parsed.payload.toPublisherHandle.trim()
|
||||
: "";
|
||||
let toPublisherId: Id<"publishers"> | undefined;
|
||||
if (toPublisherHandleRaw) {
|
||||
const publisher = await ctx.runQuery(internal.publishers.getByHandleInternal, {
|
||||
handle: toPublisherHandleRaw,
|
||||
});
|
||||
if (!publisher) return text("Publisher not found", 404, headers);
|
||||
toPublisherId = publisher._id;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.runMutation(internal.skillTransfers.requestTransferInternal, {
|
||||
actorUserId: transferContext.userId,
|
||||
skillId: transferContext.skill._id,
|
||||
toUserHandle: toUserHandleRaw,
|
||||
toPublisherId,
|
||||
message,
|
||||
});
|
||||
return json(result, 200, headers);
|
||||
@@ -1007,68 +1022,30 @@ async function handleTransferDecision(
|
||||
const transferContext = await resolveTransferContext(ctx, request, slug, headers);
|
||||
if (!transferContext.ok) return transferContext.response;
|
||||
|
||||
let pendingTransfer;
|
||||
if (decision === "cancel") {
|
||||
pendingTransfer = await ctx.runQuery(
|
||||
internal.skillTransfers.getPendingTransferBySkillAndFromUserInternal,
|
||||
{ skillId: transferContext.skill._id, fromUserId: transferContext.userId },
|
||||
);
|
||||
if (!pendingTransfer) {
|
||||
// Fallback: allow org admins to cancel transfers initiated by other admins
|
||||
pendingTransfer = await ctx.runQuery(
|
||||
internal.skillTransfers.getPendingTransferBySkillInternal,
|
||||
{ skillId: transferContext.skill._id },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Try user-specific lookup first, then fall back to any pending transfer
|
||||
// for the skill (allows org admins other than toUserId to accept/reject)
|
||||
pendingTransfer = await ctx.runQuery(
|
||||
internal.skillTransfers.getPendingTransferBySkillAndUserInternal,
|
||||
{ skillId: transferContext.skill._id, toUserId: transferContext.userId },
|
||||
);
|
||||
if (!pendingTransfer) {
|
||||
pendingTransfer = await ctx.runQuery(
|
||||
internal.skillTransfers.getPendingTransferBySkillInternal,
|
||||
{ skillId: transferContext.skill._id },
|
||||
);
|
||||
}
|
||||
}
|
||||
const pendingTransfer =
|
||||
decision === "cancel"
|
||||
? await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndFromUserInternal, {
|
||||
skillId: transferContext.skill._id,
|
||||
fromUserId: transferContext.userId,
|
||||
})
|
||||
: await ctx.runQuery(internal.skillTransfers.getPendingTransferBySkillAndUserInternal, {
|
||||
skillId: transferContext.skill._id,
|
||||
toUserId: transferContext.userId,
|
||||
});
|
||||
if (!pendingTransfer) return text("No pending transfer found", 404, headers);
|
||||
|
||||
try {
|
||||
// For accept, resolve optional publisher handle to forward publisherId
|
||||
let publisherId: Id<"publishers"> | undefined;
|
||||
if (decision === "accept") {
|
||||
const parsed = await parseJsonPayload(request, headers);
|
||||
if (parsed.ok) {
|
||||
const publisherHandleRaw =
|
||||
typeof parsed.payload.publisherHandle === "string"
|
||||
? parsed.payload.publisherHandle.trim()
|
||||
: "";
|
||||
if (publisherHandleRaw) {
|
||||
const publisher = await ctx.runQuery(internal.publishers.getByHandleInternal, {
|
||||
handle: publisherHandleRaw,
|
||||
});
|
||||
if (!publisher) return text("Publisher not found", 404, headers);
|
||||
publisherId = publisher._id;
|
||||
}
|
||||
}
|
||||
}
|
||||
const mutation =
|
||||
decision === "accept"
|
||||
? internal.skillTransfers.acceptTransferInternal
|
||||
: decision === "reject"
|
||||
? internal.skillTransfers.rejectTransferInternal
|
||||
: internal.skillTransfers.cancelTransferInternal;
|
||||
|
||||
const baseArgs = {
|
||||
try {
|
||||
const result = await ctx.runMutation(mutation, {
|
||||
actorUserId: transferContext.userId,
|
||||
transferId: pendingTransfer._id,
|
||||
};
|
||||
|
||||
const result = await (decision === "accept"
|
||||
? ctx.runMutation(internal.skillTransfers.acceptTransferInternal, {
|
||||
...baseArgs,
|
||||
publisherId,
|
||||
})
|
||||
: decision === "reject"
|
||||
? ctx.runMutation(internal.skillTransfers.rejectTransferInternal, baseArgs)
|
||||
: ctx.runMutation(internal.skillTransfers.cancelTransferInternal, baseArgs));
|
||||
});
|
||||
return json(result, 200, headers);
|
||||
} catch (error) {
|
||||
return transferErrorToResponse(error, headers);
|
||||
|
||||
@@ -16,14 +16,9 @@ export async function transfersGetRouterV1Handler(ctx: ActionCtx, request: Reque
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const skillTransfers =
|
||||
const transfers =
|
||||
direction === "incoming"
|
||||
? await ctx.runQuery(internal.skillTransfers.listIncomingInternal, { userId: auth.userId })
|
||||
: await ctx.runQuery(internal.skillTransfers.listOutgoingInternal, { userId: auth.userId });
|
||||
|
||||
const transfers = skillTransfers.sort(
|
||||
(a, b) => (b.requestedAt ?? 0) - (a.requestedAt ?? 0),
|
||||
);
|
||||
|
||||
return json({ transfers }, 200, rate.headers);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
extractWorkflowFilenameFromWorkflowRef,
|
||||
verifyGitHubActionsTrustedPublishJwt,
|
||||
|
||||
@@ -26,6 +26,7 @@ describe("deriveSkillCapabilityTags", () => {
|
||||
"requires-wallet",
|
||||
"can-make-purchases",
|
||||
"can-sign-transactions",
|
||||
"requires-sensitive-credentials",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -39,7 +40,24 @@ describe("deriveSkillCapabilityTags", () => {
|
||||
fileContents: [],
|
||||
});
|
||||
|
||||
expect(tags).toEqual(["requires-oauth-token", "posts-externally"]);
|
||||
expect(tags).toEqual([
|
||||
"requires-oauth-token",
|
||||
"requires-sensitive-credentials",
|
||||
"posts-externally",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects non-oauth API key skills that still need sensitive credentials", () => {
|
||||
const tags = deriveSkillCapabilityTags({
|
||||
slug: "minimax-usage",
|
||||
displayName: "Minimax Usage",
|
||||
frontmatter: {},
|
||||
readmeText:
|
||||
"Create a .env file with MINIMAX_CODING_API_KEY and MINIMAX_GROUP_ID, then send an authorization: Bearer header to the MiniMax endpoint.",
|
||||
fileContents: [],
|
||||
});
|
||||
|
||||
expect(tags).toEqual(["requires-sensitive-credentials"]);
|
||||
});
|
||||
|
||||
it("does not treat generic broadcast wording as a crypto transaction signal", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ export const SKILL_CAPABILITY_TAGS = [
|
||||
"can-make-purchases",
|
||||
"can-sign-transactions",
|
||||
"requires-oauth-token",
|
||||
"requires-sensitive-credentials",
|
||||
"posts-externally",
|
||||
] as const;
|
||||
|
||||
@@ -96,6 +97,19 @@ const OAUTH_PATTERNS = [
|
||||
/\btweet\.write\b/,
|
||||
] satisfies RegExp[];
|
||||
|
||||
const SENSITIVE_CREDENTIAL_PATTERNS = [
|
||||
/api[_ -]?key\b/,
|
||||
/\baccess token\b/,
|
||||
/\brefresh token\b/,
|
||||
/\bbearer token\b/,
|
||||
/\bsession (?:cookie|cookies)\b/,
|
||||
/\bauth(?:entication)? (?:cookie|cookies)\b/,
|
||||
/\bprivate[_ -]?key\b/,
|
||||
/\bmnemonic\b/,
|
||||
/\bseed phrase\b/,
|
||||
/\bsigner\b/,
|
||||
] satisfies RegExp[];
|
||||
|
||||
const EXTERNAL_POST_PATTERNS = [
|
||||
/\bpost(?: a| this)? tweet\b/,
|
||||
/\breply to (?:this )?tweet\b/,
|
||||
@@ -129,6 +143,7 @@ export function deriveSkillCapabilityTags(params: {
|
||||
const canMakePurchases = matches(text, PURCHASE_PATTERNS);
|
||||
const canSignTransactions = matches(text, TRANSACTION_PATTERNS);
|
||||
const requiresOauthToken = matches(text, OAUTH_PATTERNS);
|
||||
const requiresSensitiveCredentials = matches(text, SENSITIVE_CREDENTIAL_PATTERNS);
|
||||
const postsExternally = matches(text, EXTERNAL_POST_PATTERNS);
|
||||
|
||||
if (isCrypto) tags.add("crypto");
|
||||
@@ -136,6 +151,7 @@ export function deriveSkillCapabilityTags(params: {
|
||||
if (canMakePurchases) tags.add("can-make-purchases");
|
||||
if (canSignTransactions) tags.add("can-sign-transactions");
|
||||
if (requiresOauthToken) tags.add("requires-oauth-token");
|
||||
if (requiresSensitiveCredentials) tags.add("requires-sensitive-credentials");
|
||||
if (postsExternally) tags.add("posts-externally");
|
||||
|
||||
if (canSignTransactions || canMakePurchases) {
|
||||
@@ -144,6 +160,9 @@ export function deriveSkillCapabilityTags(params: {
|
||||
if (canSignTransactions) {
|
||||
tags.add("requires-wallet");
|
||||
}
|
||||
if (requiresWallet || canSignTransactions || requiresOauthToken) {
|
||||
tags.add("requires-sensitive-credentials");
|
||||
}
|
||||
|
||||
return SKILL_CAPABILITY_TAGS.filter((tag) => tags.has(tag));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import semver from "semver";
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
@@ -113,6 +114,7 @@ export async function publishVersionForUser(
|
||||
const sanitizedFiles = args.files.map((file) => ({
|
||||
...file,
|
||||
path: sanitizePath(file.path),
|
||||
contentType: normalizeTextContentType(file.path, file.contentType),
|
||||
}));
|
||||
if (sanitizedFiles.some((file) => !file.path)) {
|
||||
throw new ConvexError("Invalid file paths");
|
||||
|
||||
@@ -10,18 +10,34 @@ type SkillStatDeltas = {
|
||||
installsAllTime?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the canonical value of a migrated stat field from a skill document.
|
||||
*
|
||||
* Top-level fields (`statsDownloads`, etc.) are the source of truth — they are
|
||||
* indexable and kept up-to-date by the event pipeline. The nested `stats.*`
|
||||
* fields are only used as a fallback for pre-migration documents where the
|
||||
* top-level field is still `undefined`.
|
||||
*
|
||||
* All code that reads a migrated stat value should go through this function
|
||||
* rather than accessing `skill.stats.*` directly.
|
||||
*/
|
||||
export function readCanonicalStat(
|
||||
skill: Doc<"skills">,
|
||||
field: "downloads" | "stars" | "installsCurrent" | "installsAllTime",
|
||||
): number {
|
||||
const topLevelKey = `stats${field[0].toUpperCase()}${field.slice(1)}` as
|
||||
| "statsDownloads"
|
||||
| "statsStars"
|
||||
| "statsInstallsCurrent"
|
||||
| "statsInstallsAllTime";
|
||||
return typeof skill[topLevelKey] === "number" ? skill[topLevelKey]! : (skill.stats[field] ?? 0);
|
||||
}
|
||||
|
||||
export function applySkillStatDeltas(skill: Doc<"skills">, deltas: SkillStatDeltas) {
|
||||
const currentDownloads =
|
||||
typeof skill.statsDownloads === "number" ? skill.statsDownloads : skill.stats.downloads;
|
||||
const currentStars = typeof skill.statsStars === "number" ? skill.statsStars : skill.stats.stars;
|
||||
const currentInstallsCurrent =
|
||||
typeof skill.statsInstallsCurrent === "number"
|
||||
? skill.statsInstallsCurrent
|
||||
: (skill.stats.installsCurrent ?? 0);
|
||||
const currentInstallsAllTime =
|
||||
typeof skill.statsInstallsAllTime === "number"
|
||||
? skill.statsInstallsAllTime
|
||||
: (skill.stats.installsAllTime ?? 0);
|
||||
const currentDownloads = readCanonicalStat(skill, "downloads");
|
||||
const currentStars = readCanonicalStat(skill, "stars");
|
||||
const currentInstallsCurrent = readCanonicalStat(skill, "installsCurrent");
|
||||
const currentInstallsAllTime = readCanonicalStat(skill, "installsAllTime");
|
||||
|
||||
const currentComments = skill.stats.comments;
|
||||
const nextDownloads = Math.max(0, currentDownloads + (deltas.downloads ?? 0));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import semver from "semver";
|
||||
import { internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
@@ -101,7 +102,11 @@ export async function publishSoulVersionForUser(
|
||||
const sanitizedFiles = args.files.map((file) => {
|
||||
const path = sanitizePath(file.path);
|
||||
if (!path) throw new ConvexError("Invalid file paths");
|
||||
return { ...file, path };
|
||||
return {
|
||||
...file,
|
||||
path,
|
||||
contentType: normalizeTextContentType(file.path, file.contentType),
|
||||
};
|
||||
});
|
||||
const publishFiles = sanitizedFiles.filter((file) => !isMacJunkPath(file.path));
|
||||
if (publishFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
TRANSFER_EXPIRY_MS,
|
||||
isTransferExpired,
|
||||
normalizeTransferHandle,
|
||||
validateTransferOwnership,
|
||||
validateTransferAcceptPermission,
|
||||
} from "./transfers";
|
||||
|
||||
describe("transfers", () => {
|
||||
it("TRANSFER_EXPIRY_MS is 7 days", () => {
|
||||
expect(TRANSFER_EXPIRY_MS).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
describe("isTransferExpired", () => {
|
||||
it("returns true when expiresAt is in the past", () => {
|
||||
expect(isTransferExpired({ expiresAt: 1000 }, 2000)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when expiresAt is in the future", () => {
|
||||
expect(isTransferExpired({ expiresAt: 3000 }, 2000)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when expiresAt equals now", () => {
|
||||
expect(isTransferExpired({ expiresAt: 2000 }, 2000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeTransferHandle", () => {
|
||||
it("trims whitespace", () => {
|
||||
expect(normalizeTransferHandle(" alice ")).toBe("alice");
|
||||
});
|
||||
|
||||
it("strips leading @ and lowercases", () => {
|
||||
expect(normalizeTransferHandle("@Alice")).toBe("alice");
|
||||
});
|
||||
|
||||
it("strips multiple leading @ signs", () => {
|
||||
expect(normalizeTransferHandle("@@Bob")).toBe("bob");
|
||||
});
|
||||
|
||||
it("lowercases without @", () => {
|
||||
expect(normalizeTransferHandle("Charlie")).toBe("charlie");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTransferOwnership", () => {
|
||||
it("passes for direct owner (personal, no publisher)", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:1" as never,
|
||||
ownerPublisherId: undefined,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes for org admin", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "admin",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:99" as never,
|
||||
ownerPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes for org owner role", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "owner",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:99" as never,
|
||||
ownerPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects non-admin org member (publisher role)", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "publisher",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:99" as never,
|
||||
ownerPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("rejects non-owner non-member", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
ownerUserId: "users:99" as never,
|
||||
ownerPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("rejects personal item when actor is not the owner", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferOwnership(ctx as never, {
|
||||
actorUserId: "users:2" as never,
|
||||
ownerUserId: "users:1" as never,
|
||||
ownerPublisherId: undefined,
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateTransferAcceptPermission", () => {
|
||||
it("passes for personal target when actor is the target user", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferAcceptPermission(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
toUserId: "users:1" as never,
|
||||
toPublisherId: undefined,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects personal target when actor is not the target user", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
query: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferAcceptPermission(ctx as never, {
|
||||
actorUserId: "users:2" as never,
|
||||
toUserId: "users:1" as never,
|
||||
toPublisherId: undefined,
|
||||
}),
|
||||
).rejects.toThrow("No pending transfer found");
|
||||
});
|
||||
|
||||
it("passes for org target when actor is admin", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async () => ({
|
||||
_id: "publishers:org1",
|
||||
kind: "org",
|
||||
handle: "myorg",
|
||||
})),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "admin",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferAcceptPermission(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
toUserId: "users:99" as never,
|
||||
toPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects org target when actor is not admin/owner", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async () => ({
|
||||
_id: "publishers:org1",
|
||||
kind: "org",
|
||||
handle: "myorg",
|
||||
})),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:1",
|
||||
role: "publisher",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateTransferAcceptPermission(ctx as never, {
|
||||
actorUserId: "users:1" as never,
|
||||
toUserId: "users:99" as never,
|
||||
toPublisherId: "publishers:org1" as never,
|
||||
}),
|
||||
).rejects.toThrow("No pending transfer found");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { QueryCtx, MutationCtx } from "../_generated/server";
|
||||
import { getPublisherMembership, isPublisherActive, isPublisherRoleAllowed } from "./publishers";
|
||||
|
||||
type DbCtx = Pick<QueryCtx | MutationCtx, "db">;
|
||||
|
||||
/** 7 days in milliseconds */
|
||||
export const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Returns true if the transfer has expired (expiresAt is strictly less than now). */
|
||||
export function isTransferExpired(transfer: { expiresAt: number }, now: number): boolean {
|
||||
return transfer.expiresAt < now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims whitespace, strips leading `@` characters, and lowercases:
|
||||
* `"@Alice"` -> `"alice"`, `"@@Bob"` -> `"bob"`
|
||||
*/
|
||||
export function normalizeTransferHandle(value: string): string {
|
||||
return value.trim().replace(/^@+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that `actorUserId` has permission to initiate a transfer.
|
||||
*
|
||||
* - If the item is personally owned (`ownerPublisherId` is null/undefined):
|
||||
* actor must be the `ownerUserId`.
|
||||
* - If the item is org-owned: actor must be an admin or owner in that org's
|
||||
* publisherMembers.
|
||||
*
|
||||
* @throws {Error} "Forbidden" on failure
|
||||
*/
|
||||
export async function validateTransferOwnership(
|
||||
ctx: DbCtx,
|
||||
params: {
|
||||
actorUserId: Id<"users">;
|
||||
ownerUserId: Id<"users">;
|
||||
ownerPublisherId?: Id<"publishers"> | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!params.ownerPublisherId) {
|
||||
// Personally owned — actor must be the owner
|
||||
if (params.actorUserId !== params.ownerUserId) {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Org-owned — actor must be admin or owner in the org
|
||||
const membership = await getPublisherMembership(ctx, params.ownerPublisherId, params.actorUserId);
|
||||
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that `actorUserId` can accept a transfer.
|
||||
*
|
||||
* - If `toPublisherId` is null (personal target): actor must be `toUserId`.
|
||||
* - If `toPublisherId` is set (org target): actor must be admin/owner of that org.
|
||||
*
|
||||
* @throws {Error} "No pending transfer found" on failure
|
||||
*/
|
||||
export async function validateTransferAcceptPermission(
|
||||
ctx: DbCtx,
|
||||
params: {
|
||||
actorUserId: Id<"users">;
|
||||
toUserId?: Id<"users"> | null;
|
||||
toPublisherId?: Id<"publishers"> | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!params.toPublisherId) {
|
||||
// Personal target — actor must be the target user
|
||||
if (params.actorUserId !== params.toUserId) {
|
||||
throw new Error("No pending transfer found");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Org target — publisher must be active and actor must be admin or owner
|
||||
const db = (ctx as { db: { get: (id: Id<"publishers">) => Promise<Doc<"publishers"> | null> } })
|
||||
.db;
|
||||
const publisher = await db.get(params.toPublisherId);
|
||||
if (!isPublisherActive(publisher)) {
|
||||
throw new Error("Publisher not found");
|
||||
}
|
||||
const membership = await getPublisherMembership(ctx, params.toPublisherId, params.actorUserId);
|
||||
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
|
||||
throw new Error("No pending transfer found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { MutationCtx } from "../_generated/server";
|
||||
|
||||
function getSkillContribution(skill: Doc<"skills">) {
|
||||
if (skill.softDeletedAt) {
|
||||
return { publishedSkills: 0, totalStars: 0, totalDownloads: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
publishedSkills: 1,
|
||||
totalStars: skill.stats?.stars ?? 0,
|
||||
totalDownloads: skill.stats?.downloads ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function patchUserStats(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
userId: Id<"users">,
|
||||
delta: { publishedSkills: number; totalStars: number; totalDownloads: number },
|
||||
) {
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) return;
|
||||
|
||||
await ctx.db.patch(userId, {
|
||||
publishedSkills: Math.max(0, (user.publishedSkills ?? 0) + delta.publishedSkills),
|
||||
totalStars: Math.max(0, (user.totalStars ?? 0) + delta.totalStars),
|
||||
totalDownloads: Math.max(0, (user.totalDownloads ?? 0) + delta.totalDownloads),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adjustUserSkillStatsForSkillChange(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
previousSkill: Doc<"skills"> | null | undefined,
|
||||
nextSkill: Doc<"skills"> | null | undefined,
|
||||
) {
|
||||
if (!previousSkill && !nextSkill) return;
|
||||
|
||||
const prevOwnerId = previousSkill?.ownerUserId ?? null;
|
||||
const nextOwnerId = nextSkill?.ownerUserId ?? null;
|
||||
const prevContribution = previousSkill ? getSkillContribution(previousSkill) : null;
|
||||
const nextContribution = nextSkill ? getSkillContribution(nextSkill) : null;
|
||||
|
||||
if (prevOwnerId && prevOwnerId === nextOwnerId) {
|
||||
await patchUserStats(ctx, prevOwnerId, {
|
||||
publishedSkills: (nextContribution?.publishedSkills ?? 0) - (prevContribution?.publishedSkills ?? 0),
|
||||
totalStars: (nextContribution?.totalStars ?? 0) - (prevContribution?.totalStars ?? 0),
|
||||
totalDownloads: (nextContribution?.totalDownloads ?? 0) - (prevContribution?.totalDownloads ?? 0),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (prevOwnerId) {
|
||||
await patchUserStats(ctx, prevOwnerId, {
|
||||
publishedSkills: -(prevContribution?.publishedSkills ?? 0),
|
||||
totalStars: -(prevContribution?.totalStars ?? 0),
|
||||
totalDownloads: -(prevContribution?.totalDownloads ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
if (nextOwnerId) {
|
||||
await patchUserStats(ctx, nextOwnerId, {
|
||||
publishedSkills: nextContribution?.publishedSkills ?? 0,
|
||||
totalStars: nextContribution?.totalStars ?? 0,
|
||||
totalDownloads: nextContribution?.totalDownloads ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ vi.mock("./_generated/api", () => ({
|
||||
getSkillBackfillPageInternal: Symbol("getSkillBackfillPageInternal"),
|
||||
applySkillBackfillPatchInternal: Symbol("applySkillBackfillPatchInternal"),
|
||||
backfillSkillSummariesInternal: Symbol("backfillSkillSummariesInternal"),
|
||||
getUserStatsBackfillPageInternal: Symbol("getUserStatsBackfillPageInternal"),
|
||||
getUserOwnedSkillsBackfillPageInternal: Symbol("getUserOwnedSkillsBackfillPageInternal"),
|
||||
applyUserStatsBackfillPatchInternal: Symbol("applyUserStatsBackfillPatchInternal"),
|
||||
backfillUserStatsInternal: Symbol("backfillUserStatsInternal"),
|
||||
getSkillFingerprintBackfillPageInternal: Symbol("getSkillFingerprintBackfillPageInternal"),
|
||||
applySkillFingerprintBackfillPatchInternal: Symbol(
|
||||
"applySkillFingerprintBackfillPatchInternal",
|
||||
@@ -36,6 +40,7 @@ const {
|
||||
backfillLatestVersionSummaryInternal,
|
||||
backfillSkillFingerprintsInternalHandler,
|
||||
backfillSkillSummariesInternalHandler,
|
||||
backfillUserStatsInternalHandler,
|
||||
cleanupEmptySkillsInternalHandler,
|
||||
nominateEmptySkillSpammersInternalHandler,
|
||||
upsertSkillBadgeRecordInternal,
|
||||
@@ -259,6 +264,63 @@ describe("maintenance backfill", () => {
|
||||
});
|
||||
expect(runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("backfills denormalized user hover stats from indexed owner pages", async () => {
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ _id: "users:1" }],
|
||||
cursor: null,
|
||||
isDone: true,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{ stats: { stars: 4, downloads: 30 }, softDeletedAt: undefined },
|
||||
{ stats: { stars: 2, downloads: 10 }, softDeletedAt: 123 },
|
||||
{ stats: { stars: 1, downloads: 5 }, softDeletedAt: undefined },
|
||||
],
|
||||
cursor: null,
|
||||
isDone: true,
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
const result = await backfillUserStatsInternalHandler(
|
||||
{ runQuery, runMutation } as never,
|
||||
{ batchSize: 10, skillBatchSize: 50, maxBatches: 1 },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
stats: {
|
||||
usersScanned: 1,
|
||||
usersPatched: 1,
|
||||
},
|
||||
isDone: true,
|
||||
cursor: null,
|
||||
});
|
||||
expect(runQuery).toHaveBeenNthCalledWith(1, internal.maintenance.getUserStatsBackfillPageInternal, {
|
||||
cursor: undefined,
|
||||
batchSize: 10,
|
||||
});
|
||||
expect(runQuery).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
internal.maintenance.getUserOwnedSkillsBackfillPageInternal,
|
||||
{
|
||||
ownerUserId: "users:1",
|
||||
cursor: undefined,
|
||||
batchSize: 50,
|
||||
},
|
||||
);
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
internal.maintenance.applyUserStatsBackfillPatchInternal,
|
||||
{
|
||||
userId: "users:1",
|
||||
publishedSkills: 2,
|
||||
totalStars: 5,
|
||||
totalDownloads: 35,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maintenance badge denormalization", () => {
|
||||
|
||||
@@ -36,6 +36,11 @@ type BackfillStats = {
|
||||
missingStorageBlob: number;
|
||||
};
|
||||
|
||||
type UserStatsBackfillStats = {
|
||||
usersScanned: number;
|
||||
usersPatched: number;
|
||||
};
|
||||
|
||||
type BackfillPageItem =
|
||||
| {
|
||||
kind: "ok";
|
||||
@@ -57,6 +62,18 @@ type BackfillPageResult = {
|
||||
isDone: boolean;
|
||||
};
|
||||
|
||||
type UserStatsBackfillPageResult = {
|
||||
items: Array<Pick<Doc<"users">, "_id">>;
|
||||
cursor: string | null;
|
||||
isDone: boolean;
|
||||
};
|
||||
|
||||
type UserOwnedSkillsBackfillPageResult = {
|
||||
items: Array<Pick<Doc<"skills">, "stats" | "softDeletedAt">>;
|
||||
cursor: string | null;
|
||||
isDone: boolean;
|
||||
};
|
||||
|
||||
export const getSkillBackfillPageInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
@@ -136,6 +153,65 @@ export const applySkillBackfillPatchInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const getUserStatsBackfillPageInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<UserStatsBackfillPageResult> => {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query("users")
|
||||
.order("asc")
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
return {
|
||||
items: page.map((user) => ({ _id: user._id })),
|
||||
cursor: continueCursor,
|
||||
isDone,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getUserOwnedSkillsBackfillPageInternal = internalQuery({
|
||||
args: {
|
||||
ownerUserId: v.id("users"),
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<UserOwnedSkillsBackfillPageResult> => {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner", (q) => q.eq("ownerUserId", args.ownerUserId))
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
return {
|
||||
items: page.map((skill) => ({
|
||||
stats: skill.stats,
|
||||
softDeletedAt: skill.softDeletedAt,
|
||||
})),
|
||||
cursor: continueCursor,
|
||||
isDone,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const applyUserStatsBackfillPatchInternal = internalMutation({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
publishedSkills: v.number(),
|
||||
totalStars: v.number(),
|
||||
totalDownloads: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.userId, {
|
||||
publishedSkills: args.publishedSkills,
|
||||
totalStars: args.totalStars,
|
||||
totalDownloads: args.totalDownloads,
|
||||
});
|
||||
return { ok: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
export type BackfillActionArgs = {
|
||||
dryRun?: boolean;
|
||||
batchSize?: number;
|
||||
@@ -151,6 +227,20 @@ export type BackfillActionResult = {
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
export type UserStatsBackfillActionArgs = {
|
||||
batchSize?: number;
|
||||
skillBatchSize?: number;
|
||||
maxBatches?: number;
|
||||
cursor?: string;
|
||||
};
|
||||
|
||||
export type UserStatsBackfillActionResult = {
|
||||
ok: true;
|
||||
stats: UserStatsBackfillStats;
|
||||
isDone: boolean;
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
export async function backfillSkillSummariesInternalHandler(
|
||||
ctx: ActionCtx,
|
||||
args: BackfillActionArgs,
|
||||
@@ -246,6 +336,73 @@ export async function backfillSkillSummariesInternalHandler(
|
||||
return { ok: true as const, stats: totals, isDone, cursor };
|
||||
}
|
||||
|
||||
export async function backfillUserStatsInternalHandler(
|
||||
ctx: ActionCtx,
|
||||
args: UserStatsBackfillActionArgs,
|
||||
): Promise<UserStatsBackfillActionResult> {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
|
||||
const skillBatchSize = clampInt(args.skillBatchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
|
||||
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES);
|
||||
const totals: UserStatsBackfillStats = {
|
||||
usersScanned: 0,
|
||||
usersPatched: 0,
|
||||
};
|
||||
|
||||
let cursor: string | null = args.cursor ?? null;
|
||||
let isDone = false;
|
||||
|
||||
for (let i = 0; i < maxBatches; i++) {
|
||||
const page = (await ctx.runQuery(internal.maintenance.getUserStatsBackfillPageInternal, {
|
||||
cursor: cursor ?? undefined,
|
||||
batchSize,
|
||||
})) as UserStatsBackfillPageResult;
|
||||
|
||||
cursor = page.cursor;
|
||||
isDone = page.isDone;
|
||||
|
||||
for (const user of page.items) {
|
||||
totals.usersScanned++;
|
||||
let ownedSkillsCursor: string | null = null;
|
||||
let userPublishedSkills = 0;
|
||||
let userTotalStars = 0;
|
||||
let userTotalDownloads = 0;
|
||||
|
||||
while (true) {
|
||||
const skillPage = (await ctx.runQuery(
|
||||
internal.maintenance.getUserOwnedSkillsBackfillPageInternal,
|
||||
{
|
||||
ownerUserId: user._id,
|
||||
cursor: ownedSkillsCursor ?? undefined,
|
||||
batchSize: skillBatchSize,
|
||||
},
|
||||
)) as UserOwnedSkillsBackfillPageResult;
|
||||
|
||||
for (const skill of skillPage.items) {
|
||||
if (skill.softDeletedAt) continue;
|
||||
userPublishedSkills += 1;
|
||||
userTotalStars += skill.stats?.stars ?? 0;
|
||||
userTotalDownloads += skill.stats?.downloads ?? 0;
|
||||
}
|
||||
|
||||
if (skillPage.isDone) break;
|
||||
ownedSkillsCursor = skillPage.cursor;
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.maintenance.applyUserStatsBackfillPatchInternal, {
|
||||
userId: user._id,
|
||||
publishedSkills: userPublishedSkills,
|
||||
totalStars: userTotalStars,
|
||||
totalDownloads: userTotalDownloads,
|
||||
});
|
||||
totals.usersPatched++;
|
||||
}
|
||||
|
||||
if (isDone) break;
|
||||
}
|
||||
|
||||
return { ok: true as const, stats: totals, isDone, cursor };
|
||||
}
|
||||
|
||||
export const backfillSkillSummariesInternal = internalAction({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
@@ -257,6 +414,16 @@ export const backfillSkillSummariesInternal = internalAction({
|
||||
handler: backfillSkillSummariesInternalHandler,
|
||||
});
|
||||
|
||||
export const backfillUserStatsInternal = internalAction({
|
||||
args: {
|
||||
batchSize: v.optional(v.number()),
|
||||
skillBatchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
cursor: v.optional(v.string()),
|
||||
},
|
||||
handler: backfillUserStatsInternalHandler,
|
||||
});
|
||||
|
||||
export const backfillSkillSummaries: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
|
||||
@@ -221,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;
|
||||
};
|
||||
@@ -326,6 +327,7 @@ function toPublicPackage(
|
||||
capabilities: pkg.capabilities,
|
||||
verification: pkg.verification,
|
||||
scanStatus: pkg.scanStatus,
|
||||
stats: pkg.stats,
|
||||
createdAt: pkg.createdAt,
|
||||
updatedAt: pkg.updatedAt,
|
||||
};
|
||||
@@ -2144,9 +2146,9 @@ export const insertReleaseInternal = internalMutation({
|
||||
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;
|
||||
const nextNameLabel = typeof args.name === "string" ? args.name : "<unknown>";
|
||||
const nextRuntimeIdLabel = typeof args.runtimeId === "string" ? args.runtimeId : "<unknown>";
|
||||
const nextVersionLabel = typeof args.version === "string" ? args.version : "<unknown>";
|
||||
if (existing) {
|
||||
const existingIsLegacyPersonalPackage =
|
||||
!existing.ownerPublisherId &&
|
||||
@@ -2169,7 +2171,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
}
|
||||
if (existing && existing.family !== args.family) {
|
||||
throw new ConvexError(
|
||||
`Package "${nextName}" already exists as a ${existing.family}; family changes are not allowed`,
|
||||
`Package "${nextNameLabel}" already exists as a ${existing.family}; family changes are not allowed`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -2180,7 +2182,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
existing.runtimeId !== args.runtimeId
|
||||
) {
|
||||
throw new ConvexError(
|
||||
`Package "${nextName}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
|
||||
`Package "${nextNameLabel}" already exists with plugin id "${existing.runtimeId}"; runtime id changes are not allowed`,
|
||||
);
|
||||
}
|
||||
if (args.family === "code-plugin" && args.runtimeId) {
|
||||
@@ -2189,7 +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 "${nextRuntimeId}" is already claimed by another package`);
|
||||
throw new ConvexError(`Plugin id "${nextRuntimeIdLabel}" is already claimed by another package`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2226,7 +2228,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
q.eq("packageId", existing._id).eq("version", args.version),
|
||||
)
|
||||
.unique();
|
||||
if (releaseExists) throw new ConvexError(`Version ${nextVersion} already exists`);
|
||||
if (releaseExists) throw new ConvexError(`Version ${nextVersionLabel} already exists`);
|
||||
}
|
||||
const priorReleases = existing
|
||||
? await ctx.db
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
listMine,
|
||||
migrateLegacyPublisherHandleToOrgInternal,
|
||||
removeMember,
|
||||
updateProfile,
|
||||
} from "./publishers";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
@@ -52,6 +53,15 @@ const listMineHandler = (
|
||||
listMine as unknown as WrappedHandler<Record<string, never>, Array<unknown>>
|
||||
)._handler;
|
||||
|
||||
const updateProfileHandler = (
|
||||
updateProfile as unknown as WrappedHandler<{
|
||||
publisherId: string;
|
||||
displayName: string;
|
||||
bio?: string;
|
||||
image?: string;
|
||||
}>
|
||||
)._handler;
|
||||
|
||||
describe("publishers membership controls", () => {
|
||||
it("prevents admins from promoting members to owner", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
||||
@@ -371,6 +381,135 @@ describe("publishers membership controls", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets org admins update org profile fields", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
||||
const patch = vi.fn(async () => {});
|
||||
const insert = vi.fn(async () => "auditLogs:1");
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:admin") return { _id: id };
|
||||
if (id === "publishers:org") {
|
||||
return {
|
||||
_id: id,
|
||||
kind: "org",
|
||||
handle: "shopify",
|
||||
displayName: "Shopify",
|
||||
image: undefined,
|
||||
bio: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue({
|
||||
_id: "publisherMembers:admin",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:admin",
|
||||
role: "admin",
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
insert,
|
||||
delete: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
updateProfileHandler(
|
||||
ctx as never,
|
||||
{
|
||||
publisherId: "publishers:org",
|
||||
displayName: "Shopify",
|
||||
bio: "Commerce platform",
|
||||
image: "https://cdn.example.com/shopify.png",
|
||||
} as never,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
publisher: expect.objectContaining({
|
||||
_id: "publishers:org",
|
||||
displayName: "Shopify",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"publishers:org",
|
||||
expect.objectContaining({
|
||||
displayName: "Shopify",
|
||||
bio: "Commerce platform",
|
||||
image: "https://cdn.example.com/shopify.png",
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"auditLogs",
|
||||
expect.objectContaining({
|
||||
action: "publisher.profile.update",
|
||||
targetId: "publishers:org",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid org profile image URLs", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:admin") return { _id: id };
|
||||
if (id === "publishers:org") {
|
||||
return {
|
||||
_id: id,
|
||||
kind: "org",
|
||||
handle: "shopify",
|
||||
displayName: "Shopify",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue({
|
||||
_id: "publisherMembers:admin",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:admin",
|
||||
role: "admin",
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
updateProfileHandler(
|
||||
ctx as never,
|
||||
{
|
||||
publisherId: "publishers:org",
|
||||
displayName: "Shopify",
|
||||
image: "not-a-url",
|
||||
} as never,
|
||||
),
|
||||
).rejects.toThrow("Image must be a valid URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("publisher bootstrap", () => {
|
||||
|
||||
@@ -539,6 +539,70 @@ export const createOrg = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const updateProfile = mutation({
|
||||
args: {
|
||||
publisherId: v.id("publishers"),
|
||||
displayName: v.string(),
|
||||
bio: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
const publisher = await ctx.db.get(args.publisherId);
|
||||
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) {
|
||||
throw new ConvexError("Publisher not found");
|
||||
}
|
||||
if (publisher.kind !== "org") {
|
||||
throw new ConvexError("Only org publishers can be updated here");
|
||||
}
|
||||
|
||||
const membership = await getPublisherMembership(ctx, publisher._id, userId);
|
||||
if (!membership || !isPublisherRoleAllowed(membership.role, ["admin"])) {
|
||||
throw new ConvexError("Forbidden");
|
||||
}
|
||||
|
||||
const displayName = args.displayName.trim() || publisher.handle;
|
||||
const bio = args.bio?.trim() || undefined;
|
||||
const image = args.image?.trim() || undefined;
|
||||
if (image) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(image);
|
||||
} catch {
|
||||
throw new ConvexError("Image must be a valid URL");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new ConvexError("Image must use http or https");
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(publisher._id, {
|
||||
displayName,
|
||||
bio,
|
||||
image,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: userId,
|
||||
action: "publisher.profile.update",
|
||||
targetType: "publisher",
|
||||
targetId: publisher._id,
|
||||
metadata: {
|
||||
displayName,
|
||||
bio,
|
||||
image,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
publisher: toPublicPublisher(await ctx.db.get(publisher._id)),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const migrateLegacyPublisherHandleToOrg = mutation({
|
||||
args: {
|
||||
handle: v.string(),
|
||||
|
||||
@@ -28,6 +28,9 @@ const users = defineTable({
|
||||
githubFetchedAt: v.optional(v.number()),
|
||||
githubProfileSyncedAt: v.optional(v.number()),
|
||||
trustedPublisher: v.optional(v.boolean()),
|
||||
publishedSkills: v.optional(v.number()),
|
||||
totalStars: v.optional(v.number()),
|
||||
totalDownloads: v.optional(v.number()),
|
||||
personalPublisherId: v.optional(v.id("publishers")),
|
||||
requiresModerationAt: v.optional(v.number()),
|
||||
requiresModerationReason: v.optional(v.string()),
|
||||
@@ -40,7 +43,8 @@ const users = defineTable({
|
||||
})
|
||||
.index("email", ["email"])
|
||||
.index("phone", ["phone"])
|
||||
.index("handle", ["handle"]);
|
||||
.index("handle", ["handle"])
|
||||
.index("by_active_handle", ["deletedAt", "deactivatedAt", "handle"]);
|
||||
|
||||
const publishers = defineTable({
|
||||
kind: v.union(v.literal("user"), v.literal("org")),
|
||||
@@ -91,10 +95,22 @@ const badgesValidator = v.optional(
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Nested stat fields on the `skills` document.
|
||||
*
|
||||
* The four migrated fields below are kept for backward compatibility only.
|
||||
* Always use the top-level fields (`statsDownloads`, `statsStars`,
|
||||
* `statsInstallsCurrent`, `statsInstallsAllTime`) as the source of truth,
|
||||
* and use `readCanonicalStat()` / `applySkillStatDeltas()` to read/write them.
|
||||
*/
|
||||
const statsValidator = v.object({
|
||||
/** @deprecated Use top-level `statsDownloads` instead. */
|
||||
downloads: v.number(),
|
||||
/** @deprecated Use top-level `statsInstallsCurrent` instead. */
|
||||
installsCurrent: v.optional(v.number()),
|
||||
/** @deprecated Use top-level `statsInstallsAllTime` instead. */
|
||||
installsAllTime: v.optional(v.number()),
|
||||
/** @deprecated Use top-level `statsStars` instead. */
|
||||
stars: v.number(),
|
||||
versions: v.number(),
|
||||
comments: v.number(),
|
||||
@@ -384,7 +400,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"),
|
||||
@@ -1286,9 +1303,7 @@ const userSkillRootInstalls = defineTable({
|
||||
const skillOwnershipTransfers = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
fromUserId: v.id("users"),
|
||||
toUserId: v.optional(v.id("users")),
|
||||
fromPublisherId: v.optional(v.id("publishers")),
|
||||
toPublisherId: v.optional(v.id("publishers")),
|
||||
toUserId: v.id("users"),
|
||||
status: v.union(
|
||||
v.literal("pending"),
|
||||
v.literal("accepted"),
|
||||
@@ -1305,7 +1320,6 @@ const skillOwnershipTransfers = defineTable({
|
||||
.index("by_from_user", ["fromUserId"])
|
||||
.index("by_to_user", ["toUserId"])
|
||||
.index("by_to_user_status", ["toUserId", "status"])
|
||||
.index("by_to_publisher_status", ["toPublisherId", "status"])
|
||||
.index("by_from_user_status", ["fromUserId", "status"])
|
||||
.index("by_skill_status", ["skillId", "status"]);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery } from "./functions";
|
||||
import { applySkillStatDeltas, bumpDailySkillStats } from "./lib/skillStats";
|
||||
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
|
||||
|
||||
/**
|
||||
* Event types that affect skill stats:
|
||||
@@ -259,6 +260,7 @@ export const processSkillStatEventsInternal = internalMutation({
|
||||
// Don't update `updatedAt` — stat changes shouldn't move the
|
||||
// skill's position in the by_active_updated index.
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ...patch });
|
||||
}
|
||||
|
||||
// NOTE: Daily stats (skillDailyStats) are written by the 15-minute
|
||||
|
||||
@@ -389,79 +389,4 @@ describe("skillTransfers", () => {
|
||||
expect.objectContaining({ ownerUserId: "users:2" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("requestTransferInternal allows org admin to request transfer", async () => {
|
||||
// Org admin (users:2) can request transfer of a skill owned by org (publishers:org1)
|
||||
// even though ownerUserId is users:1
|
||||
const insert = vi.fn(async (table: string) => {
|
||||
if (table === "skillOwnershipTransfers") return "skillOwnershipTransfers:new";
|
||||
return "auditLogs:1";
|
||||
});
|
||||
|
||||
const result = (await requestTransferInternalHandler(
|
||||
{
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:2") return { _id: "users:2", handle: "orgadmin" };
|
||||
if (id === "skills:1") {
|
||||
return {
|
||||
_id: "skills:1",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:org1",
|
||||
};
|
||||
}
|
||||
if (id === "publishers:org1") {
|
||||
return { _id: "publishers:org1", kind: "org", handle: "myorg" };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "users:3",
|
||||
handle: "recipient",
|
||||
displayName: "Recipient",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillOwnershipTransfers") {
|
||||
return { withIndex: () => ({ collect: async () => [] }) };
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:org1",
|
||||
userId: "users:2",
|
||||
role: "admin",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return { withIndex: () => ({ unique: async () => null }) };
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch: vi.fn(async () => {}),
|
||||
insert,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
actorUserId: "users:2",
|
||||
skillId: "skills:1",
|
||||
toUserHandle: "@recipient",
|
||||
} as never,
|
||||
)) as { ok: boolean; transferId: string };
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.transferId).toBe("skillOwnershipTransfers:new");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
import {
|
||||
TRANSFER_EXPIRY_MS,
|
||||
isTransferExpired,
|
||||
normalizeTransferHandle,
|
||||
validateTransferOwnership,
|
||||
validateTransferAcceptPermission,
|
||||
} from "./lib/transfers";
|
||||
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type TransferDoc = Doc<"skillOwnershipTransfers">;
|
||||
|
||||
function normalizeHandle(value: string) {
|
||||
return value.trim().replace(/^@+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function isExpired(transfer: TransferDoc, now: number) {
|
||||
return transfer.expiresAt < now;
|
||||
}
|
||||
|
||||
async function requireActiveUserById(ctx: unknown, userId: Id<"users">) {
|
||||
const db = (ctx as { db: { get: (id: Id<"users">) => Promise<Doc<"users"> | null> } }).db;
|
||||
const user = await db.get(userId);
|
||||
@@ -51,7 +53,7 @@ async function getActivePendingTransferForSkill(ctx: unknown, skillId: Id<"skill
|
||||
|
||||
let active: TransferDoc | null = null;
|
||||
for (const transfer of transfers) {
|
||||
if (isTransferExpired(transfer, now)) {
|
||||
if (isExpired(transfer, now)) {
|
||||
await db.patch(transfer._id, { status: "expired", respondedAt: now });
|
||||
continue;
|
||||
}
|
||||
@@ -81,26 +83,14 @@ async function validatePendingTransferForActor(
|
||||
const transfer = await db.get(params.transferId);
|
||||
if (!transfer) throw new Error("Transfer not found");
|
||||
|
||||
if (
|
||||
params.role === "recipient" &&
|
||||
transfer.toUserId &&
|
||||
transfer.toUserId !== params.actorUserId &&
|
||||
!transfer.toPublisherId
|
||||
) {
|
||||
// For org-targeted transfers (toPublisherId is set), skip this check —
|
||||
// validateTransferAcceptPermission handles org membership validation separately
|
||||
if (params.role === "recipient" && transfer.toUserId !== params.actorUserId) {
|
||||
throw new Error("No pending transfer found");
|
||||
}
|
||||
if (params.role === "sender") {
|
||||
if (!transfer.fromPublisherId && transfer.fromUserId !== params.actorUserId) {
|
||||
// Personal transfer: actor must be the original sender
|
||||
throw new Error("No pending transfer found");
|
||||
}
|
||||
// Org-owned transfer: actor's org membership is verified by the caller
|
||||
// (e.g. cancelTransferInternal calls validateTransferOwnership after this)
|
||||
if (params.role === "sender" && transfer.fromUserId !== params.actorUserId) {
|
||||
throw new Error("No pending transfer found");
|
||||
}
|
||||
if (transfer.status !== "pending") throw new Error("No pending transfer found");
|
||||
if (isTransferExpired(transfer, params.now)) {
|
||||
if (isExpired(transfer, params.now)) {
|
||||
await db.patch(transfer._id, { status: "expired", respondedAt: params.now });
|
||||
throw new Error("Transfer has expired");
|
||||
}
|
||||
@@ -112,7 +102,6 @@ export const requestTransferInternal = internalMutation({
|
||||
actorUserId: v.id("users"),
|
||||
skillId: v.id("skills"),
|
||||
toUserHandle: v.string(),
|
||||
toPublisherId: v.optional(v.id("publishers")),
|
||||
message: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -121,28 +110,14 @@ export const requestTransferInternal = internalMutation({
|
||||
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
|
||||
if (skill.ownerUserId !== args.actorUserId) throw new Error("Forbidden");
|
||||
|
||||
await validateTransferOwnership(ctx, {
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
actorUserId: args.actorUserId,
|
||||
});
|
||||
|
||||
const toHandle = normalizeTransferHandle(args.toUserHandle);
|
||||
const toHandle = normalizeHandle(args.toUserHandle);
|
||||
if (!toHandle) throw new Error("toUserHandle required");
|
||||
|
||||
const toUser = await getActiveUserByHandleOrPersonalPublisher(ctx, toHandle);
|
||||
if (!toUser) throw new Error("User not found");
|
||||
if (toUser._id === args.actorUserId && !args.toPublisherId) {
|
||||
throw new Error("Cannot transfer to yourself");
|
||||
}
|
||||
|
||||
if (args.toPublisherId) {
|
||||
const toPublisher = await ctx.db.get(args.toPublisherId);
|
||||
if (!toPublisher || toPublisher.deletedAt || toPublisher.deactivatedAt) {
|
||||
throw new Error("Target publisher not found");
|
||||
}
|
||||
}
|
||||
if (toUser._id === args.actorUserId) throw new Error("Cannot transfer to yourself");
|
||||
|
||||
const activePending = await getActivePendingTransferForSkill(ctx, args.skillId, now);
|
||||
if (activePending) throw new Error("A transfer is already pending for this skill");
|
||||
@@ -153,8 +128,6 @@ export const requestTransferInternal = internalMutation({
|
||||
skillId: skill._id,
|
||||
fromUserId: args.actorUserId,
|
||||
toUserId: toUser._id,
|
||||
fromPublisherId: skill.ownerPublisherId,
|
||||
toPublisherId: args.toPublisherId,
|
||||
status: "pending",
|
||||
message: message || undefined,
|
||||
requestedAt: now,
|
||||
@@ -182,7 +155,6 @@ export const acceptTransferInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
transferId: v.id("skillOwnershipTransfers"),
|
||||
publisherId: v.optional(v.id("publishers")),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
@@ -195,45 +167,19 @@ export const acceptTransferInternal = internalMutation({
|
||||
now,
|
||||
});
|
||||
|
||||
await validateTransferAcceptPermission(ctx, {
|
||||
actorUserId: args.actorUserId,
|
||||
toUserId: transfer.toUserId ?? undefined,
|
||||
toPublisherId: transfer.toPublisherId,
|
||||
});
|
||||
|
||||
const skill = await ctx.db.get(transfer.skillId);
|
||||
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
|
||||
const ownerChanged = transfer.fromPublisherId
|
||||
? skill.ownerPublisherId !== transfer.fromPublisherId
|
||||
: skill.ownerUserId !== transfer.fromUserId;
|
||||
if (ownerChanged) {
|
||||
if (skill.ownerUserId !== transfer.fromUserId) {
|
||||
await ctx.db.patch(transfer._id, { status: "cancelled", respondedAt: now });
|
||||
throw new Error("Transfer is no longer valid");
|
||||
}
|
||||
|
||||
// Determine target publisher: sender's choice > recipient override > personal
|
||||
// When the sender specified a target publisher, honor it unconditionally
|
||||
let targetPublisherId: Id<"publishers">;
|
||||
if (transfer.toPublisherId) {
|
||||
targetPublisherId = transfer.toPublisherId;
|
||||
} else if (args.publisherId) {
|
||||
await validateTransferAcceptPermission(ctx, {
|
||||
actorUserId: args.actorUserId,
|
||||
toPublisherId: args.publisherId,
|
||||
});
|
||||
targetPublisherId = args.publisherId;
|
||||
} else {
|
||||
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
|
||||
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
|
||||
targetPublisherId = newPublisher._id;
|
||||
}
|
||||
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
|
||||
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
|
||||
|
||||
// For org-targeted transfers, ownerUserId is set to whichever admin accepts,
|
||||
// not necessarily the original toUserId. The toUserHandle on the request just
|
||||
// routes the transfer — actual ownership reflects who acted on it.
|
||||
await ctx.db.patch(skill._id, {
|
||||
ownerUserId: args.actorUserId,
|
||||
ownerPublisherId: targetPublisherId,
|
||||
ownerPublisherId: newPublisher._id,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
@@ -244,16 +190,12 @@ export const acceptTransferInternal = internalMutation({
|
||||
for (const alias of aliases) {
|
||||
await ctx.db.patch(alias._id, {
|
||||
ownerUserId: args.actorUserId,
|
||||
ownerPublisherId: targetPublisherId,
|
||||
ownerPublisherId: newPublisher._id,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.patch(transfer._id, {
|
||||
status: "accepted",
|
||||
respondedAt: now,
|
||||
toUserId: args.actorUserId,
|
||||
});
|
||||
await ctx.db.patch(transfer._id, { status: "accepted", respondedAt: now });
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
@@ -287,12 +229,6 @@ export const rejectTransferInternal = internalMutation({
|
||||
now,
|
||||
});
|
||||
|
||||
await validateTransferAcceptPermission(ctx, {
|
||||
actorUserId: args.actorUserId,
|
||||
toUserId: transfer.toUserId ?? undefined,
|
||||
toPublisherId: transfer.toPublisherId,
|
||||
});
|
||||
|
||||
await ctx.db.patch(transfer._id, { status: "rejected", respondedAt: now });
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
@@ -323,15 +259,6 @@ export const cancelTransferInternal = internalMutation({
|
||||
now,
|
||||
});
|
||||
|
||||
// For org-owned transfers, always verify actor still has admin/owner role
|
||||
if (transfer.fromPublisherId) {
|
||||
await validateTransferOwnership(ctx, {
|
||||
ownerUserId: transfer.fromUserId,
|
||||
ownerPublisherId: transfer.fromPublisherId,
|
||||
actorUserId: args.actorUserId,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.patch(transfer._id, { status: "cancelled", respondedAt: now });
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
@@ -352,63 +279,28 @@ export const listIncomingInternal = internalQuery({
|
||||
const now = Date.now();
|
||||
await requireActiveUserById(ctx, args.userId);
|
||||
|
||||
// Query transfers directed at this user personally
|
||||
const userTransfers = await ctx.db
|
||||
const transfers = await ctx.db
|
||||
.query("skillOwnershipTransfers")
|
||||
.withIndex("by_to_user_status", (q) => q.eq("toUserId", args.userId).eq("status", "pending"))
|
||||
.collect();
|
||||
|
||||
// Query transfers directed at orgs where this user is an admin or owner
|
||||
const memberships = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.collect();
|
||||
const adminPublisherIds = memberships
|
||||
.filter((m) => m.role === "owner" || m.role === "admin")
|
||||
.map((m) => m.publisherId);
|
||||
|
||||
const orgTransferArrays = await Promise.all(
|
||||
adminPublisherIds.map((publisherId) =>
|
||||
ctx.db
|
||||
.query("skillOwnershipTransfers")
|
||||
.withIndex("by_to_publisher_status", (q) =>
|
||||
q.eq("toPublisherId", publisherId).eq("status", "pending"),
|
||||
)
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
const orgTransfers = orgTransferArrays.flat();
|
||||
|
||||
// Merge and deduplicate by transfer ID
|
||||
const seen = new Set<string>();
|
||||
const allTransfers: TransferDoc[] = [];
|
||||
for (const t of [...userTransfers, ...orgTransfers]) {
|
||||
if (!seen.has(t._id)) {
|
||||
seen.add(t._id);
|
||||
allTransfers.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
type: "skill";
|
||||
_id: Id<"skillOwnershipTransfers">;
|
||||
skill: { _id: Id<"skills">; slug: string; displayName: string };
|
||||
fromUser: { _id: Id<"users">; handle: string | null; displayName: string | null };
|
||||
toPublisherId?: Id<"publishers">;
|
||||
message: string | undefined;
|
||||
requestedAt: number;
|
||||
expiresAt: number;
|
||||
}> = [];
|
||||
|
||||
for (const transfer of allTransfers) {
|
||||
if (isTransferExpired(transfer, now)) continue;
|
||||
for (const transfer of transfers) {
|
||||
if (isExpired(transfer, now)) continue;
|
||||
const skill = await ctx.db.get(transfer.skillId);
|
||||
if (!skill || skill.softDeletedAt) continue;
|
||||
const fromUser = await ctx.db.get(transfer.fromUserId);
|
||||
if (!fromUser || fromUser.deletedAt || fromUser.deactivatedAt) continue;
|
||||
|
||||
results.push({
|
||||
type: "skill" as const,
|
||||
_id: transfer._id,
|
||||
skill: { _id: skill._id, slug: skill.slug, displayName: skill.displayName },
|
||||
fromUser: {
|
||||
@@ -416,7 +308,6 @@ export const listIncomingInternal = internalQuery({
|
||||
handle: fromUser.handle ?? null,
|
||||
displayName: fromUser.displayName ?? null,
|
||||
},
|
||||
toPublisherId: transfer.toPublisherId ?? undefined,
|
||||
message: transfer.message,
|
||||
requestedAt: transfer.requestedAt,
|
||||
expiresAt: transfer.expiresAt,
|
||||
@@ -441,41 +332,29 @@ export const listOutgoingInternal = internalQuery({
|
||||
.collect();
|
||||
|
||||
const results: Array<{
|
||||
type: "skill";
|
||||
_id: Id<"skillOwnershipTransfers">;
|
||||
skill: { _id: Id<"skills">; slug: string; displayName: string };
|
||||
toUser?: { _id: Id<"users">; handle: string | null; displayName: string | null };
|
||||
toPublisherId?: Id<"publishers">;
|
||||
toUser: { _id: Id<"users">; handle: string | null; displayName: string | null };
|
||||
message: string | undefined;
|
||||
requestedAt: number;
|
||||
expiresAt: number;
|
||||
}> = [];
|
||||
|
||||
for (const transfer of transfers) {
|
||||
if (isTransferExpired(transfer, now)) continue;
|
||||
if (isExpired(transfer, now)) continue;
|
||||
const skill = await ctx.db.get(transfer.skillId);
|
||||
if (!skill || skill.softDeletedAt) continue;
|
||||
|
||||
let toUser:
|
||||
| { _id: Id<"users">; handle: string | null; displayName: string | null }
|
||||
| undefined;
|
||||
if (transfer.toUserId) {
|
||||
const tu = await ctx.db.get(transfer.toUserId);
|
||||
if (tu && !tu.deletedAt && !tu.deactivatedAt) {
|
||||
toUser = {
|
||||
_id: tu._id,
|
||||
handle: tu.handle ?? null,
|
||||
displayName: tu.displayName ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
const toUser = await ctx.db.get(transfer.toUserId);
|
||||
if (!toUser || toUser.deletedAt || toUser.deactivatedAt) continue;
|
||||
|
||||
results.push({
|
||||
type: "skill" as const,
|
||||
_id: transfer._id,
|
||||
skill: { _id: skill._id, slug: skill.slug, displayName: skill.displayName },
|
||||
toUser,
|
||||
toPublisherId: transfer.toPublisherId ?? undefined,
|
||||
toUser: {
|
||||
_id: toUser._id,
|
||||
handle: toUser.handle ?? null,
|
||||
displayName: toUser.displayName ?? null,
|
||||
},
|
||||
message: transfer.message,
|
||||
requestedAt: transfer.requestedAt,
|
||||
expiresAt: transfer.expiresAt,
|
||||
@@ -486,22 +365,6 @@ export const listOutgoingInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const getPendingTransferBySkillInternal = internalQuery({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
const transfer = await ctx.db
|
||||
.query("skillOwnershipTransfers")
|
||||
.withIndex("by_skill_status", (q) => q.eq("skillId", args.skillId).eq("status", "pending"))
|
||||
.first();
|
||||
|
||||
if (!transfer || isTransferExpired(transfer, now)) return null;
|
||||
return transfer;
|
||||
},
|
||||
});
|
||||
|
||||
export const getPendingTransferBySkillAndUserInternal = internalQuery({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
@@ -515,7 +378,7 @@ export const getPendingTransferBySkillAndUserInternal = internalQuery({
|
||||
.filter((q) => q.eq(q.field("toUserId"), args.toUserId))
|
||||
.first();
|
||||
|
||||
if (!transfer || isTransferExpired(transfer, now)) return null;
|
||||
if (!transfer || isExpired(transfer, now)) return null;
|
||||
return transfer;
|
||||
},
|
||||
});
|
||||
@@ -533,7 +396,7 @@ export const getPendingTransferBySkillAndFromUserInternal = internalQuery({
|
||||
.filter((q) => q.eq(q.field("fromUserId"), args.fromUserId))
|
||||
.first();
|
||||
|
||||
if (!transfer || isTransferExpired(transfer, now)) return null;
|
||||
if (!transfer || isExpired(transfer, now)) return null;
|
||||
return transfer;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -35,6 +35,12 @@ const getBySlugHandler = (
|
||||
image: string | null;
|
||||
bio?: string | null;
|
||||
} | null;
|
||||
latestVersion?: {
|
||||
files?: Array<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
} | null;
|
||||
} | null
|
||||
>
|
||||
)._handler;
|
||||
@@ -171,4 +177,70 @@ describe("skills.getBySlug", () => {
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes misleading file MIME types in public version metadata", async () => {
|
||||
const ctx = makeCtx({
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
_creationTime: 1,
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
summary: "Public demo skill",
|
||||
ownerUserId: "users:1",
|
||||
canonicalSkillId: undefined,
|
||||
forkOf: undefined,
|
||||
latestVersionId: "skillVersions:1",
|
||||
tags: {},
|
||||
stats: {
|
||||
downloads: 10,
|
||||
installsCurrent: 2,
|
||||
installsAllTime: 5,
|
||||
stars: 3,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
owner: {
|
||||
_id: "users:1",
|
||||
_creationTime: 1,
|
||||
handle: "demo-owner",
|
||||
name: "Demo Owner",
|
||||
displayName: "Demo Owner",
|
||||
image: null,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: "skillVersions:1",
|
||||
_creationTime: 2,
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
fingerprint: "abc",
|
||||
changelog: "",
|
||||
changelogSource: "user",
|
||||
files: [
|
||||
{
|
||||
path: "src/index.ts",
|
||||
size: 10,
|
||||
sha256: "deadbeef",
|
||||
contentType: "video/mp2t",
|
||||
},
|
||||
],
|
||||
createdBy: "users:1",
|
||||
createdAt: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getBySlugHandler(ctx, { slug: "demo" } as never);
|
||||
|
||||
expect(result?.latestVersion?.files).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "src/index.ts",
|
||||
contentType: "application/typescript",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import { getPage, type IndexKey, paginator } from "convex-helpers/server/pagination";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { ConvexError, v, type Value } from "convex/values";
|
||||
@@ -83,6 +84,7 @@ import {
|
||||
extractDigestFields,
|
||||
upsertSkillSearchDigest,
|
||||
} from "./lib/skillSearchDigest";
|
||||
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
|
||||
import schema from "./schema";
|
||||
|
||||
export { publishVersionForUser } from "./lib/skillPublish";
|
||||
@@ -757,6 +759,7 @@ async function hardDeleteSkillStep(
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
@@ -1229,7 +1232,7 @@ function toPublicSkillVersion(
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
contentType: file.contentType,
|
||||
contentType: normalizeTextContentType(file.path, file.contentType),
|
||||
})),
|
||||
parsed: version.parsed
|
||||
? {
|
||||
@@ -2528,6 +2531,7 @@ export const report = mutation({
|
||||
const nextSkill = { ...skill, ...updates };
|
||||
await ctx.db.patch(skill._id, updates);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
if (shouldAutoHide) {
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now);
|
||||
@@ -4254,6 +4258,7 @@ export const applyBanToOwnedSkillsBatchInternal = internalMutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, args.bannedAt);
|
||||
}
|
||||
|
||||
@@ -4365,6 +4370,7 @@ export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, false, now);
|
||||
restoredCount += 1;
|
||||
@@ -5304,6 +5310,7 @@ export const setSoftDeleted = mutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now);
|
||||
|
||||
@@ -5342,6 +5349,7 @@ export const changeOwner = mutation({
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ownerUserId: args.ownerUserId });
|
||||
|
||||
const embeddings = await listSkillEmbeddingsForSkill(ctx, skill._id);
|
||||
for (const embedding of embeddings) {
|
||||
@@ -6422,6 +6430,7 @@ export const insertVersion = internalMutation({
|
||||
// Digest sync is handled after the version patch below (line ~4222),
|
||||
// which captures the final state including latestVersionId and tags.
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, null, skill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, null, skill);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the Convex function wrappers so that importing statsMaintenance.ts does
|
||||
// not attempt to load the Convex runtime (convex/server) in the Node test env.
|
||||
vi.mock("./functions", () => ({
|
||||
internalMutation: (def: { handler: unknown }) => def,
|
||||
internalQuery: (def: { handler: unknown }) => def,
|
||||
internalAction: (def: { handler: unknown }) => def,
|
||||
}));
|
||||
|
||||
vi.mock("./_generated/api", () => ({
|
||||
internal: {
|
||||
statsMaintenance: {
|
||||
backfillSkillStatFieldsInternal: Symbol("backfillSkillStatFieldsInternal"),
|
||||
getSkillStatBackfillStateInternal: Symbol("getSkillStatBackfillStateInternal"),
|
||||
setSkillStatBackfillStateInternal: Symbol("setSkillStatBackfillStateInternal"),
|
||||
reconcileSkillStarCounts: Symbol("reconcileSkillStarCounts"),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { __test, reconcileSkillStarCountsHandler } = await import("./statsMaintenance");
|
||||
const { buildSkillStatPatch } = __test;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a minimal skill doc for testing. Only the stat-related fields are
|
||||
* required; everything else is left as `undefined` / cast via `as never`.
|
||||
*/
|
||||
function makeSkill(overrides: {
|
||||
statsDownloads?: number;
|
||||
statsStars?: number;
|
||||
statsInstallsCurrent?: number;
|
||||
statsInstallsAllTime?: number;
|
||||
stats: {
|
||||
downloads: number;
|
||||
stars: number;
|
||||
installsCurrent?: number;
|
||||
installsAllTime?: number;
|
||||
comments: number;
|
||||
};
|
||||
}) {
|
||||
return overrides as never;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildSkillStatPatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildSkillStatPatch", () => {
|
||||
it("scenario 1: top-level fields present and already in sync with nested → returns null", () => {
|
||||
const skill = makeSkill({
|
||||
statsDownloads: 10,
|
||||
statsStars: 5,
|
||||
statsInstallsCurrent: 3,
|
||||
statsInstallsAllTime: 20,
|
||||
stats: { downloads: 10, stars: 5, installsCurrent: 3, installsAllTime: 20, comments: 1 },
|
||||
});
|
||||
|
||||
expect(buildSkillStatPatch(skill)).toBeNull();
|
||||
});
|
||||
|
||||
it("scenario 2: top-level fields present but nested fields are stale → patches nested to match top-level", () => {
|
||||
const skill = makeSkill({
|
||||
statsDownloads: 10,
|
||||
statsStars: 5,
|
||||
statsInstallsCurrent: 3,
|
||||
statsInstallsAllTime: 20,
|
||||
stats: { downloads: 1, stars: 1, installsCurrent: 0, installsAllTime: 0, comments: 0 },
|
||||
});
|
||||
|
||||
const patch = buildSkillStatPatch(skill);
|
||||
expect(patch).not.toBeNull();
|
||||
// Top-level fields must be written with the canonical (top-level) values.
|
||||
expect(patch!.statsDownloads).toBe(10);
|
||||
expect(patch!.statsStars).toBe(5);
|
||||
expect(patch!.statsInstallsCurrent).toBe(3);
|
||||
expect(patch!.statsInstallsAllTime).toBe(20);
|
||||
// Nested fields must be brought in sync with the top-level values.
|
||||
expect(patch!.stats.downloads).toBe(10);
|
||||
expect(patch!.stats.stars).toBe(5);
|
||||
expect(patch!.stats.installsCurrent).toBe(3);
|
||||
expect(patch!.stats.installsAllTime).toBe(20);
|
||||
});
|
||||
|
||||
it("scenario 3: top-level fields absent (pre-migration doc) → reads from nested, writes both sets", () => {
|
||||
const skill = makeSkill({
|
||||
// No statsDownloads / statsStars / etc. — pre-migration document.
|
||||
stats: { downloads: 7, stars: 3, installsCurrent: 2, installsAllTime: 15, comments: 4 },
|
||||
});
|
||||
|
||||
const patch = buildSkillStatPatch(skill);
|
||||
expect(patch).not.toBeNull();
|
||||
// Top-level fields must be populated from the nested values.
|
||||
expect(patch!.statsDownloads).toBe(7);
|
||||
expect(patch!.statsStars).toBe(3);
|
||||
expect(patch!.statsInstallsCurrent).toBe(2);
|
||||
expect(patch!.statsInstallsAllTime).toBe(15);
|
||||
// Nested fields must remain consistent.
|
||||
expect(patch!.stats.downloads).toBe(7);
|
||||
expect(patch!.stats.stars).toBe(3);
|
||||
expect(patch!.stats.installsCurrent).toBe(2);
|
||||
expect(patch!.stats.installsAllTime).toBe(15);
|
||||
});
|
||||
|
||||
it("scenario 4: top-level fields present but nested is out of sync → patches nested to match top-level (not the other way around)", () => {
|
||||
// This is the exact bug that was previously shipped: the old code wrote
|
||||
// nested → top-level instead of top-level → nested.
|
||||
const skill = makeSkill({
|
||||
statsDownloads: 100,
|
||||
statsStars: 50,
|
||||
statsInstallsCurrent: 30,
|
||||
statsInstallsAllTime: 200,
|
||||
stats: { downloads: 1, stars: 1, installsCurrent: 1, installsAllTime: 1, comments: 0 },
|
||||
});
|
||||
|
||||
const patch = buildSkillStatPatch(skill);
|
||||
expect(patch).not.toBeNull();
|
||||
// The canonical top-level values must win.
|
||||
expect(patch!.statsDownloads).toBe(100);
|
||||
expect(patch!.statsStars).toBe(50);
|
||||
expect(patch!.statsInstallsCurrent).toBe(30);
|
||||
expect(patch!.statsInstallsAllTime).toBe(200);
|
||||
// The stale nested values must be overwritten by the top-level values.
|
||||
expect(patch!.stats.downloads).toBe(100);
|
||||
expect(patch!.stats.stars).toBe(50);
|
||||
expect(patch!.stats.installsCurrent).toBe(30);
|
||||
expect(patch!.stats.installsAllTime).toBe(200);
|
||||
});
|
||||
|
||||
it("preserves unrelated nested fields (e.g. comments) when patching stat fields", () => {
|
||||
const skill = makeSkill({
|
||||
statsDownloads: 5,
|
||||
statsStars: 2,
|
||||
statsInstallsCurrent: 1,
|
||||
statsInstallsAllTime: 10,
|
||||
stats: { downloads: 0, stars: 0, installsCurrent: 0, installsAllTime: 0, comments: 99 },
|
||||
});
|
||||
|
||||
const patch = buildSkillStatPatch(skill);
|
||||
expect(patch).not.toBeNull();
|
||||
// comments is not a stat field managed by buildSkillStatPatch — it must be
|
||||
// carried over unchanged from the original nested object.
|
||||
expect(patch!.stats.comments).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reconcileSkillStarCountsHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("reconcileSkillStarCounts", () => {
|
||||
/**
|
||||
* Build a minimal db mock that returns a single-page result for skills and
|
||||
* configurable star / comment record counts.
|
||||
*/
|
||||
function makeCtx(options: {
|
||||
skill: {
|
||||
_id: string;
|
||||
statsStars?: number;
|
||||
stats: { stars: number; comments: number };
|
||||
softDeletedAt?: number;
|
||||
};
|
||||
actualStarCount: number;
|
||||
actualCommentCount: number;
|
||||
}) {
|
||||
const { skill, actualStarCount, actualCommentCount } = options;
|
||||
|
||||
const starRecords = Array.from({ length: actualStarCount }, (_, i) => ({
|
||||
_id: `stars:${i}`,
|
||||
skillId: skill._id,
|
||||
}));
|
||||
|
||||
const commentRecords = Array.from({ length: actualCommentCount }, (_, i) => ({
|
||||
_id: `comments:${i}`,
|
||||
skillId: skill._id,
|
||||
softDeletedAt: undefined,
|
||||
}));
|
||||
|
||||
const paginate = vi.fn().mockResolvedValue({
|
||||
page: [skill],
|
||||
continueCursor: null,
|
||||
isDone: true,
|
||||
});
|
||||
|
||||
const collect = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(starRecords)
|
||||
.mockResolvedValueOnce(commentRecords);
|
||||
|
||||
const withIndex = vi.fn().mockReturnValue({ collect });
|
||||
|
||||
const patch = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn().mockReturnValue({
|
||||
order: vi.fn().mockReturnValue({ paginate }),
|
||||
withIndex,
|
||||
}),
|
||||
patch,
|
||||
},
|
||||
} as never;
|
||||
|
||||
return { ctx, patch };
|
||||
}
|
||||
|
||||
it("reads from top-level statsStars (canonical path) when deciding whether to patch", async () => {
|
||||
// statsStars is correct (matches actual count), but stats.stars is stale.
|
||||
// The reconcile job uses the canonical read path (top-level preferred), so
|
||||
// it should NOT trigger a patch based on the star count alone.
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
statsStars: 5, // canonical value — correct
|
||||
stats: { stars: 99, comments: 0 }, // legacy value — stale, but not reconcile's concern
|
||||
};
|
||||
|
||||
const { ctx, patch } = makeCtx({ skill, actualStarCount: 5, actualCommentCount: 0 });
|
||||
|
||||
const result = await reconcileSkillStarCountsHandler(ctx, {});
|
||||
|
||||
expect(result.scanned).toBe(1);
|
||||
expect(result.patched).toBe(0);
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to stats.stars when statsStars is absent (pre-migration doc)", async () => {
|
||||
// Pre-migration doc: no top-level statsStars. The canonical read path
|
||||
// falls back to stats.stars. If that also matches actual count, no patch.
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
// statsStars intentionally absent
|
||||
stats: { stars: 3, comments: 0 },
|
||||
};
|
||||
|
||||
const { ctx, patch } = makeCtx({ skill, actualStarCount: 3, actualCommentCount: 0 });
|
||||
|
||||
const result = await reconcileSkillStarCountsHandler(ctx, {});
|
||||
|
||||
expect(result.scanned).toBe(1);
|
||||
expect(result.patched).toBe(0);
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("patches both statsStars and stats.stars when canonical value drifts from actual count", async () => {
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
statsStars: 10, // canonical value — out of sync with actual
|
||||
stats: { stars: 10, comments: 0 },
|
||||
};
|
||||
|
||||
const { ctx, patch } = makeCtx({ skill, actualStarCount: 7, actualCommentCount: 0 });
|
||||
|
||||
const result = await reconcileSkillStarCountsHandler(ctx, {});
|
||||
|
||||
expect(result.scanned).toBe(1);
|
||||
expect(result.patched).toBe(1);
|
||||
expect(patch).toHaveBeenCalledWith("skills:1", expect.objectContaining({
|
||||
statsStars: 7,
|
||||
stats: expect.objectContaining({ stars: 7 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("patches when comment count drifts even if star count is correct", async () => {
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
statsStars: 5,
|
||||
stats: { stars: 5, comments: 10 }, // comments out of sync
|
||||
};
|
||||
|
||||
const { ctx, patch } = makeCtx({ skill, actualStarCount: 5, actualCommentCount: 3 });
|
||||
|
||||
const result = await reconcileSkillStarCountsHandler(ctx, {});
|
||||
|
||||
expect(result.scanned).toBe(1);
|
||||
expect(result.patched).toBe(1);
|
||||
expect(patch).toHaveBeenCalledWith("skills:1", expect.objectContaining({
|
||||
stats: expect.objectContaining({ comments: 3 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("skips soft-deleted skills", async () => {
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
softDeletedAt: 12345,
|
||||
statsStars: 0,
|
||||
stats: { stars: 0, comments: 0 },
|
||||
};
|
||||
|
||||
const { ctx, patch } = makeCtx({ skill, actualStarCount: 5, actualCommentCount: 0 });
|
||||
|
||||
const result = await reconcileSkillStarCountsHandler(ctx, {});
|
||||
|
||||
// Soft-deleted skills are excluded from scanned count and never patched.
|
||||
expect(result.scanned).toBe(0);
|
||||
expect(result.patched).toBe(0);
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -183,25 +183,53 @@ export const runSkillStatBackfillInternal: ReturnType<typeof internalAction> = i
|
||||
|
||||
function buildSkillStatPatch(skill: Doc<"skills">) {
|
||||
const stats = skill.stats;
|
||||
const nextDownloads = stats.downloads;
|
||||
const nextStars = stats.stars;
|
||||
const nextInstallsCurrent = stats.installsCurrent ?? 0;
|
||||
const nextInstallsAllTime = stats.installsAllTime ?? 0;
|
||||
|
||||
if (
|
||||
// Prefer the top-level stat fields when they exist (they are kept up-to-date
|
||||
// by applySkillStatDeltas on every event flush). Fall back to the legacy
|
||||
// nested `stats` object only for documents that pre-date the migration.
|
||||
const nextDownloads =
|
||||
typeof skill.statsDownloads === "number" ? skill.statsDownloads : stats.downloads;
|
||||
const nextStars =
|
||||
typeof skill.statsStars === "number" ? skill.statsStars : stats.stars;
|
||||
const nextInstallsCurrent =
|
||||
typeof skill.statsInstallsCurrent === "number"
|
||||
? skill.statsInstallsCurrent
|
||||
: (stats.installsCurrent ?? 0);
|
||||
const nextInstallsAllTime =
|
||||
typeof skill.statsInstallsAllTime === "number"
|
||||
? skill.statsInstallsAllTime
|
||||
: (stats.installsAllTime ?? 0);
|
||||
|
||||
// Check whether both sets of fields are already in sync.
|
||||
const topLevelInSync =
|
||||
skill.statsDownloads === nextDownloads &&
|
||||
skill.statsStars === nextStars &&
|
||||
skill.statsInstallsCurrent === nextInstallsCurrent &&
|
||||
skill.statsInstallsAllTime === nextInstallsAllTime
|
||||
) {
|
||||
skill.statsInstallsAllTime === nextInstallsAllTime;
|
||||
|
||||
const nestedInSync =
|
||||
stats.downloads === nextDownloads &&
|
||||
stats.stars === nextStars &&
|
||||
(stats.installsCurrent ?? 0) === nextInstallsCurrent &&
|
||||
(stats.installsAllTime ?? 0) === nextInstallsAllTime;
|
||||
|
||||
if (topLevelInSync && nestedInSync) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Write both sets of fields so they stay in sync.
|
||||
return {
|
||||
statsDownloads: nextDownloads,
|
||||
statsStars: nextStars,
|
||||
statsInstallsCurrent: nextInstallsCurrent,
|
||||
statsInstallsAllTime: nextInstallsAllTime,
|
||||
stats: {
|
||||
...stats,
|
||||
downloads: nextDownloads,
|
||||
stars: nextStars,
|
||||
installsCurrent: nextInstallsCurrent,
|
||||
installsAllTime: nextInstallsAllTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,63 +243,79 @@ function buildSkillStatPatch(skill: Doc<"skills">) {
|
||||
*
|
||||
* Downloads and installs are event-sourced only (no separate table to count from),
|
||||
* so they cannot be reconciled this way.
|
||||
*
|
||||
* Exported as a standalone function so it can be unit-tested directly without
|
||||
* going through the Convex internalMutation wrapper.
|
||||
*/
|
||||
export async function reconcileSkillStarCountsHandler(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ctx: { db: { query: any; patch: any } },
|
||||
args: { cursor?: string; batchSize?: number },
|
||||
) {
|
||||
const batchSize = clampInt(args.batchSize ?? 50, 1, 200);
|
||||
const now = Date.now();
|
||||
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query("skills")
|
||||
.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")
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.withIndex("by_skill_user", (q: any) => q.eq("skillId", skill._id))
|
||||
.collect();
|
||||
const actualStars = starRecords.length;
|
||||
|
||||
// Count actual comment records for this skill
|
||||
const commentRecords = await ctx.db
|
||||
.query("comments")
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.withIndex("by_skill", (q: any) => q.eq("skillId", skill._id))
|
||||
.collect();
|
||||
const actualComments = commentRecords.filter((c: { softDeletedAt?: unknown }) => !c.softDeletedAt).length;
|
||||
|
||||
// Check if stats are out of sync (compare against the canonical value
|
||||
// used by toPublicSkill: prefer top-level field, fall back to nested).
|
||||
const currentStars =
|
||||
typeof skill.statsStars === "number" ? skill.statsStars : skill.stats.stars;
|
||||
|
||||
if (currentStars !== actualStars || skill.stats.comments !== actualComments) {
|
||||
const updatedStats = {
|
||||
...skill.stats,
|
||||
stars: actualStars,
|
||||
comments: actualComments,
|
||||
};
|
||||
// Keep both the top-level index field and the legacy nested field in sync.
|
||||
await ctx.db.patch(skill._id, {
|
||||
statsStars: actualStars,
|
||||
stats: updatedStats,
|
||||
updatedAt: now,
|
||||
});
|
||||
patched += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scanned,
|
||||
patched,
|
||||
cursor: isDone ? null : continueCursor,
|
||||
isDone,
|
||||
};
|
||||
}
|
||||
|
||||
export const reconcileSkillStarCounts = internalMutation({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = clampInt(args.batchSize ?? 50, 1, 200);
|
||||
const now = Date.now();
|
||||
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query("skills")
|
||||
.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")
|
||||
.withIndex("by_skill_user", (q) => q.eq("skillId", skill._id))
|
||||
.collect();
|
||||
const actualStars = starRecords.length;
|
||||
|
||||
// Count actual comment records for this skill
|
||||
const commentRecords = await ctx.db
|
||||
.query("comments")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", skill._id))
|
||||
.collect();
|
||||
const actualComments = commentRecords.filter((c) => !c.softDeletedAt).length;
|
||||
|
||||
// Check if stats are out of sync
|
||||
if (skill.stats.stars !== actualStars || skill.stats.comments !== actualComments) {
|
||||
const updatedStats = {
|
||||
...skill.stats,
|
||||
stars: actualStars,
|
||||
comments: actualComments,
|
||||
};
|
||||
await ctx.db.patch(skill._id, {
|
||||
statsStars: actualStars,
|
||||
stats: updatedStats,
|
||||
updatedAt: now,
|
||||
});
|
||||
patched += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scanned,
|
||||
patched,
|
||||
cursor: isDone ? null : continueCursor,
|
||||
isDone,
|
||||
};
|
||||
},
|
||||
handler: reconcileSkillStarCountsHandler,
|
||||
});
|
||||
|
||||
export const runReconcileSkillStarCountsInternal = internalAction({
|
||||
@@ -308,6 +352,11 @@ function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
// Exported for unit testing only — not part of the public API.
|
||||
export const __test = {
|
||||
buildSkillStatPatch,
|
||||
};
|
||||
|
||||
/**
|
||||
* Count a page of skillSearchDigest docs and return the partial public count.
|
||||
* Each query runs in its own transaction (~1000 docs, ~900 KB), well under limits.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import {
|
||||
assertAdmin,
|
||||
assertModerator,
|
||||
getOptionalActiveAuthUserId,
|
||||
requireUser,
|
||||
} from "./lib/access";
|
||||
import { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
||||
import { syncGitHubProfile } from "./lib/githubAccount";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPublisherByHandle,
|
||||
getUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
getLatestActiveReservedHandle,
|
||||
isHandleReservedForAnotherUser,
|
||||
@@ -301,9 +296,7 @@ export async function ensureHandler(ctx: MutationCtx) {
|
||||
updates.updatedAt = Date.now();
|
||||
await ctx.db.patch(userId, updates);
|
||||
}
|
||||
const ensuredUser = hasUpdates
|
||||
? ({ ...user, ...updates } as Doc<"users">)
|
||||
: ((await ctx.db.get(userId)) ?? user);
|
||||
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
|
||||
await ensurePersonalPublisherForUser(ctx, ensuredUser);
|
||||
return await ctx.db.get(userId);
|
||||
}
|
||||
@@ -393,6 +386,23 @@ export const list = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const listPublic = query({
|
||||
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? 40, 1, 100);
|
||||
const result = await queryUsersForPublicList(ctx, {
|
||||
limit,
|
||||
search: args.search,
|
||||
});
|
||||
return {
|
||||
items: result.items
|
||||
.map((user) => toPublicUser(user))
|
||||
.filter((user): user is NonNullable<ReturnType<typeof toPublicUser>> => Boolean(user)),
|
||||
total: result.total,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function normalizeSearchQuery(search?: string) {
|
||||
const trimmed = search?.trim().toLowerCase();
|
||||
return trimmed ? trimmed : undefined;
|
||||
@@ -403,13 +413,7 @@ function computeUserSearchScanLimit(limit: number) {
|
||||
}
|
||||
|
||||
async function queryUsersForAdminList(
|
||||
ctx: {
|
||||
db: {
|
||||
query: (table: "users") => {
|
||||
order: (order: "desc") => { take: (n: number) => Promise<Doc<"users">[]> };
|
||||
};
|
||||
};
|
||||
},
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
args: { limit: number; search?: string; exactUserId?: Id<"users"> },
|
||||
) {
|
||||
const normalizedSearch = normalizeSearchQuery(args.search);
|
||||
@@ -431,6 +435,27 @@ async function queryUsersForAdminList(
|
||||
};
|
||||
}
|
||||
|
||||
async function queryUsersForPublicList(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
args: { limit: number; search?: string },
|
||||
) {
|
||||
const normalizedSearch = normalizeSearchQuery(args.search);
|
||||
const scanLimit = normalizedSearch
|
||||
? computeUserSearchScanLimit(args.limit)
|
||||
: clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
|
||||
const scannedUsers = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_active_handle", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined))
|
||||
.order("desc")
|
||||
.take(scanLimit);
|
||||
const activeUsers = scannedUsers.filter((user) => Boolean(user.handle));
|
||||
const result = buildUserSearchResults(activeUsers, normalizedSearch);
|
||||
return {
|
||||
items: result.items.slice(0, args.limit),
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(Math.trunc(value), min), max);
|
||||
}
|
||||
@@ -442,6 +467,20 @@ export const getByHandle = query({
|
||||
},
|
||||
});
|
||||
|
||||
/** Lightweight stats for user hover tooltips. Uses the skills by_owner index. */
|
||||
export const getHoverStats = query({
|
||||
args: { userId: v.id("users") },
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
|
||||
return {
|
||||
publishedSkills: user?.publishedSkills ?? 0,
|
||||
totalStars: user?.totalStars ?? 0,
|
||||
totalDownloads: user?.totalDownloads ?? 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getReservedHandleInternal = internalQuery({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -860,8 +899,7 @@ async function ensurePublisherHandleWithActor(
|
||||
|
||||
if (existing) {
|
||||
const nextDisplayName =
|
||||
args.displayName?.trim() &&
|
||||
(!existing.displayName || existing.displayName === existing.handle)
|
||||
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
|
||||
? displayName
|
||||
: existing.displayName;
|
||||
await ctx.db.patch(existing._id, {
|
||||
|
||||
@@ -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
|
||||
@@ -177,15 +180,13 @@ Stores your API token + cached registry URL.
|
||||
|
||||
### `transfer`
|
||||
|
||||
- Skill ownership transfer workflow.
|
||||
- Supports user-to-user, user-to-org, org-to-user, and org-to-org transfers.
|
||||
- Ownership transfer workflow.
|
||||
- Subcommands:
|
||||
- `transfer request <slug> <handle> [--message "..."] [--publisher @org] [--yes]`
|
||||
- `transfer request <slug> <handle> [--message "..."] [--yes]`
|
||||
- `transfer list [--outgoing]`
|
||||
- `transfer accept <slug> [--publisher @org] [--yes]`
|
||||
- `transfer accept <slug> [--yes]`
|
||||
- `transfer reject <slug> [--yes]`
|
||||
- `transfer cancel <slug> [--yes]`
|
||||
- `--publisher @org`: on request, targets the transfer to an org (recipient must be org admin to accept). On accept, assigns ownership to the org instead of the accepting user's personal publisher.
|
||||
- Endpoints:
|
||||
- `POST /api/v1/skills/{slug}/transfer`
|
||||
- `POST /api/v1/skills/{slug}/transfer/accept`
|
||||
|
||||
@@ -478,33 +478,16 @@ Notes:
|
||||
|
||||
### Transfer ownership endpoints
|
||||
|
||||
Transfers support user-to-user, user-to-org, org-to-user, and org-to-org flows for skills. Org-targeted transfers require the actor to hold `admin` or `owner` role on the relevant publisher.
|
||||
|
||||
#### Skill transfers
|
||||
|
||||
- `POST /api/v1/skills/{slug}/transfer`
|
||||
- Body: `{ "toUserHandle": "target_handle", "message": "optional", "toPublisherHandle": "optional_org_handle" }`
|
||||
- When `toPublisherHandle` is provided, the transfer targets the org. The recipient (or any org admin) accepts on behalf of the org.
|
||||
- Body: `{ "toUserHandle": "target_handle", "message": "optional" }`
|
||||
- Response: `{ "ok": true, "transferId": "skillOwnershipTransfers:...", "toUserHandle": "target_handle", "expiresAt": 1730000000000 }`
|
||||
- `POST /api/v1/skills/{slug}/transfer/accept`
|
||||
- Optional body: `{ "publisherHandle": "org_handle" }` — assign the skill to an org instead of the accepting user's personal publisher.
|
||||
- `POST /api/v1/skills/{slug}/transfer/reject`
|
||||
- `POST /api/v1/skills/{slug}/transfer/cancel`
|
||||
- Response (accept/reject/cancel): `{ "ok": true, "skillSlug": "demo-skill?" }`
|
||||
|
||||
#### Transfer listing
|
||||
|
||||
- `GET /api/v1/transfers/incoming`
|
||||
- `GET /api/v1/transfers/outgoing`
|
||||
- Returns skill transfers, sorted by `requestedAt` descending.
|
||||
- Response shape: `{ "transfers": [{ "_id": "...", "type": "skill", "skill": { "slug": "demo", "displayName": "Demo" }, "fromUser"|"toUser": { "handle": "..." }, "message": "...", "requestedAt": 0, "expiresAt": 0 }] }`
|
||||
|
||||
#### Transfer rules
|
||||
|
||||
- Pending transfers expire after 7 days.
|
||||
- Only one pending transfer per skill at a time.
|
||||
- Ownership is re-validated at accept time; if ownership changed since the request, the transfer is auto-cancelled.
|
||||
- Org transfers require `admin` or `owner` role on the source/target publisher.
|
||||
- Response shape: `{ "transfers": [{ "_id": "...", "skill": { "slug": "demo", "displayName": "Demo" }, "fromUser"|"toUser": { "handle": "..." }, "message": "...", "requestedAt": 0, "expiresAt": 0 }] }`
|
||||
|
||||
### `POST /api/v1/users/ban`
|
||||
|
||||
|
||||
@@ -325,9 +325,12 @@ Semantics:
|
||||
|
||||
## Transfer Model
|
||||
|
||||
Transfers support publisher-based flows for both skills and packages:
|
||||
Current transfers are user-to-user only. That is too narrow.
|
||||
|
||||
New transfer target should be a publisher.
|
||||
|
||||
Support:
|
||||
|
||||
- user publisher -> user publisher
|
||||
- user publisher -> org publisher
|
||||
- org publisher -> user publisher
|
||||
- org publisher -> org publisher
|
||||
@@ -336,12 +339,12 @@ Transfer acceptance rule:
|
||||
|
||||
- actor must have `owner` or `admin` on target publisher
|
||||
|
||||
Audit records:
|
||||
Audit should record:
|
||||
|
||||
- actor user id
|
||||
- source publisher id (`fromPublisherId`)
|
||||
- target publisher id (`toPublisherId`)
|
||||
- resource id (skill or package)
|
||||
- source publisher id
|
||||
- target publisher id
|
||||
- resource id
|
||||
|
||||
## Search Digest Changes
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Logo Replacement Design
|
||||
|
||||
Date: 2026-04-21
|
||||
Topic: Comprehensive logo replacement using the provided lobster artwork
|
||||
|
||||
## Summary
|
||||
|
||||
Replace every current application logo surface with the user-provided lobster artwork while preserving the existing UI layout and copy. This includes in-app logo images, favicon and install icon assets, and manifest/head wiring. The existing wide social preview image `public/og.png` remains unchanged. Instead, `public/og-logo.png` is included in the replacement asset pack as a standalone logo export and is not wired into site metadata.
|
||||
|
||||
## Goals
|
||||
|
||||
- Replace all current logo imagery with the provided lobster art.
|
||||
- Preserve existing layout structure in header, mobile navigation, and hero content.
|
||||
- Provide dedicated asset files for browser, install, and app surfaces rather than relying on one large source image everywhere.
|
||||
- Keep runtime references stable where possible by replacing existing filenames in place.
|
||||
- Improve browser/device logo behavior by adding standard favicon and touch icon variants.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No header, navigation, or hero layout redesign.
|
||||
- No typography or copy changes to the `ClawHub` wordmark text.
|
||||
- No change to the existing social preview card asset `public/og.png`.
|
||||
- No full vector redraw of the lobster artwork from scratch.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- Replace:
|
||||
- `public/clawd-logo.png`
|
||||
- `public/clawd-mark.png`
|
||||
- `public/logo192.png`
|
||||
- `public/logo512.png`
|
||||
- `public/favicon.ico`
|
||||
- Add or update:
|
||||
- `public/favicon-16x16.png`
|
||||
- `public/favicon-32x32.png`
|
||||
- `public/apple-touch-icon.png`
|
||||
- `public/logo.jpg`
|
||||
- `public/logo.svg`
|
||||
- `public/og-logo.png`
|
||||
- Update runtime/browser metadata:
|
||||
- root document link tags in `src/routes/__root.tsx`
|
||||
- `public/manifest.json`
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- `public/og.png`
|
||||
- Any route-level social metadata currently using `og.png`
|
||||
- Any non-logo artwork or unrelated illustration assets
|
||||
|
||||
## Current State
|
||||
|
||||
- The app currently references `public/clawd-logo.png` in the desktop and mobile header.
|
||||
- The homepage hero references `public/clawd-mark.png`.
|
||||
- The root document exposes `/favicon.ico`, `/logo192.png`, and `/manifest.json`.
|
||||
- The web app manifest references `favicon.ico`, `logo192.png`, and `logo512.png`.
|
||||
- The site-wide OG metadata still references `og.png`.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Use the provided lobster image as the master artwork and derive a small asset pack tailored to each output surface.
|
||||
|
||||
Why this approach:
|
||||
|
||||
- It satisfies the request to replace the logo everywhere it appears.
|
||||
- It avoids visual degradation from blindly reusing one oversized raster in tiny favicon contexts.
|
||||
- It minimizes application code changes by preserving the established filenames used by the UI.
|
||||
|
||||
## Asset Plan
|
||||
|
||||
### Master Asset
|
||||
|
||||
Create one high-resolution square source derived from the attached lobster artwork. This will be the basis for all exported formats.
|
||||
|
||||
### Replacement Assets
|
||||
|
||||
- `clawd-logo.png`
|
||||
- High-resolution square PNG used by header/mobile brand image references.
|
||||
- `clawd-mark.png`
|
||||
- High-resolution square PNG used by hero/logo-only surfaces.
|
||||
- `logo192.png`
|
||||
- 192×192 install icon.
|
||||
- `logo512.png`
|
||||
- 512×512 install icon.
|
||||
- `favicon.ico`
|
||||
- Multi-size favicon generated from the same master for browser tab use.
|
||||
- `favicon-16x16.png`
|
||||
- Explicit raster favicon for browsers that prefer PNG.
|
||||
- `favicon-32x32.png`
|
||||
- Explicit raster favicon for higher-density tab/bookmark use.
|
||||
- `apple-touch-icon.png`
|
||||
- 180×180 touch icon for iOS home screen usage.
|
||||
- `logo.jpg`
|
||||
- Flattened JPEG export for contexts where a non-transparent logo file is useful.
|
||||
- `logo.svg`
|
||||
- SVG wrapper asset that embeds the logo image in an SVG container so an SVG logo file exists for downstream usage without falsely claiming the art is natively vector.
|
||||
- `og-logo.png`
|
||||
- Logo-focused branded raster asset retained separately from the existing wide social card `og.png`.
|
||||
|
||||
## Runtime Wiring
|
||||
|
||||
### Application UI
|
||||
|
||||
- Keep existing JSX references to `clawd-logo.png` and `clawd-mark.png` unless a clearer dedicated asset path becomes necessary.
|
||||
- Do not replace image elements with text or SVG components.
|
||||
|
||||
### Root Head Tags
|
||||
|
||||
Update `src/routes/__root.tsx` to use dedicated icon assets:
|
||||
|
||||
- `rel="icon"` should include PNG favicon variants in addition to the ICO.
|
||||
- `rel="apple-touch-icon"` should point to `apple-touch-icon.png`.
|
||||
- `rel="manifest"` remains `manifest.json`.
|
||||
- OG/Twitter metadata remains wired to `og.png` and is not changed.
|
||||
|
||||
### Web App Manifest
|
||||
|
||||
Update `public/manifest.json` so install surfaces reference the replacement icon assets. Keep the manifest conservative and omit maskable-specific `purpose` values for this change.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. Start from the provided lobster artwork.
|
||||
2. Export optimized raster variants for each target size.
|
||||
3. Replace or add files in `public/`.
|
||||
4. Update root document links and manifest entries.
|
||||
5. Build the app and verify the logo surfaces still render without layout regressions.
|
||||
|
||||
## Error Handling And Risks
|
||||
|
||||
### Small-Size Legibility
|
||||
|
||||
Risk: the artwork is detailed and may lose clarity at favicon sizes.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Generate dedicated 16×16 and 32×32 outputs instead of relying only on browser downscaling.
|
||||
- Prefer the ICO plus PNG favicon set to maximize compatibility.
|
||||
|
||||
### Raster-As-Vector Expectations
|
||||
|
||||
Risk: a pure SVG redraw would be time-consuming and subjective.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Provide `logo.svg` as an SVG container asset, while using raster files for browser/runtime surfaces that need visual fidelity.
|
||||
|
||||
### Unintended Social Preview Changes
|
||||
|
||||
Risk: a broad asset refresh accidentally changes OG behavior.
|
||||
|
||||
Mitigation:
|
||||
|
||||
- Explicitly leave `og.png` and its metadata references untouched.
|
||||
- Treat `og-logo.png` as a separate logo asset only.
|
||||
|
||||
## Testing And Verification
|
||||
|
||||
- Confirm the generated files exist in `public/` with expected dimensions.
|
||||
- Run the production build to ensure asset references still resolve.
|
||||
- Spot-check the following surfaces:
|
||||
- desktop header brand image
|
||||
- mobile navigation brand image
|
||||
- homepage hero lobster image
|
||||
- browser favicon and touch icon wiring
|
||||
- manifest icon references
|
||||
- Verify that `og.png` remains unchanged and the site metadata still references it.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Use minimal code churn: replace files in place where existing paths are already correct.
|
||||
- Add new icon files only where they improve browser/device handling.
|
||||
- Keep the change tightly scoped to branding assets and metadata.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Every current application logo surface displays the provided lobster artwork instead of the previous brand image.
|
||||
- Favicon, touch icon, and install icons resolve to replacement assets.
|
||||
- Header/mobile/hero layout remains unchanged.
|
||||
- `og.png` is not modified.
|
||||
- `og-logo.png` exists as part of the updated asset pack.
|
||||
- The app builds successfully after the change.
|
||||
@@ -0,0 +1,135 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
|
||||
|
||||
// Only run in mobile projects — skip on desktop
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
test.skip(
|
||||
!testInfo.project.name.includes("mobile"),
|
||||
"mobile-only test",
|
||||
);
|
||||
});
|
||||
|
||||
test("browse page has no horizontal overflow on mobile", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
|
||||
await expect(page.locator(".skill-card, .skill-list-item").first()).toBeVisible();
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
test("browse sidebar toggle opens and closes filters", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
|
||||
|
||||
const filterButton = page.getByRole("button", { name: "Toggle filters" });
|
||||
await expect(filterButton).toBeVisible();
|
||||
|
||||
// Sidebar should be hidden initially
|
||||
const sidebar = page.locator(".browse-sidebar");
|
||||
await expect(sidebar).not.toBeVisible();
|
||||
|
||||
// Open sidebar
|
||||
await filterButton.click();
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// Close sidebar
|
||||
await filterButton.click();
|
||||
await expect(sidebar).not.toBeVisible();
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
test("card grid fits within viewport on mobile", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/skills?sort=downloads&view=cards", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".skill-card").first()).toBeVisible();
|
||||
|
||||
const card = page.locator(".skill-card").first();
|
||||
const cardBox = await card.boundingBox();
|
||||
const viewport = page.viewportSize()!;
|
||||
|
||||
// Card should not exceed viewport width
|
||||
expect(cardBox!.width).toBeLessThanOrEqual(viewport.width);
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
test("skill detail page has no horizontal overflow on mobile", async ({ page, request }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
const response = await request.get("/api/v1/skills/gifgrep");
|
||||
test.skip(!response.ok(), "gifgrep fixture missing");
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
owner?: { handle?: string | null };
|
||||
skill?: { slug?: string | null; displayName?: string | null };
|
||||
};
|
||||
const ownerHandle = payload.owner?.handle?.trim();
|
||||
const slug = payload.skill?.slug?.trim();
|
||||
test.skip(!ownerHandle || !slug || !payload.skill?.displayName, "fixture missing owner handle, slug, or displayName");
|
||||
|
||||
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.getByRole("heading", { name: payload.skill!.displayName! }),
|
||||
).toBeVisible();
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
test("detail tabs are scrollable and touch-friendly on mobile", async ({ page, request }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
const response = await request.get("/api/v1/skills/gifgrep");
|
||||
test.skip(!response.ok(), "gifgrep fixture missing");
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
owner?: { handle?: string | null };
|
||||
skill?: { slug?: string | null };
|
||||
};
|
||||
const ownerHandle = payload.owner?.handle?.trim();
|
||||
const slug = payload.skill?.slug?.trim();
|
||||
test.skip(!ownerHandle || !slug, "fixture missing");
|
||||
|
||||
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
// All standard tabs should be accessible (even if scrolled)
|
||||
for (const tabName of ["README", "Files", "Versions"]) {
|
||||
const tab = page.getByRole("button", { name: tabName });
|
||||
await tab.scrollIntoViewIfNeeded();
|
||||
await expect(tab).toBeVisible();
|
||||
|
||||
// Touch target should be at least 44px
|
||||
const box = await tab.boundingBox();
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
test("search input font size prevents iOS zoom", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const input = page.locator(".browse-search-input");
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
const fontSize = await input.evaluate((el) => getComputedStyle(el).fontSize);
|
||||
// iOS Safari zooms the page when an input has font-size below 16px
|
||||
expect(parseFloat(fontSize)).toBeGreaterThanOrEqual(16);
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
@@ -46,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",
|
||||
@@ -58,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",
|
||||
@@ -67,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",
|
||||
@@ -77,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -503,11 +503,10 @@ const transfer = program.command("transfer").description("Transfer skill ownersh
|
||||
|
||||
transfer
|
||||
.command("request")
|
||||
.description("Request skill transfer to another user or organization")
|
||||
.description("Request skill transfer to another user")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.argument("<handle>", "Recipient handle (e.g., @username)")
|
||||
.option("--message <text>", "Optional message for recipient")
|
||||
.option("--publisher <handle>", "Target org publisher handle")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.action(async (slug, handle, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
@@ -525,9 +524,8 @@ transfer
|
||||
|
||||
transfer
|
||||
.command("accept")
|
||||
.description("Accept incoming skill transfer")
|
||||
.description("Accept incoming transfer for a skill")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--publisher <handle>", "Accept to org publisher instead of personal")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
@@ -536,7 +534,7 @@ transfer
|
||||
|
||||
transfer
|
||||
.command("reject")
|
||||
.description("Reject incoming skill transfer")
|
||||
.description("Reject incoming transfer for a skill")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.action(async (slug, options) => {
|
||||
@@ -546,7 +544,7 @@ transfer
|
||||
|
||||
transfer
|
||||
.command("cancel")
|
||||
.description("Cancel outgoing skill transfer")
|
||||
.description("Cancel outgoing transfer for a skill")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.action(async (slug, options) => {
|
||||
|
||||
@@ -64,10 +64,8 @@ describe("transfer commands", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as {
|
||||
body?: Record<string, string>;
|
||||
};
|
||||
expect(requestArgs.body?.toUserHandle).toBe("alice");
|
||||
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
expect(requestArgs.body).toContain('"toUserHandle":"alice"');
|
||||
});
|
||||
|
||||
it("list calls incoming transfers endpoint", async () => {
|
||||
|
||||
@@ -63,7 +63,7 @@ export async function cmdTransferRequest(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
toHandleArg: string,
|
||||
options: ConfirmOptions & { message?: string; publisher?: string },
|
||||
options: ConfirmOptions & { message?: string },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const slug = normalizeSlug(slugArg);
|
||||
@@ -73,7 +73,7 @@ export async function cmdTransferRequest(
|
||||
const confirmed = await requireYesOrConfirm(
|
||||
options,
|
||||
inputAllowed,
|
||||
`Transfer skill "${slug}" to @${toHandle}? Recipient must accept.`,
|
||||
`Transfer ${slug} to @${toHandle}? Recipient must accept.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -82,21 +82,16 @@ export async function cmdTransferRequest(
|
||||
const spinner = createSpinner(`Requesting transfer of ${slug} to @${toHandle}`);
|
||||
|
||||
try {
|
||||
const body: Record<string, string | undefined> = {
|
||||
toUserHandle: toHandle,
|
||||
message: options.message,
|
||||
};
|
||||
if (options.publisher) {
|
||||
body.toPublisherHandle = options.publisher.replace(/^@+/, "");
|
||||
}
|
||||
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/transfer`,
|
||||
token,
|
||||
body,
|
||||
body: JSON.stringify({
|
||||
toUserHandle: toHandle,
|
||||
message: options.message,
|
||||
}),
|
||||
},
|
||||
ApiV1TransferRequestResponseSchema,
|
||||
);
|
||||
@@ -155,12 +150,11 @@ export async function cmdTransferList(opts: GlobalOpts, options: { outgoing?: bo
|
||||
async function runTransferDecision(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: ConfirmOptions & { publisher?: string },
|
||||
options: ConfirmOptions,
|
||||
inputAllowed: boolean,
|
||||
spec: DecisionSpec,
|
||||
) {
|
||||
const slug = normalizeSlug(slugArg);
|
||||
|
||||
const confirmed = await requireYesOrConfirm(
|
||||
options,
|
||||
inputAllowed,
|
||||
@@ -173,18 +167,12 @@ async function runTransferDecision(
|
||||
const spinner = createSpinner(`${spec.progress} transfer of ${slug}`);
|
||||
|
||||
try {
|
||||
const body: Record<string, string> | undefined =
|
||||
spec.action === "accept" && options.publisher
|
||||
? { publisherHandle: options.publisher.replace(/^@+/, "") }
|
||||
: undefined;
|
||||
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}/transfer/${spec.action}`,
|
||||
token,
|
||||
...(body ? { body } : {}),
|
||||
},
|
||||
ApiV1TransferDecisionResponseSchema,
|
||||
);
|
||||
@@ -200,7 +188,7 @@ async function runTransferDecision(
|
||||
export function cmdTransferAccept(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: ConfirmOptions & { publisher?: string },
|
||||
options: ConfirmOptions,
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
return runTransferDecision(opts, slugArg, options, inputAllowed, DECISION_SPECS.accept);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockSpawn = vi.fn();
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: (...args: unknown[]) => mockSpawn(...args),
|
||||
@@ -26,6 +27,26 @@ function createMockChild() {
|
||||
}
|
||||
|
||||
describe("openInBrowser", () => {
|
||||
it("uses explorer on Windows and preserves query params in the URL argument", () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValueOnce(child);
|
||||
const url =
|
||||
"https://clawhub.ai/auth?redirect_uri=http%3A%2F%2F127.0.0.1%3A43123%2Fcallback&state=abc123";
|
||||
|
||||
try {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
openInBrowser(url);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
||||
}
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith("explorer", [url], {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
expect(child.unref).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("prints manual URL instructions when browser opener is missing", () => {
|
||||
const child = createMockChild();
|
||||
mockSpawn.mockReturnValueOnce(child);
|
||||
|
||||
@@ -48,7 +48,7 @@ export function openInBrowser(url: string) {
|
||||
process.platform === "darwin"
|
||||
? ["open", url]
|
||||
: process.platform === "win32"
|
||||
? ["cmd", "/c", "start", "", url]
|
||||
? ["explorer", url]
|
||||
: ["xdg-open", url];
|
||||
const [command, ...commandArgs] = args;
|
||||
if (!command) return;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,3 +3,5 @@ export declare const TEXT_FILE_EXTENSION_SET: Set<string>;
|
||||
export declare const TEXT_CONTENT_TYPES: readonly ["application/json", "application/xml", "application/yaml", "application/x-yaml", "application/toml", "application/javascript", "application/typescript", "application/markdown", "image/svg+xml"];
|
||||
export declare const TEXT_CONTENT_TYPE_SET: Set<string>;
|
||||
export declare function isTextContentType(contentType: string): boolean;
|
||||
export declare function guessTextContentType(path: string): string | undefined;
|
||||
export declare function normalizeTextContentType(path: string, contentType?: string | null): string | undefined;
|
||||
|
||||
@@ -53,6 +53,26 @@ const RAW_TEXT_CONTENT_TYPES = [
|
||||
];
|
||||
export const TEXT_CONTENT_TYPES = RAW_TEXT_CONTENT_TYPES;
|
||||
export const TEXT_CONTENT_TYPE_SET = new Set(TEXT_CONTENT_TYPES);
|
||||
const CANONICAL_TEXT_CONTENT_TYPES = {
|
||||
md: "text/markdown",
|
||||
mdx: "text/markdown",
|
||||
txt: "text/plain",
|
||||
json: "application/json",
|
||||
json5: "application/json",
|
||||
yaml: "application/yaml",
|
||||
yml: "application/yaml",
|
||||
toml: "application/toml",
|
||||
js: "application/javascript",
|
||||
cjs: "application/javascript",
|
||||
mjs: "application/javascript",
|
||||
jsx: "application/javascript",
|
||||
ts: "application/typescript",
|
||||
mts: "application/typescript",
|
||||
cts: "application/typescript",
|
||||
tsx: "application/typescript",
|
||||
xml: "application/xml",
|
||||
svg: "image/svg+xml",
|
||||
};
|
||||
export function isTextContentType(contentType) {
|
||||
if (!contentType)
|
||||
return false;
|
||||
@@ -63,4 +83,19 @@ export function isTextContentType(contentType) {
|
||||
return true;
|
||||
return TEXT_CONTENT_TYPE_SET.has(normalized);
|
||||
}
|
||||
export function guessTextContentType(path) {
|
||||
const ext = path.trim().toLowerCase().split(".").at(-1) ?? "";
|
||||
if (!ext || !TEXT_FILE_EXTENSION_SET.has(ext))
|
||||
return undefined;
|
||||
return CANONICAL_TEXT_CONTENT_TYPES[ext] ?? "text/plain";
|
||||
}
|
||||
export function normalizeTextContentType(path, contentType) {
|
||||
const normalized = contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
||||
const guessed = guessTextContentType(path);
|
||||
if (!guessed)
|
||||
return normalized || undefined;
|
||||
if (isTextContentType(normalized))
|
||||
return normalized;
|
||||
return guessed;
|
||||
}
|
||||
//# sourceMappingURL=textFiles.js.map
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"textFiles.js","sourceRoot":"","sources":["../src/textFiles.ts"],"names":[],"mappings":"AAAA,MAAM,wBAAwB,GAAG;IAC/B,IAAI;IACJ,KAAK;IACL,KAAK;IACL,MAAM;IACN,OAAO;IACP,MAAM;IACN,KAAK;IACL,MAAM;IACN,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,OAAO;IACP,IAAI;IACJ,MAAM;IACN,IAAI;IACJ,KAAK;IACL,GAAG;IACH,GAAG;IACH,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,MAAM;IACN,KAAK;IACL,MAAM;IACN,MAAM;IACN,KAAK;CACG,CAAC;AAEX,MAAM,CAAC,MAAM,oBAAoB,GAAG,wBAAwB,CAAC;AAC7D,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAS,oBAAoB,CAAC,CAAC;AAE7E,MAAM,sBAAsB,GAAG;IAC7B,kBAAkB;IAClB,iBAAiB;IACjB,kBAAkB;IAClB,oBAAoB;IACpB,kBAAkB;IAClB,wBAAwB;IACxB,wBAAwB;IACxB,sBAAsB;IACtB,eAAe;CACP,CAAC;AAEX,MAAM,CAAC,MAAM,kBAAkB,GAAG,sBAAsB,CAAC;AACzD,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,GAAG,CAAS,kBAAkB,CAAC,CAAC;AAEzE,MAAM,UAAU,iBAAiB,CAAC,WAAmB;IACnD,IAAI,CAAC,WAAW;QAAE,OAAO,KAAK,CAAC;IAC/B,MAAM,UAAU,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IAC5E,IAAI,CAAC,UAAU;QAAE,OAAO,KAAK,CAAC;IAC9B,IAAI,UAAU,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IAChD,OAAO,qBAAqB,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;AAC/C,CAAC"}
|
||||
{"version":3,"file":"textFiles.js","sourceRoot":"","sources":["../src/textFiles.ts"],"names":[],"mappings":"AAAA,MAAM,wBAAwB,GAAG;IAC/B,IAAI;IACJ,KAAK;IACL,KAAK;IACL,MAAM;IACN,OAAO;IACP,MAAM;IACN,KAAK;IACL,MAAM;IACN,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,KAAK;IACL,KAAK;IACL,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,OAAO;IACP,IAAI;IACJ,MAAM;IACN,IAAI;IACJ,KAAK;IACL,GAAG;IACH,GAAG;IACH,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,KAAK;IACL,MAAM;IACN,KAAK;IACL,MAAM;IACN,MAAM;IACN,KAAK;CACG,CAAC;AAEX,MAAM,CAAC,MAAM,oBAAoB,GAAG,wBAAwB,CAAC;AAC7D,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAS,oBAAoB,CAAC,CAAC;AAE7E,MAAM,sBAAsB,GAAG;IAC7B,kBAAkB;IAClB,iBAAiB;IACjB,kBAAkB;IAClB,oBAAoB;IACpB,kBAAkB;IAClB,wBAAwB;IACxB,wBAAwB;IACxB,sBAAsB;IACtB,eAAe;CACP,CAAC;AAEX,MAAM,CAAC,MAAM,kBAAkB,GAAG,sBAAsB,CAAC;AACzD,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,GAAG,CAAS,kBAAkB,CAAC,CAAC;AAEzE,MAAM,4BAA4B,GAA2B;IAC3D,EAAE,EAAE,eAAe;IACnB,GAAG,EAAE,eAAe;IACpB,GAAG,EAAE,YAAY;IACjB,IAAI,EAAE,kBAAkB;IACxB,KAAK,EAAE,kBAAkB;IACzB,IAAI,EAAE,kBAAkB;IACxB,GAAG,EAAE,kBAAkB;IACvB,IAAI,EAAE,kBAAkB;IACxB,EAAE,EAAE,wBAAwB;IAC5B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,EAAE,EAAE,wBAAwB;IAC5B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,wBAAwB;IAC7B,GAAG,EAAE,iBAAiB;IACtB,GAAG,EAAE,eAAe;CACrB,CAAC;AAEF,MAAM,UAAU,iBAAiB,CAAC,WAAmB;IACnD,IAAI,CAAC,WAAW;QAAE,OAAO,KAAK,CAAC;IAC/B,MAAM,UAAU,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IAC5E,IAAI,CAAC,UAAU;QAAE,OAAO,KAAK,CAAC;IAC9B,IAAI,UAAU,CAAC,UAAU,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IAChD,OAAO,qBAAqB,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;AAC/C,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAC,IAAY;IAC/C,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC9D,IAAI,CAAC,GAAG,IAAI,CAAC,uBAAuB,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO,SAAS,CAAC;IAChE,OAAO,4BAA4B,CAAC,GAAG,CAAC,IAAI,YAAY,CAAC;AAC3D,CAAC;AAED,MAAM,UAAU,wBAAwB,CAAC,IAAY,EAAE,WAA2B;IAChF,MAAM,UAAU,GAAG,WAAW,EAAE,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IAC7E,MAAM,OAAO,GAAG,oBAAoB,CAAC,IAAI,CAAC,CAAC;IAC3C,IAAI,CAAC,OAAO;QAAE,OAAO,UAAU,IAAI,SAAS,CAAC;IAC7C,IAAI,iBAAiB,CAAC,UAAU,CAAC;QAAE,OAAO,UAAU,CAAC;IACrD,OAAO,OAAO,CAAC;AACjB,CAAC"}
|
||||
@@ -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",
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as schema from ".";
|
||||
import { isTextContentType, TEXT_FILE_EXTENSION_SET } from "./textFiles";
|
||||
import {
|
||||
guessTextContentType,
|
||||
isTextContentType,
|
||||
normalizeTextContentType,
|
||||
TEXT_FILE_EXTENSION_SET,
|
||||
} from "./textFiles";
|
||||
|
||||
describe("clawhub-schema textFiles", () => {
|
||||
it("exports text-file extension set", () => {
|
||||
@@ -16,8 +21,25 @@ describe("clawhub-schema textFiles", () => {
|
||||
expect(isTextContentType("application/octet-stream")).toBe(false);
|
||||
});
|
||||
|
||||
it("guesses canonical content types for text files", () => {
|
||||
expect(guessTextContentType("src/index.ts")).toBe("application/typescript");
|
||||
expect(guessTextContentType("README.md")).toBe("text/markdown");
|
||||
expect(guessTextContentType("image.png")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes misleading MIME types for text files", () => {
|
||||
expect(normalizeTextContentType("src/index.ts", "video/mp2t")).toBe("application/typescript");
|
||||
expect(normalizeTextContentType("README.md", "text/markdown; charset=utf-8")).toBe(
|
||||
"text/markdown",
|
||||
);
|
||||
expect(normalizeTextContentType("image.png", "image/png")).toBe("image/png");
|
||||
});
|
||||
|
||||
it("re-exports helpers from index", () => {
|
||||
expect(typeof schema.isTextContentType).toBe("function");
|
||||
expect(schema.isTextContentType("application/markdown")).toBe(true);
|
||||
expect(schema.normalizeTextContentType("src/index.ts", "video/mp2t")).toBe(
|
||||
"application/typescript",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,27 @@ const RAW_TEXT_CONTENT_TYPES = [
|
||||
export const TEXT_CONTENT_TYPES = RAW_TEXT_CONTENT_TYPES;
|
||||
export const TEXT_CONTENT_TYPE_SET = new Set<string>(TEXT_CONTENT_TYPES);
|
||||
|
||||
const CANONICAL_TEXT_CONTENT_TYPES: Record<string, string> = {
|
||||
md: "text/markdown",
|
||||
mdx: "text/markdown",
|
||||
txt: "text/plain",
|
||||
json: "application/json",
|
||||
json5: "application/json",
|
||||
yaml: "application/yaml",
|
||||
yml: "application/yaml",
|
||||
toml: "application/toml",
|
||||
js: "application/javascript",
|
||||
cjs: "application/javascript",
|
||||
mjs: "application/javascript",
|
||||
jsx: "application/javascript",
|
||||
ts: "application/typescript",
|
||||
mts: "application/typescript",
|
||||
cts: "application/typescript",
|
||||
tsx: "application/typescript",
|
||||
xml: "application/xml",
|
||||
svg: "image/svg+xml",
|
||||
};
|
||||
|
||||
export function isTextContentType(contentType: string) {
|
||||
if (!contentType) return false;
|
||||
const normalized = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
||||
@@ -64,3 +85,17 @@ export function isTextContentType(contentType: string) {
|
||||
if (normalized.startsWith("text/")) return true;
|
||||
return TEXT_CONTENT_TYPE_SET.has(normalized);
|
||||
}
|
||||
|
||||
export function guessTextContentType(path: string) {
|
||||
const ext = path.trim().toLowerCase().split(".").at(-1) ?? "";
|
||||
if (!ext || !TEXT_FILE_EXTENSION_SET.has(ext)) return undefined;
|
||||
return CANONICAL_TEXT_CONTENT_TYPES[ext] ?? "text/plain";
|
||||
}
|
||||
|
||||
export function normalizeTextContentType(path: string, contentType?: string | null) {
|
||||
const normalized = contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
||||
const guessed = guessTextContentType(path);
|
||||
if (!guessed) return normalized || undefined;
|
||||
if (isTextContentType(normalized)) return normalized;
|
||||
return guessed;
|
||||
}
|
||||
|
||||
@@ -29,5 +29,13 @@ export default defineConfig({
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "mobile-chrome",
|
||||
use: { ...devices["Pixel 7"] },
|
||||
},
|
||||
{
|
||||
name: "mobile-safari",
|
||||
use: { ...devices["iPhone 14"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 792 B |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 285 KiB |
@@ -21,5 +21,5 @@
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
"background_color": "#0a0a0a"
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 281 KiB |
@@ -1,98 +1,102 @@
|
||||
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#14110F"/>
|
||||
<stop offset="0.55" stop-color="#1A1512"/>
|
||||
<stop offset="1" stop-color="#14110F"/>
|
||||
</linearGradient>
|
||||
|
||||
<radialGradient id="glowOrange" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(260 60) rotate(120) scale(520 420)">
|
||||
<stop stop-color="#E86A47" stop-opacity="0.55"/>
|
||||
<stop offset="1" stop-color="#E86A47" stop-opacity="0"/>
|
||||
<radialGradient id="bgGlowRight" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1048 328) rotate(180) scale(358 260)">
|
||||
<stop stop-color="#7B1F18" stop-opacity="0.58"/>
|
||||
<stop offset="0.45" stop-color="#431210" stop-opacity="0.26"/>
|
||||
<stop offset="1" stop-color="#030305" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<radialGradient id="glowSea" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1050 120) rotate(140) scale(520 420)">
|
||||
<stop stop-color="#4AD8B7" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#4AD8B7" stop-opacity="0"/>
|
||||
<radialGradient id="bgGlowBottom" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(192 610) rotate(-90) scale(180 420)">
|
||||
<stop stop-color="#A12A1D" stop-opacity="0.2"/>
|
||||
<stop offset="1" stop-color="#030305" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<filter id="softBlur" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="24"/>
|
||||
</filter>
|
||||
|
||||
<filter id="cardShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="18" stdDeviation="26" flood-color="#000000" flood-opacity="0.6"/>
|
||||
</filter>
|
||||
|
||||
<linearGradient id="pill" x1="0" y1="0" x2="360" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E86A47" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#E86A47" stop-opacity="0.08"/>
|
||||
<linearGradient id="frameStroke" x1="40" y1="106" x2="1146" y2="530" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6D3437" stop-opacity="0.8"/>
|
||||
<stop offset="0.55" stop-color="#DF5D35" stop-opacity="0.36"/>
|
||||
<stop offset="1" stop-color="#FF6D39" stop-opacity="0.9"/>
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="stroke" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop stop-color="#FFFFFF" stop-opacity="0.16"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0.06"/>
|
||||
<linearGradient id="frameGlow" x1="164" y1="164" x2="1114" y2="476" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#130D10"/>
|
||||
<stop offset="0.5" stop-color="#170B0E"/>
|
||||
<stop offset="1" stop-color="#261011"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoStroke" x1="112" y1="140" x2="398" y2="430" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4C2A28" stop-opacity="0.55"/>
|
||||
<stop offset="1" stop-color="#E05831" stop-opacity="0.28"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="searchStroke" x1="146" y1="480" x2="1068" y2="480" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7F342A" stop-opacity="0.65"/>
|
||||
<stop offset="1" stop-color="#FF6F37" stop-opacity="0.85"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="buttonFill" x1="838" y1="445" x2="1084" y2="510" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D55335"/>
|
||||
<stop offset="1" stop-color="#EB6A3E"/>
|
||||
</linearGradient>
|
||||
<filter id="softBlur" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur stdDeviation="20"/>
|
||||
</filter>
|
||||
<filter id="glowBlur" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="10"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
<circle cx="260" cy="60" r="520" fill="url(#glowOrange)" filter="url(#softBlur)"/>
|
||||
<circle cx="1050" cy="120" r="520" fill="url(#glowSea)" filter="url(#softBlur)"/>
|
||||
<rect width="1200" height="630" fill="#030305"/>
|
||||
<rect width="1200" height="630" fill="url(#bgGlowRight)"/>
|
||||
<rect width="1200" height="630" fill="url(#bgGlowBottom)"/>
|
||||
|
||||
<!-- Subtle grain (very light) -->
|
||||
<g opacity="0.08">
|
||||
<path d="M0 84 C160 120 340 40 520 86 C700 132 820 210 1200 160" stroke="#FFFFFF" stroke-opacity="0.10" stroke-width="2"/>
|
||||
<path d="M0 188 C220 240 360 160 560 204 C760 248 900 330 1200 300" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="2"/>
|
||||
<path d="M0 440 C240 380 420 520 620 470 C820 420 960 500 1200 460" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="2"/>
|
||||
<g opacity="0.22">
|
||||
<circle cx="998" cy="406" r="1.8" fill="#FF7649"/>
|
||||
<circle cx="1036" cy="446" r="1.4" fill="#FF7649"/>
|
||||
<circle cx="1088" cy="492" r="1.2" fill="#FF7649"/>
|
||||
<circle cx="1116" cy="540" r="1.1" fill="#FF7649"/>
|
||||
<circle cx="964" cy="502" r="1.3" fill="#FF7649"/>
|
||||
<circle cx="880" cy="528" r="1.3" fill="#FF7649"/>
|
||||
<circle cx="716" cy="452" r="1.1" fill="#FF7649"/>
|
||||
<circle cx="622" cy="396" r="1.4" fill="#FF7649"/>
|
||||
<circle cx="188" cy="558" r="1.3" fill="#FF7649"/>
|
||||
<circle cx="152" cy="580" r="1.1" fill="#FF7649"/>
|
||||
<circle cx="92" cy="594" r="1.4" fill="#FF7649"/>
|
||||
</g>
|
||||
|
||||
<!-- Right mark -->
|
||||
<g opacity="0.22" filter="url(#softBlur)">
|
||||
<image href="clawd-mark.png" x="740" y="70" width="560" height="560" preserveAspectRatio="xMidYMid meet"/>
|
||||
</g>
|
||||
<path d="M870 146C990 166 1082 230 1142 328" stroke="#AA3D2B" stroke-opacity="0.16" stroke-width="2"/>
|
||||
<path d="M926 190C1036 234 1108 306 1168 420" stroke="#AA3D2B" stroke-opacity="0.12" stroke-width="2"/>
|
||||
<path d="M1044 374H1200" stroke="#B74A36" stroke-opacity="0.28" stroke-width="2"/>
|
||||
<path d="M24 522H164" stroke="#B74A36" stroke-opacity="0.22" stroke-width="2"/>
|
||||
|
||||
<!-- Content card -->
|
||||
<g filter="url(#cardShadow)">
|
||||
<rect x="72" y="96" width="640" height="438" rx="34" fill="#201B18" fill-opacity="0.92" stroke="url(#stroke)"/>
|
||||
</g>
|
||||
<rect x="42" y="108" width="1092" height="430" rx="42" fill="url(#frameGlow)"/>
|
||||
<rect x="42.75" y="108.75" width="1090.5" height="428.5" rx="41.25" stroke="url(#frameStroke)" stroke-width="1.5"/>
|
||||
<rect x="113" y="148" width="292" height="292" rx="38" fill="#09090C"/>
|
||||
<rect x="113.75" y="148.75" width="290.5" height="290.5" rx="37.25" stroke="url(#logoStroke)" stroke-width="1.5"/>
|
||||
|
||||
<!-- Tiny mark -->
|
||||
<image href="clawd-mark.png" x="108" y="134" width="46" height="46" preserveAspectRatio="xMidYMid meet"/>
|
||||
<ellipse cx="1118" cy="180" rx="86" ry="42" fill="#FF6532" fill-opacity="0.18" filter="url(#glowBlur)"/>
|
||||
<ellipse cx="1018" cy="328" rx="208" ry="164" fill="#8A2218" fill-opacity="0.12" filter="url(#softBlur)"/>
|
||||
<ellipse cx="96" cy="532" rx="48" ry="10" fill="#FF5E35" fill-opacity="0.24" filter="url(#softBlur)"/>
|
||||
<ellipse cx="572" cy="494" rx="302" ry="12" fill="#FF5E35" fill-opacity="0.12" filter="url(#softBlur)"/>
|
||||
|
||||
<!-- Pill -->
|
||||
<g>
|
||||
<rect x="166" y="136" width="304" height="42" rx="21" fill="url(#pill)" stroke="#E86A47" stroke-opacity="0.28"/>
|
||||
<text x="186" y="163"
|
||||
fill="#F6EFE4"
|
||||
font-size="18"
|
||||
font-weight="600"
|
||||
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif"
|
||||
opacity="0.92">lobster-light. agent-right.</text>
|
||||
</g>
|
||||
<image href="clawd-logo.png" x="124" y="158" width="270" height="270" preserveAspectRatio="xMidYMid meet"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="112" y="265"
|
||||
fill="#F6EFE4"
|
||||
font-size="92"
|
||||
font-weight="700"
|
||||
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">ClawHub</text>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text x="114" y="332"
|
||||
fill="#C6B8A8"
|
||||
font-size="23"
|
||||
font-weight="500"
|
||||
font-family="Manrope, Bricolage Grotesque, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">
|
||||
a fast skill registry for agents, with vector search.
|
||||
<text x="500" y="256" fill="#F8EEE8" font-size="88" font-weight="900" letter-spacing="-4.2" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">ClawHub.ai</text>
|
||||
<text x="500" y="338" fill="#F8EEE8" font-size="42" font-weight="800" letter-spacing="-1.2" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">Equip. Install. <tspan fill="#FF6236">Build.</tspan></text>
|
||||
<text x="500" y="390" fill="#E1D4CF" font-size="23" font-weight="500" letter-spacing="-0.15" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">
|
||||
<tspan x="500" dy="0">Developer tools and agent skills</tspan>
|
||||
<tspan x="500" dy="28">for your next project.</tspan>
|
||||
</text>
|
||||
|
||||
<!-- Accent line + hint -->
|
||||
<rect x="114" y="372" width="110" height="6" rx="3" fill="#E86A47"/>
|
||||
<text x="114" y="430"
|
||||
fill="#F6EFE4"
|
||||
font-size="20"
|
||||
font-weight="600"
|
||||
opacity="0.90"
|
||||
font-family="IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace">clawhub.ai</text>
|
||||
<g>
|
||||
<rect x="148" y="432" width="924" height="104" rx="31" fill="#11090D"/>
|
||||
<rect x="148.75" y="432.75" width="922.5" height="102.5" rx="30.25" stroke="url(#searchStroke)" stroke-width="1.5"/>
|
||||
<circle cx="220" cy="484" r="19" stroke="#FFF9F3" stroke-width="5"/>
|
||||
<line x1="233" y1="497" x2="249" y2="513" stroke="#FFF9F3" stroke-width="5" stroke-linecap="round"/>
|
||||
<text x="272" y="495" fill="#DCD0CB" font-size="31" font-weight="600" letter-spacing="-0.4" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">What are you looking for?</text>
|
||||
<rect x="824" y="450" width="258" height="66" rx="21" fill="url(#buttonFill)"/>
|
||||
<text x="953" y="494" text-anchor="middle" fill="#FFF8F1" font-size="28" font-weight="800" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">Search tools</text>
|
||||
</g>
|
||||
|
||||
<g>
|
||||
<rect x="292" y="574" width="214" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
|
||||
<text x="399" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">self-improving</text>
|
||||
<rect x="530" y="574" width="248" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
|
||||
<text x="654" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">GitHub integration</text>
|
||||
<rect x="802" y="574" width="180" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
|
||||
<text x="892" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">dashboard</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -1,6 +1,39 @@
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import { copyFile, mkdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
async function resolveExistingPath(candidates: string[]) {
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
await stat(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Try next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Missing required asset. Tried: ${candidates.join(", ")}`);
|
||||
}
|
||||
|
||||
function nodeModuleCandidates(relativePath: string) {
|
||||
return [
|
||||
path.resolve(`node_modules/${relativePath}`),
|
||||
path.resolve(`../../node_modules/${relativePath}`),
|
||||
];
|
||||
}
|
||||
|
||||
const resvgWasmSource = await resolveExistingPath(
|
||||
nodeModuleCandidates("@resvg/resvg-wasm/index_bg.wasm"),
|
||||
);
|
||||
const bricolage800Source = await resolveExistingPath(
|
||||
nodeModuleCandidates("@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2"),
|
||||
);
|
||||
const bricolage500Source = await resolveExistingPath(
|
||||
nodeModuleCandidates("@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2"),
|
||||
);
|
||||
const ibmPlex500Source = await resolveExistingPath(
|
||||
nodeModuleCandidates("@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2"),
|
||||
);
|
||||
|
||||
const copies = [
|
||||
{
|
||||
source: path.resolve("public/clawd-mark.png"),
|
||||
@@ -12,7 +45,7 @@ const copies = [
|
||||
],
|
||||
},
|
||||
{
|
||||
source: path.resolve("node_modules/@resvg/resvg-wasm/index_bg.wasm"),
|
||||
source: resvgWasmSource,
|
||||
targets: [
|
||||
path.resolve(".output/server/node_modules/@resvg/resvg-wasm/index_bg.wasm"),
|
||||
path.resolve(
|
||||
@@ -21,9 +54,7 @@ const copies = [
|
||||
],
|
||||
},
|
||||
{
|
||||
source: path.resolve(
|
||||
"node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2",
|
||||
),
|
||||
source: bricolage800Source,
|
||||
targets: [
|
||||
path.resolve(
|
||||
".output/server/node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-800-normal.woff2",
|
||||
@@ -34,9 +65,7 @@ const copies = [
|
||||
],
|
||||
},
|
||||
{
|
||||
source: path.resolve(
|
||||
"node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2",
|
||||
),
|
||||
source: bricolage500Source,
|
||||
targets: [
|
||||
path.resolve(
|
||||
".output/server/node_modules/@fontsource/bricolage-grotesque/files/bricolage-grotesque-latin-500-normal.woff2",
|
||||
@@ -47,9 +76,7 @@ const copies = [
|
||||
],
|
||||
},
|
||||
{
|
||||
source: path.resolve(
|
||||
"node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
|
||||
),
|
||||
source: ibmPlex500Source,
|
||||
targets: [
|
||||
path.resolve(
|
||||
".output/server/node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderWithInlineCode } from "../routes/about";
|
||||
|
||||
function renderToContainer(text: string) {
|
||||
const { container } = render(<p>{renderWithInlineCode(text)}</p>);
|
||||
return container.querySelector("p")!;
|
||||
}
|
||||
|
||||
describe("renderWithInlineCode", () => {
|
||||
it("returns plain text unchanged when no backticks present", () => {
|
||||
const el = renderToContainer("No code here.");
|
||||
expect(el.textContent).toBe("No code here.");
|
||||
expect(el.querySelectorAll("code")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("wraps backtick-delimited text in <code> elements", () => {
|
||||
const el = renderToContainer("Run `curl | sh` to install.");
|
||||
const codes = el.querySelectorAll("code");
|
||||
expect(codes).toHaveLength(1);
|
||||
expect(codes[0].textContent).toBe("curl | sh");
|
||||
expect(codes[0].className).toBe("about-inline-code");
|
||||
expect(el.textContent).toBe("Run curl | sh to install.");
|
||||
});
|
||||
|
||||
it("handles multiple code spans in a single string", () => {
|
||||
const el = renderToContainer(
|
||||
"Use `curl | sh` or `npx @latest` for setup."
|
||||
);
|
||||
const codes = el.querySelectorAll("code");
|
||||
expect(codes).toHaveLength(2);
|
||||
expect(codes[0].textContent).toBe("curl | sh");
|
||||
expect(codes[1].textContent).toBe("npx @latest");
|
||||
});
|
||||
|
||||
it("handles empty input string", () => {
|
||||
const el = renderToContainer("");
|
||||
expect(el.textContent).toBe("");
|
||||
expect(el.querySelectorAll("code")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles string that is only a code span", () => {
|
||||
const el = renderToContainer("`only-code`");
|
||||
const codes = el.querySelectorAll("code");
|
||||
expect(codes).toHaveLength(1);
|
||||
expect(codes[0].textContent).toBe("only-code");
|
||||
expect(el.textContent).toBe("only-code");
|
||||
});
|
||||
});
|
||||
@@ -1,92 +1,193 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import Header from "../components/Header";
|
||||
|
||||
const siteModeMock = vi.fn(() => "souls");
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
|
||||
Link: (props: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hash?: string;
|
||||
to?: string;
|
||||
}) => (
|
||||
<a
|
||||
href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`}
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/" }),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("@convex-dev/auth/react", () => ({
|
||||
useAuthActions: () => ({
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}),
|
||||
useAuthActions: () => ({
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const authStatusMock = vi.fn(() => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
}),
|
||||
useAuthStatus: () => authStatusMock(),
|
||||
}));
|
||||
|
||||
const setThemeMock = vi.fn();
|
||||
const setModeMock = vi.fn();
|
||||
|
||||
vi.mock("../lib/theme", () => ({
|
||||
applyTheme: vi.fn(),
|
||||
useThemeMode: () => ({
|
||||
mode: "system",
|
||||
setMode: vi.fn(),
|
||||
}),
|
||||
applyTheme: vi.fn(),
|
||||
THEME_OPTIONS: [
|
||||
{ value: "claw", label: "Claw", description: "" },
|
||||
{ value: "hub", label: "Hub", description: "" },
|
||||
],
|
||||
useThemeMode: () => ({
|
||||
theme: "hub",
|
||||
mode: "system",
|
||||
setTheme: setThemeMock,
|
||||
setMode: setModeMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/theme-transition", () => ({
|
||||
startThemeTransition: ({
|
||||
setTheme,
|
||||
nextTheme,
|
||||
}: {
|
||||
setTheme: (value: string) => void;
|
||||
nextTheme: string;
|
||||
}) => setTheme(nextTheme),
|
||||
startThemeTransition: ({
|
||||
setTheme,
|
||||
nextTheme,
|
||||
}: {
|
||||
setTheme: (value: string) => void;
|
||||
nextTheme: string;
|
||||
}) => setTheme(nextTheme),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthError", () => ({
|
||||
setAuthError: vi.fn(),
|
||||
useAuthError: () => ({
|
||||
error: null,
|
||||
clear: vi.fn(),
|
||||
}),
|
||||
setAuthError: vi.fn(),
|
||||
useAuthError: () => ({
|
||||
error: null,
|
||||
clear: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/roles", () => ({
|
||||
isModerator: () => false,
|
||||
isModerator: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/site", () => ({
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
getSiteMode: () => "souls",
|
||||
getSiteName: () => "OnlyCrabs",
|
||||
}));
|
||||
|
||||
vi.mock("../lib/convexError", () => ({
|
||||
getUserFacingConvexError: vi.fn(),
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
getSiteMode: () => siteModeMock(),
|
||||
getSiteName: () => "OnlyCrabs",
|
||||
}));
|
||||
|
||||
vi.mock("../lib/gravatar", () => ({
|
||||
gravatarUrl: vi.fn(),
|
||||
gravatarUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/toggle-group", () => ({
|
||||
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Header", () => {
|
||||
it("hides Packages navigation in soul mode on mobile and desktop", () => {
|
||||
render(<Header />);
|
||||
it("hides Packages navigation in soul mode on mobile and desktop", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders direct desktop theme family controls and plain Skills tab", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
setThemeMock.mockClear();
|
||||
setModeMock.mockClear();
|
||||
|
||||
render(<Header />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Cycle theme mode/i }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(1);
|
||||
expect(
|
||||
screen.getByPlaceholderText("Search skills, plugins, users"),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
|
||||
expect(setModeMock).toHaveBeenCalledWith("light");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
expect(screen.getAllByText("Home")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows Home above Skills in the mobile menu", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
|
||||
|
||||
const labels = Array.from(
|
||||
document.querySelectorAll(".mobile-nav-section .mobile-nav-link"),
|
||||
)
|
||||
.map((element) => element.textContent?.trim())
|
||||
.filter((label): label is string => Boolean(label));
|
||||
|
||||
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
|
||||
});
|
||||
|
||||
it("routes soul-mode header searches to the souls browse page", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
navigateMock.mockReset();
|
||||
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
|
||||
target: { value: "angler" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/souls",
|
||||
search: {
|
||||
q: "angler",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ const useAuthStatusMock = vi.fn();
|
||||
let useActionCallCount = 0;
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useAction: () => {
|
||||
const action = [previewImport, previewCandidate, importSkill][useActionCallCount % 3];
|
||||
|
||||
@@ -26,6 +26,7 @@ const useAuthStatusMock = vi.fn();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useMutation: () => generateUploadUrl,
|
||||
useAction: () => publishRelease,
|
||||
useQuery: () => undefined,
|
||||
@@ -276,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();
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ let loaderDataMock: {
|
||||
nextCursor: string | null;
|
||||
rateLimited: boolean;
|
||||
retryAfterSeconds: number | null;
|
||||
apiError?: boolean;
|
||||
} = {
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
@@ -72,7 +73,13 @@ describe("plugins route", () => {
|
||||
isRateLimitedPackageApiErrorMock.mockClear();
|
||||
navigateMock.mockReset();
|
||||
searchMock = {};
|
||||
loaderDataMock = { items: [], nextCursor: null, rateLimited: false, retryAfterSeconds: null };
|
||||
loaderDataMock = {
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
rateLimited: false,
|
||||
retryAfterSeconds: null,
|
||||
apiError: false,
|
||||
};
|
||||
});
|
||||
|
||||
it("rejects skill family filter in search state", async () => {
|
||||
@@ -223,6 +230,36 @@ describe("plugins route", () => {
|
||||
nextCursor: null,
|
||||
rateLimited: true,
|
||||
retryAfterSeconds: 22,
|
||||
apiError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("flags API errors for filtered catalog requests", async () => {
|
||||
fetchPluginCatalogMock.mockRejectedValue(new Error("boom"));
|
||||
const route = await loadRoute();
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
items: Array<{ name: string }>;
|
||||
nextCursor: string | null;
|
||||
rateLimited: boolean;
|
||||
retryAfterSeconds: number | null;
|
||||
apiError?: boolean;
|
||||
}>;
|
||||
|
||||
const result = await loader({
|
||||
deps: {
|
||||
q: "demo",
|
||||
executesCode: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
rateLimited: false,
|
||||
retryAfterSeconds: null,
|
||||
apiError: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,99 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
|
||||
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convex: {},
|
||||
convexHttp: { query: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { beforeLoad?: unknown }) => ({ __config: config }),
|
||||
createFileRoute: () => (config: { validateSearch?: unknown; component?: unknown }) => ({
|
||||
__config: config,
|
||||
}),
|
||||
redirect: (options: unknown) => ({ redirect: options }),
|
||||
Link: "a",
|
||||
useNavigate: () => vi.fn(),
|
||||
}));
|
||||
|
||||
import { Route } from "../routes/search";
|
||||
|
||||
function runBeforeLoad(
|
||||
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean },
|
||||
hostname = "clawdhub.com",
|
||||
) {
|
||||
function runValidateSearch(search: Record<string, unknown>) {
|
||||
const route = Route as unknown as {
|
||||
__config: {
|
||||
beforeLoad?: (args: {
|
||||
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
|
||||
location: { url: URL };
|
||||
}) => void;
|
||||
validateSearch?: (search: Record<string, unknown>) => unknown;
|
||||
};
|
||||
};
|
||||
const beforeLoad = route.__config.beforeLoad as (args: {
|
||||
search: { q?: string; highlighted?: boolean; nonSuspicious?: boolean };
|
||||
location: { url: URL };
|
||||
}) => void;
|
||||
let thrown: unknown;
|
||||
|
||||
try {
|
||||
beforeLoad({ search, location: { url: new URL(`https://${hostname}/search`) } });
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
return thrown;
|
||||
const validateSearch = route.__config.validateSearch;
|
||||
return validateSearch ? validateSearch(search) : {};
|
||||
}
|
||||
|
||||
describe("search route", () => {
|
||||
it("redirects skills host to the skills index", () => {
|
||||
expect(runBeforeLoad({ q: "crab", highlighted: true }, "clawdhub.com")).toEqual({
|
||||
redirect: {
|
||||
to: "/skills",
|
||||
search: {
|
||||
q: "crab",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: true,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
it("validates search with query", () => {
|
||||
expect(runValidateSearch({ q: "crab" })).toEqual({
|
||||
q: "crab",
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards nonSuspicious filter to skills index", () => {
|
||||
expect(runBeforeLoad({ q: "crab", nonSuspicious: true }, "clawdhub.com")).toEqual({
|
||||
redirect: {
|
||||
to: "/skills",
|
||||
search: {
|
||||
q: "crab",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
view: undefined,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
it("validates search with type filter", () => {
|
||||
expect(runValidateSearch({ q: "crab", type: "skills" })).toEqual({
|
||||
q: "crab",
|
||||
type: "skills",
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects souls host with query to home search", () => {
|
||||
expect(runBeforeLoad({ q: "crab", highlighted: true }, "onlycrabs.ai")).toEqual({
|
||||
redirect: {
|
||||
to: "/",
|
||||
search: {
|
||||
q: "crab",
|
||||
highlighted: undefined,
|
||||
search: undefined,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
it("ignores invalid type filter", () => {
|
||||
expect(runValidateSearch({ q: "crab", type: "invalid" })).toEqual({
|
||||
q: "crab",
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects souls host without query to home with search mode", () => {
|
||||
expect(runBeforeLoad({}, "onlycrabs.ai")).toEqual({
|
||||
redirect: {
|
||||
to: "/",
|
||||
search: {
|
||||
q: undefined,
|
||||
highlighted: undefined,
|
||||
search: true,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
it("accepts the users type filter", () => {
|
||||
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
|
||||
q: "vincent",
|
||||
type: "users",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips empty query", () => {
|
||||
expect(runValidateSearch({ q: " " })).toEqual({
|
||||
q: undefined,
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("has a component (not a redirect-only route)", () => {
|
||||
const route = Route as unknown as {
|
||||
__config: { component?: unknown };
|
||||
};
|
||||
expect(route.__config.component).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" | "users" } = {};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute:
|
||||
() =>
|
||||
(config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useUnifiedSearch", () => ({
|
||||
useUnifiedSearch: () => ({
|
||||
results: [],
|
||||
skillCount: 0,
|
||||
pluginCount: 0,
|
||||
userCount: 0,
|
||||
isSearching: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../components/PluginListItem", () => ({
|
||||
PluginListItem: ({ item }: { item: { name: string } }) => <div>{item.name}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/SkillListItem", () => ({
|
||||
SkillListItem: ({ skill }: { skill: { slug: string } }) => <div>{skill.slug}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/UserListItem", () => ({
|
||||
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/card", () => ({
|
||||
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/search")).Route as unknown as {
|
||||
__config: {
|
||||
component?: ComponentType;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("search route", () => {
|
||||
beforeEach(() => {
|
||||
searchMock = { q: "first" };
|
||||
navigateMock.mockReset();
|
||||
});
|
||||
|
||||
it("keeps the input synced with query param changes while mounted", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
const rendered = render(<Component />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement;
|
||||
expect(input.value).toBe("first");
|
||||
|
||||
fireEvent.change(input, { target: { value: "draft" } });
|
||||
expect(input.value).toBe("draft");
|
||||
|
||||
searchMock = { q: "second" };
|
||||
rendered.rerender(<Component />);
|
||||
|
||||
expect(
|
||||
(screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement).value,
|
||||
).toBe("second");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,18 @@ import { SkillDetailPage } from "../components/SkillDetailPage";
|
||||
const navigateMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
|
||||
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
|
||||
|
||||
|
||||
vi.mock("../components/UserBadge", () => ({
|
||||
UserBadge: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convex: {},
|
||||
convexHttp: { query: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({ children }: { children: unknown }) => children,
|
||||
useNavigate: () => navigateMock,
|
||||
@@ -15,6 +27,7 @@ const useQueryMock = vi.fn();
|
||||
const getReadmeMock = vi.fn();
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useMutation: () => vi.fn(),
|
||||
useAction: () => getReadmeMock,
|
||||
@@ -24,8 +37,8 @@ vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../components/SkillDiffCard", () => ({
|
||||
SkillDiffCard: () => <div data-testid="skill-diff-card" />,
|
||||
vi.mock("../components/SkillCommentsPanel", () => ({
|
||||
SkillCommentsPanel: () => <div data-testid="skill-comments-panel" />,
|
||||
}));
|
||||
|
||||
describe("SkillDetailPage", () => {
|
||||
@@ -58,11 +71,8 @@ describe("SkillDetailPage", () => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const { container } = render(<SkillDetailPage slug="weather" />);
|
||||
// Loading state now renders a skeleton, not text
|
||||
expect(
|
||||
container.querySelector('[class*="animate-pulse"], [data-slot="skeleton"]'),
|
||||
).toBeTruthy();
|
||||
render(<SkillDetailPage slug="weather" />);
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
|
||||
expect(screen.queryByText(/Skill not found/i)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -135,174 +145,11 @@ describe("SkillDetailPage", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// With initialData, should render content instead of skeleton
|
||||
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
|
||||
expect(screen.queryByText(/Loading skill/i)).toBeNull();
|
||||
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: "Files" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows capability tags on the skill page without other scan findings", async () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
render(
|
||||
<SkillDetailPage
|
||||
slug="skill-pay"
|
||||
initialData={{
|
||||
result: {
|
||||
skill: {
|
||||
_id: skillId,
|
||||
_creationTime: 0,
|
||||
slug: "skill-pay",
|
||||
displayName: "SkillPay",
|
||||
summary: "Crypto payments for AI skills.",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
stars: 12,
|
||||
downloads: 34,
|
||||
installsCurrent: 5,
|
||||
installsAllTime: 8,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerPublisherId,
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: ownerId,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
_creationTime: 0,
|
||||
skillId,
|
||||
version: "1.0.0",
|
||||
fingerprint: "abc",
|
||||
changelog: "Initial release",
|
||||
parsed: { license: "MIT-0", frontmatter: {} },
|
||||
capabilityTags: ["crypto", "requires-wallet", "can-make-purchases"],
|
||||
sha256hash: "abc123",
|
||||
files: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
size: 10,
|
||||
storageId,
|
||||
sha256: "abc",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
createdBy: ownerId,
|
||||
createdAt: 0,
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
},
|
||||
readme: "# SkillPay",
|
||||
readmeError: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy();
|
||||
expect(screen.getByText("Capability signals")).toBeTruthy();
|
||||
expect(screen.getByText("Crypto")).toBeTruthy();
|
||||
expect(screen.getByText("Requires wallet")).toBeTruthy();
|
||||
expect(screen.getByText("Can make purchases")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("prefers the full frontmatter description over the shortened summary in the header", async () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const fullDescription =
|
||||
"Add credit-based payments to any OpenClaw skill. Register paid skills, charge users per call, track earnings, and withdraw USDC. Use when a user wants to monetize a skill.";
|
||||
|
||||
render(
|
||||
<SkillDetailPage
|
||||
slug="skill-pay"
|
||||
initialData={{
|
||||
result: {
|
||||
skill: {
|
||||
_id: skillId,
|
||||
_creationTime: 0,
|
||||
slug: "skill-pay",
|
||||
displayName: "SkillPay",
|
||||
summary: "Add credit-based payments to any OpenClaw skill. Register paid skills...",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
stars: 12,
|
||||
downloads: 34,
|
||||
installsCurrent: 5,
|
||||
installsAllTime: 8,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerPublisherId,
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: ownerId,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
_creationTime: 0,
|
||||
skillId,
|
||||
version: "1.0.0",
|
||||
fingerprint: "abc",
|
||||
changelog: "Initial release",
|
||||
parsed: {
|
||||
license: "MIT-0",
|
||||
frontmatter: {
|
||||
description: fullDescription,
|
||||
},
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
size: 10,
|
||||
storageId,
|
||||
sha256: "abc",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
createdBy: ownerId,
|
||||
createdAt: 0,
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
},
|
||||
readme: "# SkillPay",
|
||||
readmeError: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "SkillPay" })).toBeTruthy();
|
||||
// The header now always shows skill.summary (not frontmatter.description)
|
||||
expect(
|
||||
screen.getByText("Add credit-based payments to any OpenClaw skill. Register paid skills..."),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Files" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Compare" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not refetch readme when SSR data already matches the latest version", async () => {
|
||||
@@ -375,7 +222,7 @@ describe("SkillDetailPage", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Weather" })).toBeTruthy();
|
||||
expect((await screen.findAllByRole("heading", { name: "Weather" })).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/Get current weather\./i)).toBeTruthy();
|
||||
expect(getReadmeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -394,17 +241,17 @@ describe("SkillDetailPage", () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:steipete",
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: {
|
||||
_id: "publishers:steipete",
|
||||
_creationTime: 0,
|
||||
@@ -417,9 +264,8 @@ describe("SkillDetailPage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const { container } = render(<SkillDetailPage slug="weather" redirectToCanonical />);
|
||||
// Loading state now renders a skeleton, not text
|
||||
expect(container.querySelector('[class*="animate-pulse"]')).toBeTruthy();
|
||||
render(<SkillDetailPage slug="weather" redirectToCanonical />);
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
@@ -524,7 +370,7 @@ describe("SkillDetailPage", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Skill not found/i)).toBeNull();
|
||||
expect(screen.queryByText(/Loading skill/i)).toBeNull();
|
||||
expect(screen.getAllByText("Weather").length).toBeGreaterThan(0);
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -634,10 +480,24 @@ describe("SkillDetailPage", () => {
|
||||
it("defers compare version query until compare tab is requested", async () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
"skillId" in args &&
|
||||
"limit" in args &&
|
||||
(args as { limit: number }).limit === 50
|
||||
) {
|
||||
return [
|
||||
{ _id: "skillVersions:1", version: "1.0.0", files: [] },
|
||||
{ _id: "skillVersions:2", version: "1.1.0", files: [] },
|
||||
];
|
||||
}
|
||||
if (args && typeof args === "object" && "skillId" in args && "limit" in args) {
|
||||
if ((args as { limit: number }).limit === 200) return [];
|
||||
}
|
||||
if (args && typeof args === "object" && "limit" in args) {
|
||||
return [];
|
||||
}
|
||||
if (args && typeof args === "object" && "skillId" in args) return [];
|
||||
if (args && typeof args === "object" && "slug" in args) {
|
||||
return {
|
||||
skill: {
|
||||
@@ -666,6 +526,7 @@ describe("SkillDetailPage", () => {
|
||||
|
||||
render(<SkillDetailPage slug="weather" />);
|
||||
expect(await screen.findByText("Weather")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /compare/i })).toBeTruthy();
|
||||
|
||||
expect(
|
||||
useQueryMock.mock.calls.some((call) => {
|
||||
@@ -679,9 +540,7 @@ describe("SkillDetailPage", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
const compareTab = screen.getByRole("tab", { name: /compare/i });
|
||||
fireEvent.mouseEnter(compareTab);
|
||||
fireEvent.click(compareTab);
|
||||
fireEvent.click(screen.getByRole("button", { name: /compare/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute:
|
||||
() =>
|
||||
(config: {
|
||||
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
|
||||
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
|
||||
component?: unknown;
|
||||
head?: unknown;
|
||||
@@ -31,6 +32,7 @@ vi.mock("../lib/skillPage", () => ({
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/$owner/$slug")).Route as unknown as {
|
||||
__config: {
|
||||
beforeLoad?: (args: { params: { owner: string; slug: string } }) => unknown;
|
||||
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>;
|
||||
head?: (args: {
|
||||
params: { owner: string; slug: string };
|
||||
@@ -45,6 +47,14 @@ async function loadRoute() {
|
||||
};
|
||||
}
|
||||
|
||||
async function runBeforeLoad(params: { owner: string; slug: string }) {
|
||||
const route = await loadRoute();
|
||||
const beforeLoad = route.__config.beforeLoad as ((args: {
|
||||
params: { owner: string; slug: string };
|
||||
}) => unknown) | undefined;
|
||||
return beforeLoad?.({ params });
|
||||
}
|
||||
|
||||
async function runLoader(params: { owner: string; slug: string }) {
|
||||
const route = await loadRoute();
|
||||
const loader = route.__config.loader as (args: {
|
||||
@@ -71,6 +81,18 @@ function runHead(
|
||||
}
|
||||
|
||||
describe("skill route loader", () => {
|
||||
it("allows numeric owner handles in beforeLoad", () => {
|
||||
expect(() => runBeforeLoad({ owner: "123abc", slug: "weather" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows raw owner ids in beforeLoad", () => {
|
||||
expect(() => runBeforeLoad({ owner: "users:abc123", slug: "weather" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("allows raw publisher ids in beforeLoad", () => {
|
||||
expect(() => runBeforeLoad({ owner: "publishers:abc123", slug: "weather" })).not.toThrow();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSkillPageDataMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
|
||||
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
|
||||
}));
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useAction: (...args: unknown[]) => convexReactMocks.useAction(...args),
|
||||
useQuery: (...args: unknown[]) => convexReactMocks.useQuery(...args),
|
||||
}));
|
||||
@@ -66,7 +67,7 @@ describe("SkillsIndex", () => {
|
||||
it("renders an empty state when no skills are returned", async () => {
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByText("No skills match that filter")).toBeTruthy();
|
||||
expect(screen.getByText("No skills found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows loading state before fetch completes", async () => {
|
||||
@@ -74,9 +75,9 @@ describe("SkillsIndex", () => {
|
||||
convexHttpMock.query.mockReturnValue(new Promise(() => {}));
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
// Header subtitle shows "Loading skills..."
|
||||
expect(screen.getAllByText("Loading skills...").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.queryByText("No skills match that filter")).toBeNull();
|
||||
// Results area shows skeleton or dash while loading
|
||||
expect(screen.getByText("\u2014")).toBeTruthy();
|
||||
expect(screen.queryByText("No skills found")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows empty state immediately when search returns no results", async () => {
|
||||
@@ -91,8 +92,8 @@ describe("SkillsIndex", () => {
|
||||
});
|
||||
|
||||
// Should show empty state, not loading
|
||||
expect(screen.getByText("No skills match that filter")).toBeTruthy();
|
||||
expect(screen.queryByText("Loading skills...")).toBeNull();
|
||||
expect(screen.getByText("No skills found")).toBeTruthy();
|
||||
expect(screen.queryByText(/Loading skills/)).toBeNull();
|
||||
});
|
||||
|
||||
it("skips list fetch and calls search when query is set", async () => {
|
||||
@@ -136,7 +137,7 @@ describe("SkillsIndex", () => {
|
||||
|
||||
render(<SkillsIndex />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
|
||||
const input = screen.getByPlaceholderText("Search skills...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "cli-design-framework" } });
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -161,7 +162,7 @@ describe("SkillsIndex", () => {
|
||||
|
||||
render(<SkillsIndex />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills by name, slug, or summary...");
|
||||
const input = screen.getByPlaceholderText("Search skills...");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "cli-design-framework" } });
|
||||
await vi.runAllTimersAsync();
|
||||
@@ -251,9 +252,12 @@ describe("SkillsIndex", () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links[0]?.textContent).toContain("Older High Score");
|
||||
expect(links[1]?.textContent).toContain("Newer Low Score");
|
||||
const titles = Array.from(
|
||||
document.querySelectorAll(".skill-list-item-name"),
|
||||
).map((node) => node.textContent);
|
||||
|
||||
expect(titles[0]).toBe("Older High Score");
|
||||
expect(titles[1]).toBe("Newer Low Score");
|
||||
});
|
||||
|
||||
it("passes nonSuspiciousOnly to list query when filter is active", async () => {
|
||||
@@ -288,42 +292,6 @@ describe("SkillsIndex", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes capabilityTag to list query when tag filter is active", async () => {
|
||||
searchMock = { tag: "crypto" };
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
|
||||
expect(convexHttpMock.query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
capabilityTag: "crypto",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows and clears the active capability tag filter", async () => {
|
||||
searchMock = { tag: "crypto" };
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
|
||||
const capabilityChip = screen.getByRole("button", { name: /crypto/i });
|
||||
expect(capabilityChip).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(capabilityChip);
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
|
||||
replace?: boolean;
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
};
|
||||
expect(lastCall.replace).toBe(true);
|
||||
expect(lastCall.search({ tag: "crypto" })).toEqual({
|
||||
tag: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows load-more button when more results are available", async () => {
|
||||
vi.stubGlobal("IntersectionObserver", undefined);
|
||||
convexHttpMock.query.mockResolvedValue({
|
||||
@@ -356,7 +324,7 @@ describe("SkillsIndex", () => {
|
||||
fireEvent.click(loadMoreButton);
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: "Load more" }).hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByText(/Loading/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { createRef, type ComponentProps } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SkillsToolbar } from '../routes/skills/-SkillsToolbar';
|
||||
|
||||
function renderToolbar(overrides?: Partial<ComponentProps<typeof SkillsToolbar>>) {
|
||||
return render(
|
||||
<SkillsToolbar
|
||||
searchInputRef={createRef<HTMLInputElement>()}
|
||||
query=""
|
||||
hasQuery={false}
|
||||
sort="downloads"
|
||||
dir="desc"
|
||||
view="list"
|
||||
highlightedOnly={false}
|
||||
nonSuspiciousOnly={false}
|
||||
capabilityTag={undefined}
|
||||
onQueryChange={vi.fn()}
|
||||
onToggleHighlighted={vi.fn()}
|
||||
onToggleNonSuspicious={vi.fn()}
|
||||
onCapabilityTagChange={vi.fn()}
|
||||
onSortChange={vi.fn()}
|
||||
onToggleDir={vi.fn()}
|
||||
onToggleView={vi.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('SkillsToolbar', () => {
|
||||
it('keeps filter chips on a dark-mode surface', () => {
|
||||
renderToolbar();
|
||||
|
||||
const staffPicksButton = screen.getByRole('button', { name: 'Staff Picks' });
|
||||
|
||||
expect(staffPicksButton.className).toContain('dark:bg-[rgba(14,28,37,0.84)]');
|
||||
expect(staffPicksButton.className).toContain('dark:text-[rgba(245,238,232,0.88)]');
|
||||
});
|
||||
|
||||
it('uses a readable active color treatment in dark mode', () => {
|
||||
renderToolbar({ highlightedOnly: true });
|
||||
|
||||
const staffPicksButton = screen.getByRole('button', { name: 'Staff Picks' });
|
||||
|
||||
expect(staffPicksButton.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(staffPicksButton.className).toContain('dark:bg-[rgba(255,131,95,0.14)]');
|
||||
expect(staffPicksButton.className).toContain('dark:text-[#ffd5c9]');
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ const useAuthStatusMock = vi.fn();
|
||||
let useActionCallCount = 0;
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useMutation: () => generateUploadUrl,
|
||||
useAction: () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useRef } from "react";
|
||||
import { convex } from "../convex/client";
|
||||
import { getUserFacingAuthError, normalizeAuthErrorMessage } from "../lib/authErrorMessage";
|
||||
import { clearAuthError, setAuthError } from "../lib/useAuthError";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
import { UserBootstrap } from "./UserBootstrap";
|
||||
|
||||
function getPendingAuthCode() {
|
||||
@@ -82,10 +83,12 @@ export function AuthErrorHandler() {
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
|
||||
<AuthCodeHandler />
|
||||
<AuthErrorHandler />
|
||||
<UserBootstrap />
|
||||
{children}
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<AuthCodeHandler />
|
||||
<AuthErrorHandler />
|
||||
<UserBootstrap />
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</ConvexAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Database,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import type { SkillCategory } from "../lib/categories";
|
||||
|
||||
type FilterItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type SortOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type BrowseSidebarProps = {
|
||||
categories?: SkillCategory[];
|
||||
activeCategory?: string;
|
||||
onCategoryChange?: (slug: string | undefined) => void;
|
||||
sortOptions: SortOption[];
|
||||
activeSort: string;
|
||||
onSortChange: (value: string) => void;
|
||||
filters: FilterItem[];
|
||||
onFilterToggle: (key: string) => void;
|
||||
};
|
||||
|
||||
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||
"mcp-tools": <Plug size={15} />,
|
||||
prompts: <MessageSquare size={15} />,
|
||||
workflows: <GitBranch size={15} />,
|
||||
"dev-tools": <Wrench size={15} />,
|
||||
data: <Database size={15} />,
|
||||
security: <Shield size={15} />,
|
||||
automation: <RefreshCw size={15} />,
|
||||
other: <Package size={15} />,
|
||||
};
|
||||
|
||||
export function BrowseSidebar({
|
||||
categories,
|
||||
activeCategory,
|
||||
onCategoryChange,
|
||||
sortOptions,
|
||||
activeSort,
|
||||
onSortChange,
|
||||
filters,
|
||||
onFilterToggle,
|
||||
}: BrowseSidebarProps) {
|
||||
return (
|
||||
<aside className="browse-sidebar" aria-label="Browse filters">
|
||||
<fieldset className="sidebar-section" role="radiogroup" aria-label="Sort order">
|
||||
<legend className="sidebar-title">Sort by</legend>
|
||||
{sortOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sidebar-option${activeSort === opt.value ? " is-active" : ""}`}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={activeSort === opt.value}
|
||||
onClick={() => onSortChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
{categories && onCategoryChange ? (
|
||||
<fieldset className="sidebar-section" role="radiogroup" aria-label="Category filter">
|
||||
<legend className="sidebar-title">Categories</legend>
|
||||
<button
|
||||
className={`sidebar-option${!activeCategory ? " is-active" : ""}`}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={!activeCategory}
|
||||
onClick={() => onCategoryChange(undefined)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.slug}
|
||||
className={`sidebar-option${activeCategory === cat.slug ? " is-active" : ""}`}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={activeCategory === cat.slug}
|
||||
onClick={() => onCategoryChange(cat.slug)}
|
||||
>
|
||||
<span className="sidebar-option-icon" aria-hidden="true">
|
||||
{CATEGORY_ICONS[cat.slug]}
|
||||
</span>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
<fieldset className="sidebar-section" aria-label="Toggle filters">
|
||||
<legend className="sidebar-title">Filters</legend>
|
||||
{filters.map((f) => (
|
||||
<label key={f.key} className="sidebar-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={f.active}
|
||||
onChange={() => onFilterToggle(f.key)}
|
||||
aria-label={f.label}
|
||||
/>
|
||||
<span>{f.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -69,17 +69,7 @@ function DeploymentDriftBannerContent() {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
margin: "16px auto 0",
|
||||
width: "min(1100px, calc(100vw - 32px))",
|
||||
border: "1px solid #f59e0b",
|
||||
background: "#fff7ed",
|
||||
color: "#9a3412",
|
||||
borderRadius: "14px",
|
||||
padding: "12px 16px",
|
||||
fontSize: "0.95rem",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
className="mx-auto mt-4 w-[min(1100px,calc(100vw-32px))] rounded-[14px] border border-status-warning-fg/40 bg-status-warning-bg px-4 py-3 text-[0.95rem] leading-[1.4] text-status-warning-fg"
|
||||
>
|
||||
Deploy mismatch detected. Frontend expects backend build <code>{drift.expectedBuildSha}</code>{" "}
|
||||
but Convex reports <code>{drift.actualBuildSha}</code>.
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
import { getSiteName } from "../lib/site";
|
||||
import { Separator } from "./ui/separator";
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { FOOTER_NAV_SECTIONS } from "../lib/nav-items";
|
||||
export function Footer() {
|
||||
const siteName = getSiteName();
|
||||
return (
|
||||
<footer className="mt-auto px-7 pb-8 pt-12">
|
||||
<div className="mx-auto max-w-[1200px]">
|
||||
<Separator className="mb-6" />
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[0.82rem] text-[color:var(--ink-soft)]">
|
||||
<span className="font-semibold text-[color:var(--ink)]">{siteName}</span>
|
||||
<FooterLink href="https://openclaw.ai">OpenClaw</FooterLink>
|
||||
<FooterLink href="https://vercel.com">Vercel</FooterLink>
|
||||
<FooterLink href="https://www.convex.dev">Convex</FooterLink>
|
||||
<FooterLink href="https://github.com/openclaw/clawhub">Open source (MIT)</FooterLink>
|
||||
<FooterLink href="https://steipete.me">Peter Steinberger</FooterLink>
|
||||
<footer className="site-footer" role="contentinfo">
|
||||
<div className="site-footer-inner">
|
||||
<div className="footer-grid">
|
||||
{FOOTER_NAV_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="footer-col">
|
||||
<h4 className="footer-col-title">{section.title}</h4>
|
||||
{section.items.filter((item) => item.featureFlag !== false).map((item) => {
|
||||
if (item.kind === "link") {
|
||||
return (
|
||||
<Link key={item.label} to={item.to} search={item.search ?? {}}>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (item.kind === "external") {
|
||||
return (
|
||||
<a key={item.label} href={item.href} target="_blank" rel="noreferrer">
|
||||
{item.label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// kind === "text"
|
||||
return <span key={item.label}>{item.label}</span>;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[color:var(--ink-soft)] transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Menu, Monitor, Moon, Plus, Search, Sun } from "lucide-react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
|
||||
import { type ComponentType, useMemo, useRef, useState } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import { gravatarUrl } from "../lib/gravatar";
|
||||
import {
|
||||
filterNavItems,
|
||||
type NavIconName,
|
||||
PRIMARY_NAV_ITEMS,
|
||||
SECONDARY_NAV_ITEMS,
|
||||
} from "../lib/nav-items";
|
||||
import { isModerator } from "../lib/roles";
|
||||
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
|
||||
import { applyTheme, useThemeMode } from "../lib/theme";
|
||||
import { startThemeTransition } from "../lib/theme-transition";
|
||||
import { useAuthError } from "../lib/useAuthError";
|
||||
import { SignInButton } from "./SignInButton";
|
||||
import { setAuthError, useAuthError } from "../lib/useAuthError";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -19,309 +24,406 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "./ui/sheet";
|
||||
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
|
||||
|
||||
const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?: string }>> = {
|
||||
wrench: Wrench,
|
||||
plug: Plug,
|
||||
ghost: Ghost,
|
||||
};
|
||||
|
||||
const THEME_MODE_SEQUENCE: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
|
||||
|
||||
export default function Header() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
const { signOut } = useAuthActions();
|
||||
const { mode, setMode } = useThemeMode();
|
||||
const { signIn, signOut } = useAuthActions();
|
||||
const { theme, mode, setMode } = useThemeMode();
|
||||
const toggleRef = useRef<HTMLDivElement | null>(null);
|
||||
const siteMode = getSiteMode();
|
||||
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
|
||||
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";
|
||||
const initial = (me?.displayName ?? me?.name ?? handle).charAt(0).toUpperCase();
|
||||
const isStaff = isModerator(me);
|
||||
const hasResolvedUser = Boolean(me);
|
||||
const navCtx = useMemo(
|
||||
() => ({ isSoulMode, isAuthenticated: hasResolvedUser, isStaff }),
|
||||
[hasResolvedUser, isSoulMode, isStaff],
|
||||
);
|
||||
const primaryItems = useMemo(() => filterNavItems(PRIMARY_NAV_ITEMS, navCtx), [navCtx]);
|
||||
const secondaryItems = useMemo(() => filterNavItems(SECONDARY_NAV_ITEMS, navCtx), [navCtx]);
|
||||
const { error: authError, clear: clearAuthError } = useAuthError();
|
||||
const signInRedirectTo = getCurrentRelativeUrl();
|
||||
|
||||
const setTheme = (next: "system" | "light" | "dark") => {
|
||||
const [navSearchQuery, setNavSearchQuery] = useState("");
|
||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const ThemeModeIcon = getThemeModeIcon(mode);
|
||||
|
||||
const setThemeMode = (next: "system" | "light" | "dark") => {
|
||||
startThemeTransition({
|
||||
nextTheme: next,
|
||||
currentTheme: mode,
|
||||
setTheme: (value) => {
|
||||
const nextMode = value as "system" | "light" | "dark";
|
||||
applyTheme(nextMode);
|
||||
applyTheme(nextMode, theme);
|
||||
setMode(nextMode);
|
||||
},
|
||||
context: { element: toggleRef.current },
|
||||
});
|
||||
};
|
||||
|
||||
const navLinks = (
|
||||
<>
|
||||
{isSoulMode ? (
|
||||
<a
|
||||
href={clawHubUrl}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
ClawHub
|
||||
</a>
|
||||
) : null}
|
||||
{isSoulMode ? (
|
||||
<Link
|
||||
to="/souls"
|
||||
search={{
|
||||
q: undefined,
|
||||
const cycleThemeMode = () => {
|
||||
const currentIndex = Math.max(0, THEME_MODE_SEQUENCE.indexOf(mode));
|
||||
const nextMode = THEME_MODE_SEQUENCE[(currentIndex + 1) % THEME_MODE_SEQUENCE.length] ?? "system";
|
||||
setThemeMode(nextMode);
|
||||
};
|
||||
|
||||
const handleNavSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = navSearchQuery.trim();
|
||||
if (!q) return;
|
||||
void navigate({
|
||||
to: isSoulMode ? "/souls" : "/search",
|
||||
search: isSoulMode
|
||||
? {
|
||||
q,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Souls
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
}}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Skills
|
||||
</Link>
|
||||
)}
|
||||
{isSoulMode ? null : (
|
||||
<Link
|
||||
to="/plugins"
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Plugins
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={isSoulMode ? "/souls" : "/skills"}
|
||||
search={
|
||||
isSoulMode
|
||||
? { q: undefined, sort: undefined, dir: undefined, view: undefined, focus: "search" }
|
||||
: {
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: undefined,
|
||||
view: undefined,
|
||||
focus: "search",
|
||||
}
|
||||
}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)] inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
Search
|
||||
</Link>
|
||||
{isSoulMode ? null : (
|
||||
<Link
|
||||
to="/about"
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
)}
|
||||
{me ? (
|
||||
<Link
|
||||
to="/stars"
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Stars
|
||||
</Link>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<Link
|
||||
to="/management"
|
||||
search={{ skill: undefined }}
|
||||
className="text-[color:var(--ink-soft)] font-semibold text-sm transition-colors duration-150 hover:text-[color:var(--ink)]"
|
||||
>
|
||||
Management
|
||||
</Link>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
: { q, type: undefined },
|
||||
});
|
||||
setNavSearchQuery("");
|
||||
setMobileSearchOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-[color:var(--line)] bg-[color:var(--nav-bg)] backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-16 max-w-[1200px] items-center justify-between gap-4 px-5">
|
||||
{/* Brand */}
|
||||
<Link
|
||||
to="/"
|
||||
search={{ q: undefined, highlighted: undefined, search: undefined }}
|
||||
className="flex items-center gap-2.5 font-display text-lg font-bold text-[color:var(--ink)] no-underline transition-opacity hover:opacity-80"
|
||||
>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[color:var(--accent)] to-[color:var(--accent-deep)] p-0.5">
|
||||
<img
|
||||
src="/clawd-logo.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</span>
|
||||
<span>{siteName}</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden items-center gap-6 md:flex">{navLinks}</nav>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Publish CTA (desktop, authenticated) */}
|
||||
{isAuthenticated && me && (
|
||||
<Link
|
||||
to="/publish-skill"
|
||||
search={{ updateSlug: undefined }}
|
||||
className="hidden sm:block"
|
||||
>
|
||||
<Button variant="primary" size="sm">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Publish
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Mobile nav trigger */}
|
||||
<div className="md:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="Open menu">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-72">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{siteName}</SheetTitle>
|
||||
<header className="navbar">
|
||||
<div className="navbar-inner">
|
||||
{/* Row 1: Brand + Search + Actions */}
|
||||
<div className="navbar-top">
|
||||
<div className="nav-mobile">
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<button
|
||||
className="nav-mobile-trigger"
|
||||
type="button"
|
||||
aria-label="Open menu"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<SheetContent side="left" className="mobile-nav-sheet">
|
||||
<SheetHeader className="pr-10">
|
||||
<SheetTitle>
|
||||
<span className="mobile-nav-brand">
|
||||
<span className="mobile-nav-brand-mark" aria-hidden="true">
|
||||
<img
|
||||
src="/clawd-logo.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="mobile-nav-brand-mark-image"
|
||||
/>
|
||||
</span>
|
||||
<span className="mobile-nav-brand-name">{siteName}</span>
|
||||
</span>
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Browse sections, switch theme, and access account actions.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-4">{navLinks}</nav>
|
||||
{/* Mobile theme toggle */}
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-[color:var(--ink-soft)]">
|
||||
Theme
|
||||
</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setTheme(value as "system" | "light" | "dark");
|
||||
}}
|
||||
aria-label="Theme mode"
|
||||
>
|
||||
<ToggleGroupItem value="system" aria-label="System theme">
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light" aria-label="Light theme">
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Dark theme">
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{/* Mobile publish link */}
|
||||
{isAuthenticated && me && (
|
||||
<div className="mt-6">
|
||||
<Link to="/publish-skill" search={{ updateSlug: undefined }}>
|
||||
<Button variant="primary" className="w-full">
|
||||
<Plus className="h-4 w-4" />
|
||||
Publish Skill
|
||||
</Button>
|
||||
<div className="mobile-nav-section">
|
||||
<SheetClose asChild>
|
||||
<Link to="/" className="mobile-nav-link">
|
||||
Home
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</SheetClose>
|
||||
{isSoulMode ? (
|
||||
<SheetClose asChild>
|
||||
<a href={clawHubUrl} className="mobile-nav-link">
|
||||
ClawHub
|
||||
</a>
|
||||
</SheetClose>
|
||||
) : null}
|
||||
{primaryItems.map((item) => (
|
||||
<SheetClose key={item.to + item.label} asChild>
|
||||
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
|
||||
{item.label}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
{secondaryItems.map((item) => (
|
||||
<SheetClose key={item.to + item.label} asChild>
|
||||
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
|
||||
{item.label === "Management" ? "Manage" : item.label}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
</div>
|
||||
<div className="mobile-nav-section">
|
||||
<div className="mobile-nav-section-title">Theme mode</div>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("system");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
System
|
||||
</button>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("light");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("dark");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{/* Desktop theme toggle */}
|
||||
<div className="theme-toggle hidden md:block" ref={toggleRef}>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setTheme(value as "system" | "light" | "dark");
|
||||
}}
|
||||
aria-label="Theme mode"
|
||||
>
|
||||
<ToggleGroupItem value="system" aria-label="System theme">
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light" aria-label="Light theme">
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Dark theme">
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<Link
|
||||
to="/"
|
||||
search={{ q: undefined, highlighted: undefined, search: undefined }}
|
||||
className="brand"
|
||||
>
|
||||
<span className="brand-mark">
|
||||
<img src="/clawd-logo.png" alt="" aria-hidden="true" className="brand-mark-image" />
|
||||
</span>
|
||||
<span className="brand-name brand-name-responsive">{siteName}</span>
|
||||
</Link>
|
||||
|
||||
{/* User menu / Sign in */}
|
||||
{isAuthenticated && me ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<form className="navbar-search" onSubmit={handleNavSearch} role="search" aria-label="Site search">
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="search"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => setNavSearchQuery(e.target.value)}
|
||||
aria-label="Search"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="nav-actions">
|
||||
<button
|
||||
className="navbar-search-mobile-trigger"
|
||||
type="button"
|
||||
aria-label="Search"
|
||||
onClick={() => setMobileSearchOpen(!mobileSearchOpen)}
|
||||
>
|
||||
<Search size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="theme-toggle" ref={toggleRef}>
|
||||
<div className="theme-cycle-group" aria-label="Theme controls">
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-2 rounded-full border border-[color:var(--line)] bg-[color:var(--surface)] px-2 py-1.5 text-sm font-semibold text-[color:var(--ink)] transition-colors hover:border-[color:var(--border-ui-hover)]"
|
||||
className="theme-cycle-button theme-cycle-button-mode"
|
||||
onClick={cycleThemeMode}
|
||||
aria-label={`Cycle theme mode. Current: ${mode}`}
|
||||
title={`Theme mode: ${mode}`}
|
||||
>
|
||||
<Avatar className="h-7 w-7">
|
||||
{avatar && (
|
||||
<AvatarImage src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs">{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden font-mono text-xs sm:inline">@{handle}</span>
|
||||
<span className="text-xs text-[color:var(--ink-soft)]">▾</span>
|
||||
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard">Dashboard</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings">Settings</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<>
|
||||
{authError ? (
|
||||
<div
|
||||
className="flex items-center gap-1 text-[0.85rem] text-red-600 dark:text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
{authError}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAuthError}
|
||||
aria-label="Dismiss"
|
||||
className="ml-1 cursor-pointer border-none bg-transparent p-0.5 text-inherit opacity-70 hover:opacity-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<SignInButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
</div>
|
||||
<ToggleGroup
|
||||
className="theme-mode-toggle"
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setThemeMode(value as "system" | "light" | "dark");
|
||||
}}
|
||||
aria-label="Theme mode"
|
||||
>
|
||||
<span>Sign in</span>
|
||||
<span className="hidden text-white/70 sm:inline">with GitHub</span>
|
||||
</SignInButton>
|
||||
</>
|
||||
)}
|
||||
<ToggleGroupItem value="system" aria-label="System theme">
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">System</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light" aria-label="Light theme">
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Light</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Dark theme">
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Dark</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{isAuthenticated && me ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="user-trigger" type="button">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt={me.displayName ?? me.name ?? "User avatar"} />
|
||||
) : (
|
||||
<span className="user-menu-fallback">{initial}</span>
|
||||
)}
|
||||
<span className="mono">@{handle}</span>
|
||||
<span className="user-menu-chevron">▾</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/dashboard">Dashboard</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings">Settings</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => void signOut()}>Sign out</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<>
|
||||
{authError ? (
|
||||
<div className="error mr-2 text-[0.85rem]" role="alert">
|
||||
{authError}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAuthError}
|
||||
aria-label="Dismiss"
|
||||
className="cursor-pointer border-none bg-transparent px-0.5 py-0 text-inherit"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
clearAuthError();
|
||||
void signIn(
|
||||
"github",
|
||||
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
|
||||
).catch((error) => {
|
||||
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Github size={16} aria-hidden="true" />
|
||||
<span className="sign-in-label">Sign in</span>
|
||||
<span className="sign-in-provider">with GitHub</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile search bar (expandable) */}
|
||||
{mobileSearchOpen ? (
|
||||
<form className="navbar-search-mobile" onSubmit={handleNavSearch}>
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="text"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => setNavSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{/* Row 2: Content type tabs */}
|
||||
<nav className="navbar-tabs" aria-label="Content types">
|
||||
<div className="navbar-tabs-primary">
|
||||
{isSoulMode ? (
|
||||
<a href={clawHubUrl} className="navbar-tab">
|
||||
ClawHub
|
||||
</a>
|
||||
) : 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}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="navbar-tabs-secondary">
|
||||
{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>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentRelativeUrl() {
|
||||
if (typeof window === "undefined") return "/";
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
function getThemeModeIcon(mode: "system" | "light" | "dark") {
|
||||
switch (mode) {
|
||||
case "light":
|
||||
return Sun;
|
||||
case "dark":
|
||||
return Moon;
|
||||
case "system":
|
||||
default:
|
||||
return Monitor;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function InstallSwitcher({ exampleSlug = "sonoscli" }: InstallSwitcherPro
|
||||
type="button"
|
||||
className={`cursor-pointer rounded-full border-none px-3 py-1.5 text-xs font-semibold transition-all duration-200 ${
|
||||
pm === entry.id
|
||||
? "bg-[color:var(--accent)] text-white shadow-sm"
|
||||
? "bg-accent text-accent-fg shadow-sm"
|
||||
: "bg-transparent text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
|
||||
}`}
|
||||
role="tab"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { FileText, Package, Plug, User } from "lucide-react";
|
||||
|
||||
type MarketplaceIconProps = {
|
||||
kind: "skill" | "plugin" | "soul" | "user";
|
||||
label: string;
|
||||
imageUrl?: string | null;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
const TONES = [
|
||||
{ accent: "oklch(0.63 0.16 42)", wash: "oklch(0.95 0.04 42)" },
|
||||
{ accent: "oklch(0.61 0.15 168)", wash: "oklch(0.95 0.04 168)" },
|
||||
{ accent: "oklch(0.59 0.14 236)", wash: "oklch(0.95 0.04 236)" },
|
||||
{ accent: "oklch(0.66 0.13 92)", wash: "oklch(0.96 0.04 92)" },
|
||||
] as const;
|
||||
|
||||
function hashTone(label: string) {
|
||||
let sum = 0;
|
||||
for (const char of label) sum += char.charCodeAt(0);
|
||||
return TONES[sum % TONES.length] ?? TONES[0];
|
||||
}
|
||||
|
||||
function getIcon(kind: MarketplaceIconProps["kind"]) {
|
||||
switch (kind) {
|
||||
case "plugin":
|
||||
return Plug;
|
||||
case "soul":
|
||||
return FileText;
|
||||
case "user":
|
||||
return User;
|
||||
default:
|
||||
return Package;
|
||||
}
|
||||
}
|
||||
|
||||
export function MarketplaceIcon({
|
||||
kind,
|
||||
label,
|
||||
imageUrl,
|
||||
size = "sm",
|
||||
}: MarketplaceIconProps) {
|
||||
const Icon = getIcon(kind);
|
||||
const tone = hashTone(label);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`marketplace-icon marketplace-icon-${size}`}
|
||||
style={
|
||||
{
|
||||
"--marketplace-icon-accent": tone.accent,
|
||||
"--marketplace-icon-wash": tone.wash,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img className="marketplace-icon-image" src={imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<Icon className="marketplace-icon-glyph" strokeWidth={1.8} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { familyLabel } from "../lib/packageLabels";
|
||||
import type { PackageListItem } from "../lib/packageApi";
|
||||
|
||||
type PluginListItemProps = {
|
||||
item: PackageListItem;
|
||||
};
|
||||
|
||||
export function PluginListItem({ item }: PluginListItemProps) {
|
||||
return (
|
||||
<Link to="/plugins/$name" params={{ name: item.name }} className="skill-list-item" aria-label={`Plugin: ${item.displayName}`}>
|
||||
<MarketplaceIcon kind="plugin" label={item.displayName} />
|
||||
<div className="skill-list-item-body">
|
||||
<div className="skill-list-item-main">
|
||||
{item.ownerHandle ? (
|
||||
<>
|
||||
<span className="skill-list-item-owner">@{item.ownerHandle}</span>
|
||||
<span className="skill-list-item-sep">/</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="skill-list-item-name">{item.displayName}</span>
|
||||
<Badge variant="compact">{familyLabel(item.family)}</Badge>
|
||||
{item.isOfficial ? <Badge variant="accent">Verified</Badge> : null}
|
||||
</div>
|
||||
<p className="skill-list-item-summary">{item.summary ?? "Plugin package for agent workflows."}</p>
|
||||
<div className="skill-list-item-meta">
|
||||
<span className="skill-list-item-meta-item">Plugin</span>
|
||||
{item.latestVersion ? (
|
||||
<span className="skill-list-item-meta-item">v{item.latestVersion}</span>
|
||||
) : null}
|
||||
<span className="skill-list-item-meta-item">
|
||||
{item.ownerHandle ? `@${item.ownerHandle}` : "community"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import { Badge } from "./ui/badge";
|
||||
import type { PublicSkill } from "../lib/publicUser";
|
||||
|
||||
type SkillCardProps = {
|
||||
skill: PublicSkill;
|
||||
@@ -12,7 +12,6 @@ type SkillCardProps = {
|
||||
summaryFallback: string;
|
||||
meta: ReactNode;
|
||||
href?: string;
|
||||
verified?: boolean;
|
||||
};
|
||||
|
||||
export function SkillCard({
|
||||
@@ -23,7 +22,6 @@ export function SkillCard({
|
||||
summaryFallback,
|
||||
meta,
|
||||
href,
|
||||
verified,
|
||||
}: SkillCardProps) {
|
||||
const owner = encodeURIComponent(String(skill.ownerUserId));
|
||||
const link = href ?? `/${owner}/${skill.slug}`;
|
||||
@@ -31,43 +29,28 @@ export function SkillCard({
|
||||
const hasTags = badges.length || chip || platformLabels?.length;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={link}
|
||||
className="group flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[22px] no-underline transition-all duration-200 ease-out hover:-translate-y-0.5 hover:shadow-[0_12px_28px_rgba(29,26,23,0.12)] hover:border-[color:var(--border-ui-hover)]"
|
||||
>
|
||||
<Link to={link} className="card skill-card">
|
||||
{hasTags ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div className="skill-card-tags">
|
||||
{badges.map((label) => (
|
||||
<Badge key={label} variant="default">
|
||||
<Badge key={label}>
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
{chip ? (
|
||||
<Badge variant="accent" className="text-[0.72rem] px-2.5 py-0.5">
|
||||
{chip}
|
||||
</Badge>
|
||||
) : null}
|
||||
{chip ? <Badge variant="accent">{chip}</Badge> : null}
|
||||
{platformLabels?.map((label) => (
|
||||
<Badge key={label} variant="compact">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
{verified && (
|
||||
<span className="inline-flex items-center gap-1 text-[0.72rem] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<h3 className="font-display text-base font-bold leading-tight text-[color:var(--ink)] group-hover:text-[color:var(--accent)]">
|
||||
{skill.displayName}
|
||||
</h3>
|
||||
<p className="line-clamp-2 text-sm leading-relaxed text-[color:var(--ink-soft)]">
|
||||
{skill.summary ?? summaryFallback}
|
||||
</p>
|
||||
<div className="mt-auto flex flex-col gap-2 pt-1 text-[0.82rem] text-[color:var(--ink-soft)]">
|
||||
{meta}
|
||||
<div className="skill-card-header">
|
||||
<MarketplaceIcon kind="skill" label={skill.displayName} size="md" />
|
||||
<h3 className="skill-card-title">{skill.displayName}</h3>
|
||||
</div>
|
||||
<p className="skill-card-summary">{skill.summary ?? summaryFallback}</p>
|
||||
<div className="skill-card-footer">{meta}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export function SkillCommentsPanel({ skillId, isAuthenticated, me }: SkillCommen
|
||||
comments.map((entry) => (
|
||||
<div
|
||||
key={entry.comment._id}
|
||||
className="flex gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3"
|
||||
className="comment-entry flex gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<strong className="text-sm">
|
||||
|
||||
@@ -2,19 +2,16 @@ import { useNavigate } from "@tanstack/react-router";
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { canManageSkill, isModerator } from "../lib/roles";
|
||||
import { hasOwnProperty } from "../lib/hasOwnProperty";
|
||||
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { Card } from "./ui/card";
|
||||
import { ClientOnly } from "./ClientOnly";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { Container } from "./layout/Container";
|
||||
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
|
||||
import { SkillCommentsPanel } from "./SkillCommentsPanel";
|
||||
import { SkillDetailTabs } from "./SkillDetailTabs";
|
||||
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
|
||||
import { SkillMetadataSidebar } from "./SkillMetadataSidebar";
|
||||
import {
|
||||
buildSkillHref,
|
||||
formatConfigSnippet,
|
||||
@@ -25,8 +22,6 @@ import {
|
||||
import { SkillHeader } from "./SkillHeader";
|
||||
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
|
||||
import { SkillReportDialog } from "./SkillReportDialog";
|
||||
import { Card } from "./ui/card";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
type SkillDetailPageProps = {
|
||||
slug: string;
|
||||
@@ -38,11 +33,13 @@ type SkillDetailPageProps = {
|
||||
type SkillFile = Doc<"skillVersions">["files"][number];
|
||||
|
||||
function formatReportError(error: unknown) {
|
||||
if (hasOwnProperty(error, "data")) {
|
||||
if (error && typeof error === "object" && "data" in error) {
|
||||
const data = (error as { data?: unknown }).data;
|
||||
if (typeof data === "string" && data.trim()) return data.trim();
|
||||
if (
|
||||
hasOwnProperty(data, "message") &&
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"message" in data &&
|
||||
typeof (data as { message?: unknown }).message === "string"
|
||||
) {
|
||||
const message = (data as { message?: string }).message?.trim();
|
||||
@@ -98,7 +95,7 @@ export function SkillDetailPage({
|
||||
);
|
||||
const [tagName, setTagName] = useState("latest");
|
||||
const [tagVersionId, setTagVersionId] = useState<Id<"skillVersions"> | "">("");
|
||||
const [activeTab, setActiveTab] = useState<"files" | "compare" | "versions">("files");
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("readme");
|
||||
const [shouldPrefetchCompare, setShouldPrefetchCompare] = useState(false);
|
||||
const [isReportDialogOpen, setIsReportDialogOpen] = useState(false);
|
||||
const [reportReason, setReportReason] = useState("");
|
||||
@@ -215,7 +212,6 @@ export function SkillDetailPage({
|
||||
?.clawdis;
|
||||
const osLabels = useMemo(() => formatOsList(clawdis?.os), [clawdis?.os]);
|
||||
const nixPlugin = clawdis?.nix?.plugin;
|
||||
const nixSystems = clawdis?.nix?.systems ?? [];
|
||||
const nixSnippet = nixPlugin ? formatNixInstallSnippet(nixPlugin) : null;
|
||||
const configRequirements = clawdis?.config;
|
||||
const configExample = configRequirements?.example
|
||||
@@ -299,13 +295,10 @@ export function SkillDetailPage({
|
||||
|
||||
const deleteTag = (tag: string) => {
|
||||
if (!skill) return;
|
||||
toast(`Delete tag "${tag}"?`, {
|
||||
action: {
|
||||
label: "Delete",
|
||||
onClick: () => {
|
||||
void deleteTags({ skillId: skill._id, tags: [tag] });
|
||||
},
|
||||
},
|
||||
if (!window.confirm(`Delete tag "${tag}"?`)) return;
|
||||
void deleteTags({
|
||||
skillId: skill._id,
|
||||
tags: [tag],
|
||||
});
|
||||
};
|
||||
|
||||
@@ -324,9 +317,9 @@ export function SkillDetailPage({
|
||||
const submission = await reportSkill({ skillId: skill._id, reason: trimmedReason });
|
||||
closeReportDialog();
|
||||
if (submission.reported) {
|
||||
toast.success("Thanks — your report has been submitted.");
|
||||
window.alert("Thanks — your report has been submitted.");
|
||||
} else {
|
||||
toast.info("You have already reported this skill.");
|
||||
window.alert("You have already reported this skill.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to report skill", error);
|
||||
@@ -336,19 +329,19 @@ export function SkillDetailPage({
|
||||
};
|
||||
|
||||
if (isLoadingSkill || wantsCanonicalRedirect) {
|
||||
return <SkillDetailSkeleton />;
|
||||
return (
|
||||
<main className="section">
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading skill…</div>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (result === null || !skill) {
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container>
|
||||
<EmptyState
|
||||
title="Skill not found"
|
||||
description="The skill you're looking for doesn't exist or may have been removed."
|
||||
action={{ label: "Browse skills", href: "/skills" }}
|
||||
/>
|
||||
</Container>
|
||||
<main className="section">
|
||||
<Card>Skill not found.</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -356,80 +349,91 @@ export function SkillDetailPage({
|
||||
const tagEntries = Object.entries(skill.tags ?? {}) as Array<[string, Id<"skillVersions">]>;
|
||||
|
||||
return (
|
||||
<main className="py-10">
|
||||
<Container>
|
||||
<div className="flex flex-col gap-6">
|
||||
<SkillHeader
|
||||
skill={skill}
|
||||
owner={owner}
|
||||
<main className="section">
|
||||
<div className="skill-detail-stack">
|
||||
<SkillHeader
|
||||
skill={skill}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
latestVersion={latestVersion}
|
||||
modInfo={modInfo}
|
||||
canManage={canManage}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isStaff={isStaff}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={() => void toggleStar({ skillId: skill._id })}
|
||||
onOpenReport={openReportDialog}
|
||||
forkOf={forkOf}
|
||||
forkOfLabel={forkOfLabel}
|
||||
forkOfHref={forkOfHref}
|
||||
forkOfOwnerHandle={forkOfOwnerHandle}
|
||||
canonical={canonical}
|
||||
canonicalHref={canonicalHref}
|
||||
canonicalOwnerHandle={canonicalOwnerHandle}
|
||||
staffModerationNote={staffModerationNote}
|
||||
staffVisibilityTag={staffVisibilityTag}
|
||||
isAutoHidden={isAutoHidden}
|
||||
isRemoved={isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
hasPluginBundle={hasPluginBundle}
|
||||
configRequirements={configRequirements}
|
||||
cliHelp={cliHelp}
|
||||
tagEntries={tagEntries}
|
||||
versionById={versionById}
|
||||
tagName={tagName}
|
||||
onTagNameChange={setTagName}
|
||||
tagVersionId={tagVersionId}
|
||||
onTagVersionChange={setTagVersionId}
|
||||
onTagSubmit={submitTag}
|
||||
onTagDelete={deleteTag}
|
||||
tagVersions={versions ?? []}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
/>
|
||||
|
||||
{isOwner && skill ? (
|
||||
<SkillOwnershipPanel
|
||||
skillId={skill._id}
|
||||
slug={skill.slug}
|
||||
ownerHandle={ownerHandle}
|
||||
latestVersion={latestVersion}
|
||||
modInfo={modInfo}
|
||||
canManage={canManage}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isStaff={isStaff}
|
||||
isStarred={isStarred}
|
||||
onToggleStar={() => void toggleStar({ skillId: skill._id })}
|
||||
onOpenReport={openReportDialog}
|
||||
forkOf={forkOf}
|
||||
forkOfLabel={forkOfLabel}
|
||||
forkOfHref={forkOfHref}
|
||||
forkOfOwnerHandle={forkOfOwnerHandle}
|
||||
canonical={canonical}
|
||||
canonicalHref={canonicalHref}
|
||||
canonicalOwnerHandle={canonicalOwnerHandle}
|
||||
staffModerationNote={staffModerationNote}
|
||||
staffVisibilityTag={staffVisibilityTag}
|
||||
isAutoHidden={isAutoHidden}
|
||||
isRemoved={isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
hasPluginBundle={hasPluginBundle}
|
||||
configRequirements={configRequirements}
|
||||
cliHelp={cliHelp}
|
||||
tagEntries={tagEntries}
|
||||
versionById={versionById}
|
||||
tagName={tagName}
|
||||
onTagNameChange={setTagName}
|
||||
tagVersionId={tagVersionId}
|
||||
onTagVersionChange={setTagVersionId}
|
||||
onTagSubmit={submitTag}
|
||||
onTagDelete={deleteTag}
|
||||
tagVersions={versions ?? []}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
ownerId={owner?._id ?? null}
|
||||
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isOwner && skill ? (
|
||||
<SkillOwnershipPanel
|
||||
skillId={skill._id}
|
||||
slug={skill.slug}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={owner?._id ?? null}
|
||||
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
|
||||
/>
|
||||
) : null}
|
||||
<SkillMetadataSidebar
|
||||
skill={skill}
|
||||
latestVersion={latestVersion}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
tagEntries={tagEntries}
|
||||
isMalwareBlocked={modInfo?.isMalwareBlocked}
|
||||
isRemoved={modInfo?.isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
/>
|
||||
|
||||
<div className="detail-content-full">
|
||||
{nixSnippet ? (
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Install via Nix
|
||||
</h2>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
{nixSystems.length ? `Systems: ${nixSystems.join(", ")}` : "nix-clawdbot"}
|
||||
</p>
|
||||
<pre className="hero-install-code mt-3">{nixSnippet}</pre>
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{nixSnippet}
|
||||
</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{configExample ? (
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Config example
|
||||
</h2>
|
||||
<p className="text-sm text-[color:var(--ink-soft)]">
|
||||
Starter config for this plugin bundle.
|
||||
</p>
|
||||
<pre className="hero-install-code mt-3">{configExample}</pre>
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{configExample}
|
||||
</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
@@ -452,11 +456,12 @@ export function SkillDetailPage({
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<Card>
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--ink)]">Comments</h2>
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Comments
|
||||
</h2>
|
||||
<p className="section-subtitle mt-3 mb-0">
|
||||
Loading comments...
|
||||
</p>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
@@ -467,17 +472,17 @@ export function SkillDetailPage({
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkillReportDialog
|
||||
isOpen={isAuthenticated && isReportDialogOpen}
|
||||
isSubmitting={isSubmittingReport}
|
||||
reportReason={reportReason}
|
||||
reportError={reportError}
|
||||
onReasonChange={setReportReason}
|
||||
onCancel={closeReportDialog}
|
||||
onSubmit={() => void submitReport()}
|
||||
/>
|
||||
</Container>
|
||||
<SkillReportDialog
|
||||
isOpen={isAuthenticated && isReportDialogOpen}
|
||||
isSubmitting={isSubmittingReport}
|
||||
reportReason={reportReason}
|
||||
reportError={reportError}
|
||||
onReasonChange={setReportReason}
|
||||
onCancel={closeReportDialog}
|
||||
onSubmit={() => void submitReport()}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { SkillVersionsPanel } from "./SkillVersionsPanel";
|
||||
import { Card } from "./ui/card";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
|
||||
const SkillDiffCard = lazy(() =>
|
||||
import("./SkillDiffCard").then((module) => ({ default: module.SkillDiffCard })),
|
||||
@@ -15,9 +14,11 @@ const SkillFilesPanel = lazy(() =>
|
||||
|
||||
type SkillFile = Doc<"skillVersions">["files"][number];
|
||||
|
||||
export type DetailTab = "readme" | "files" | "compare" | "versions";
|
||||
|
||||
type SkillDetailTabsProps = {
|
||||
activeTab: "files" | "compare" | "versions";
|
||||
setActiveTab: (tab: "files" | "compare" | "versions") => void;
|
||||
activeTab: DetailTab;
|
||||
setActiveTab: (tab: DetailTab) => void;
|
||||
onCompareIntent: () => void;
|
||||
readmeContent: string | null;
|
||||
readmeError: string | null;
|
||||
@@ -46,13 +47,30 @@ export function SkillDetailTabs({
|
||||
suppressVersionScanResults,
|
||||
scanResultsSuppressedMessage,
|
||||
}: SkillDetailTabsProps) {
|
||||
const compareEnabled = (versions?.length ?? 0) > 1;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as typeof activeTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="compare"
|
||||
<div className="card tab-card">
|
||||
<div className="tab-header">
|
||||
<button
|
||||
className={`tab-button${activeTab === "readme" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("readme")}
|
||||
>
|
||||
README
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button${activeTab === "files" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("files")}
|
||||
>
|
||||
Files
|
||||
</button>
|
||||
{compareEnabled ? (
|
||||
<button
|
||||
className={`tab-button${activeTab === "compare" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("compare")}
|
||||
onMouseEnter={() => {
|
||||
onCompareIntent();
|
||||
void import("./SkillDiffCard");
|
||||
@@ -63,37 +81,64 @@ export function SkillDetailTabs({
|
||||
}}
|
||||
>
|
||||
Compare
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="versions">Versions</TabsTrigger>
|
||||
</TabsList>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className={`tab-button${activeTab === "versions" ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => setActiveTab("versions")}
|
||||
>
|
||||
Versions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TabsContent value="files">
|
||||
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
|
||||
<SkillFilesPanel
|
||||
versionId={latestVersionId}
|
||||
readmeContent={readmeContent}
|
||||
readmeError={readmeError}
|
||||
latestFiles={latestFiles}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
{activeTab === "readme" ? (
|
||||
<div className="tab-body">
|
||||
{readmeContent ? (
|
||||
<div className="markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
|
||||
</div>
|
||||
) : readmeError ? (
|
||||
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
|
||||
<p className="empty-state-title">No README available</p>
|
||||
<p className="empty-state-body">
|
||||
This skill doesn't have a SKILL.md file yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stat p-4">
|
||||
Loading README...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="compare">
|
||||
<Suspense fallback={<Skeleton className="h-40 w-full" />}>
|
||||
{activeTab === "files" ? (
|
||||
<Suspense fallback={<div className="tab-body stat">Loading file viewer...</div>}>
|
||||
<SkillFilesPanel
|
||||
versionId={latestVersionId}
|
||||
latestFiles={latestFiles}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{activeTab === "compare" ? (
|
||||
<div className="tab-body">
|
||||
<Suspense fallback={<div className="stat">Loading diff viewer...</div>}>
|
||||
<SkillDiffCard skill={skill} versions={diffVersions ?? []} variant="embedded" />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="versions">
|
||||
<SkillVersionsPanel
|
||||
versions={versions}
|
||||
nixPlugin={nixPlugin}
|
||||
skillSlug={skill.slug}
|
||||
suppressScanResults={suppressVersionScanResults}
|
||||
suppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
{activeTab === "versions" ? (
|
||||
<SkillVersionsPanel
|
||||
versions={versions}
|
||||
nixPlugin={nixPlugin}
|
||||
skillSlug={skill.slug}
|
||||
suppressScanResults={suppressVersionScanResults}
|
||||
suppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||