mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-16 09:52:03 +00:00
Compare commits
75
Commits
shadow/rewrite
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f53be5c8d2 | ||
|
|
03164fe8b1 | ||
|
|
81b53f0b20 | ||
|
|
75474075b1 | ||
|
|
5946a08267 | ||
|
|
b997f5e749 | ||
|
|
27f300bda5 | ||
|
|
557f11985d | ||
|
|
40f9ba5697 | ||
|
|
26a353efc7 | ||
|
|
990d3d730d | ||
|
|
75f7a93fe8 | ||
|
|
bc51ab4b5f | ||
|
|
82fce7f34f | ||
|
|
5f4fa02b42 | ||
|
|
cfef87059b | ||
|
|
b948b216c6 | ||
|
|
f156b909e7 | ||
|
|
0b46210f61 | ||
|
|
03eee4b6de | ||
|
|
09ffaba89b | ||
|
|
b39f203b12 | ||
|
|
4002bb615d | ||
|
|
583e391d83 | ||
|
|
55ae6e1eb7 | ||
|
|
b4fe685a46 | ||
|
|
a93de9cf92 | ||
|
|
74bf5946e1 | ||
|
|
04c99aafc2 | ||
|
|
ee81325cb3 | ||
|
|
bdc6348b1a | ||
|
|
68abaa3642 | ||
|
|
0c3acda26e | ||
|
|
650090d298 | ||
|
|
70220f9487 | ||
|
|
aad1fbe2c4 | ||
|
|
2d7914859c | ||
|
|
ba6a99a65f | ||
|
|
4ac63c2373 | ||
|
|
8d0f32443a | ||
|
|
9e75090a39 | ||
|
|
5cff71104c | ||
|
|
5ef8526874 | ||
|
|
46faafc413 | ||
|
|
0710b99ac4 | ||
|
|
9ebe1b6da8 | ||
|
|
98dd49c651 | ||
|
|
a115bb0d65 | ||
|
|
f1625cd5ff | ||
|
|
010ca1677f | ||
|
|
62d62f976c | ||
|
|
24ad575a4e | ||
|
|
1cd6a22284 | ||
|
|
343e065292 | ||
|
|
2849a864e0 | ||
|
|
ae0338e469 | ||
|
|
ca1ef737cb | ||
|
|
589e46353b | ||
|
|
7edbc03494 | ||
|
|
f359071d96 | ||
|
|
57bae9859e | ||
|
|
96e9ffdcdc | ||
|
|
eb4601141e | ||
|
|
39686b3b8d | ||
|
|
a24d3e9809 | ||
|
|
405c74a4ef | ||
|
|
ee828046b8 | ||
|
|
789082bc00 | ||
|
|
3e4c2450cd | ||
|
|
ea2f51d2ba | ||
|
|
7b2bdbd08f | ||
|
|
f654dc9325 | ||
|
|
d78c105570 | ||
|
|
a32498ea7d | ||
|
|
5d6ee7adf3 |
+36
-2
@@ -8,15 +8,49 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
## 0.6.0 - 2026-02-10
|
||||
|
||||
### Added
|
||||
- CLI/API: add `set-role` to change user roles (admin only).
|
||||
- Security: quarantine skill publishes with VirusTotal scans + UI (thanks @aleph8, #130).
|
||||
- Testing: add tests for badges, skillZip, uploadFiles expandDroppedItems, and ark schema error truncation.
|
||||
- Moderation: add ban reasons to API/CLI and show in management UI.
|
||||
|
||||
### Changed
|
||||
- Coverage: track `convex/lib/skillZip.ts` in coverage reports.
|
||||
|
||||
### Fixed
|
||||
- Web: show pending-scan skills to owners without 404 (thanks @orlyjamie, #136).
|
||||
- Users: backfill empty handles from name/email in ensure (thanks @adlai88, #158).
|
||||
- Web: update footer branding to OpenClaw (thanks @jontsai, #122).
|
||||
- Auth: restore soft-deleted users on reauth, block banned users (thanks @mkrokosz, #106).
|
||||
|
||||
## 0.5.0 - 2026-02-02
|
||||
|
||||
### Added
|
||||
- Admin: ban users and delete owned skills from management console.
|
||||
- Moderation: auto-hide skills after 4 unique reports; per-user report cap; moderators can ban users.
|
||||
- Uploads: require GitHub accounts to be at least 7 days old for skill + soul publish/import.
|
||||
- CLI: add `inspect` to fetch skill metadata/files without installing.
|
||||
- CLI: add moderation commands for hide/unhide/delete and ban users.
|
||||
- Management: add filters for reported skills and users.
|
||||
|
||||
### Changed
|
||||
- Deps: update dependencies to latest available versions.
|
||||
- Reporting: require reasons, show them in management console, warn about abuse bans.
|
||||
|
||||
### Fixed
|
||||
- Bans: batch hard-delete cleanup to avoid Convex read limits on large skills.
|
||||
|
||||
## 0.4.0 - 2026-01-30
|
||||
|
||||
### Added
|
||||
- Web: show published skills on user profiles (thanks @njoylab, #20).
|
||||
- CLI: include OpenClaw + Moltbot fallback skill roots for sync scans.
|
||||
- CLI: include ClawHub + Moltbot fallback skill roots for sync scans.
|
||||
- CLI: support OpenClaw configuration files (`OPENCLAW_CONFIG_PATH` / `OPENCLAW_STATE_DIR`).
|
||||
|
||||
### Changed
|
||||
- Brand: rebrand to OpenClaw and publish CLI as `clawhub` (legacy `clawdhub` supported).
|
||||
- Brand: rebrand to ClawHub and publish CLI as `clawhub` (legacy `clawdhub` supported).
|
||||
- Domain: default site/registry now `https://clawhub.ai`; `.well-known/clawhub.json` preferred.
|
||||
- Theme: persist theme under `clawhub-theme` (legacy key still read).
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# OpenClaw
|
||||
# ClawHub
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/openclaw/clawhub/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/openclaw/clawhub/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
@@ -6,7 +6,7 @@
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
OpenClaw 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 Clawdbot**: 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.
|
||||
|
||||
onlycrabs.ai is the **SOUL.md registry**: publish and share system lore the same way you publish skills.
|
||||
@@ -14,7 +14,7 @@ onlycrabs.ai is the **SOUL.md registry**: publish and share system lore the same
|
||||
Live: `https://clawhub.ai`
|
||||
onlycrabs.ai: `https://onlycrabs.ai`
|
||||
|
||||
## What you can do
|
||||
## What you can do with it
|
||||
|
||||
- Browse skills + render their `SKILL.md`.
|
||||
- Publish new skill versions with changelogs + tags (including `latest`).
|
||||
@@ -27,7 +27,7 @@ onlycrabs.ai: `https://onlycrabs.ai`
|
||||
|
||||
- Entry point is host-based: `onlycrabs.ai`.
|
||||
- On the onlycrabs.ai host, the home page and nav default to souls.
|
||||
- On OpenClaw, souls live under `/souls`.
|
||||
- On ClawHub, souls live under `/souls`.
|
||||
- Soul bundles only accept `SOUL.md` for now (no extra files).
|
||||
|
||||
## How it works (high level)
|
||||
@@ -37,9 +37,10 @@ onlycrabs.ai: `https://onlycrabs.ai`
|
||||
- Search: OpenAI embeddings (`text-embedding-3-small`) + Convex vector search.
|
||||
- API schema + routes: `packages/schema` (`clawhub-schema`).
|
||||
|
||||
|
||||
## Telemetry
|
||||
|
||||
OpenClaw tracks minimal **install telemetry** (to compute install counts) when you run `clawhub sync` while logged in.
|
||||
ClawHub tracks minimal **install telemetry** (to compute install counts) when you run `clawhub sync` while logged in.
|
||||
Disable via:
|
||||
|
||||
```bash
|
||||
@@ -95,7 +96,7 @@ This writes `JWT_PRIVATE_KEY` + `JWKS` to the deployment and prints values for y
|
||||
|
||||
## Nix plugins (nixmode skills)
|
||||
|
||||
OpenClaw can store a nix-clawdbot plugin pointer in SKILL frontmatter so the registry knows which
|
||||
ClawHub can store a nix-clawdbot plugin pointer in SKILL frontmatter so the registry knows which
|
||||
Nix package bundle to install. A nix plugin is different from a regular skill pack: it bundles the
|
||||
skill pack, the CLI binary, and its config flags/requirements together.
|
||||
|
||||
|
||||
@@ -15,55 +15,55 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@resvg/resvg-wasm": "^2.6.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-devtools": "^0.9.2",
|
||||
"@tanstack/react-router": "^1.151.6",
|
||||
"@tanstack/react-router-devtools": "^1.151.6",
|
||||
"@tanstack/react-start": "^1.152.0",
|
||||
"@tanstack/router-plugin": "^1.151.6",
|
||||
"@tanstack/react-devtools": "^0.9.4",
|
||||
"@tanstack/react-router": "^1.157.18",
|
||||
"@tanstack/react-router-devtools": "^1.157.18",
|
||||
"@tanstack/react-start": "^1.157.18",
|
||||
"@tanstack/router-plugin": "^1.157.18",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.31.6",
|
||||
"convex": "^1.31.7",
|
||||
"convex-helpers": "^0.1.111",
|
||||
"fflate": "^0.8.2",
|
||||
"h3": "2.0.1-rc.8",
|
||||
"lucide-react": "^0.562.0",
|
||||
"h3": "2.0.1-rc.11",
|
||||
"lucide-react": "^0.563.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"nitro": "^3.0.1-alpha.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"nitro": "^3.0.1-alpha.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite-tsconfig-paths": "^6.0.4",
|
||||
"vite-tsconfig-paths": "^6.0.5",
|
||||
"yaml": "^2.8.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.11",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tanstack/devtools-vite": "^0.4.1",
|
||||
"@biomejs/biome": "^2.3.13",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@tanstack/devtools-vite": "^0.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/node": "^25.0.9",
|
||||
"@types/react": "^19.2.8",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"jsdom": "^27.4.0",
|
||||
"only-allow": "^1.2.1",
|
||||
"oxlint": "^1.39.0",
|
||||
"oxlint-tsgolint": "^0.11.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"jsdom": "^28.0.0",
|
||||
"only-allow": "^1.2.2",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.11.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.17",
|
||||
"vitest": "^4.0.18",
|
||||
},
|
||||
},
|
||||
"packages/clawdhub": {
|
||||
"name": "clawhub",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"bin": {
|
||||
"clawhub": "bin/clawdhub.js",
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
@@ -252,7 +252,7 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.10.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg=="],
|
||||
"@exodus/bytes": ["@exodus/bytes@1.11.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
|
||||
|
||||
@@ -380,17 +380,17 @@
|
||||
|
||||
"@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.110.0", "", { "os": "win32", "cpu": "x64" }, "sha512-QROrowwlrApI1fEScMknGWKM6GTM/Z2xwMnDqvSaEmzNazBsDUlE08Jasw610hFEsYAVU2K5sp/YaCa9ORdP4A=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.11.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FU4e+w09D+2rkCVdL7I7zMuQOJ2tuapVhBGPGY66VAct2FUwFDVmgU+rNJ2hHIdc9uHg24v+FD8PcfFYpask8Q=="],
|
||||
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.11.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IhdhiC183s5wdFDZSQC8PaFFq1QROiVT5ahz7ysgEKVnkNDjy82ieM7ZKiUfm2ncXNX2RcFGSSZrQO6plR+VAQ=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.11.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-7sm1d920HfFsC3hIP7SJVm11WhYufA8qnLQQVk7odTpSzVUAT1jtG8LdfFigzgb38zHszQbsqJ7OjAgIW/OgmA=="],
|
||||
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.11.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-KJmBg10Z1uGpJqxDzETXOytYyeVrKUepo8rCXeVkRlZ2QzZqMElgalFN4BI3ccgIPkQpzzu4SVzWNFz7yiKavQ=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.11.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-eoJfdmHcpG9k8fufb8yL3rC3HC6QELoTEfs56lmGaRIHHmd1aj4MWDbGCqdRqPEp7oC5fVvFxi7wDkA1MDf99Q=="],
|
||||
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-P6I3dSSpoEnjFzTMlrbcBHNbErSxceZmcVUslBxrrIUH1NSVS1XfSz6S75vT2Gay7Jv6LI7zTTVAk4cSqkfe+w=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.11.3", "", { "os": "linux", "cpu": "x64" }, "sha512-t7jGK0vBApuAGvOnCPTxsdX+1e9nMdvqU3zHCJWQ7yUDaJxki0bCy4zbKfUgVo8ePeVRgIKWwqLFBOVTXQ5AMQ=="],
|
||||
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-G0eAW3S7cp/vP7Kx6e7+Ze7WfNgSt1tc/rOexfLKnnIi+9BelyOa2wF9bWFPpxk3n3AdkBwKttU1/adDZlD87Q=="],
|
||||
|
||||
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.11.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-6ellG0zcWnj2b6Mr7fl19x+nlFIWGWoKCBlYnqNZ4CaziRYGpYx7PLwHhPJq331w7zzRRSnYqhyTrVluYjZADQ=="],
|
||||
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.11.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-prgQEBiwp4TAxarh6dYbVOKw6riRJ6hB49vDD6DxQlOZQky7xHQ9qTec5/rf0JTUZ16YaJ9YfHycbJS3QVpTYw=="],
|
||||
|
||||
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.11.3", "", { "os": "win32", "cpu": "x64" }, "sha512-rzvfaRJPK9eRYVWMXCt8JtvOsVFAsqScgsFhnXzsipU6W1Te0g+b4q068o7hZ3NRTjJxNgFJj8ayOkZ6NbX0tA=="],
|
||||
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-5xXTzZIT/1meWMmS60Q+FYWvWncc6iTfC8tyQt7GDfPUoqQvE5WVgHm1QjDSJvxTD+6AHphpCqdhXq/KtxagRw=="],
|
||||
|
||||
"@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.42.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ui5CdAcDsXPQwZQEXOOSWsilJWhgj9jqHCvYBm2tDE8zfwZZuF9q58+hGKH1x5y0SV4sRlyobB2Quq6uU6EgeA=="],
|
||||
|
||||
@@ -410,7 +410,7 @@
|
||||
|
||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.58.0", "", { "dependencies": { "playwright": "1.58.0" }, "bin": { "playwright": "cli.js" } }, "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg=="],
|
||||
"@playwright/test": ["@playwright/test@1.58.1", "", { "dependencies": { "playwright": "1.58.1" }, "bin": { "playwright": "cli.js" } }, "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
@@ -566,7 +566,7 @@
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||
|
||||
"@tanstack/devtools": ["@tanstack/devtools@0.10.4", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-GR/HMWe+eAZgSm/mOeuWMs/cXy3pEcrdMBU+OH0c6Qv1IXYv/xqru4aCSJPe+2/eJXng5ioqCsoVt9MztyU1mg=="],
|
||||
"@tanstack/devtools": ["@tanstack/devtools@0.10.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-aptV4sMcdEn/zB8zqNqKSKi8pLzfB7BhdP2MuVmyfWgBDYNchqJjhviaxEXW3tJTolbWwc30o+jszwqxOIcIaA=="],
|
||||
|
||||
"@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.5", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0" } }, "sha512-hsNDE3iu4frt9cC2ppn1mNRnLKo2uc1/1hXAyY9z4UYb+o40M2clFAhiFoo4HngjfGJDV3x18KVVIq7W4Un+zA=="],
|
||||
|
||||
@@ -576,43 +576,43 @@
|
||||
|
||||
"@tanstack/devtools-ui": ["@tanstack/devtools-ui@0.4.4", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-5xHXFyX3nom0UaNfiOM92o6ziaHjGo3mcSGe2HD5Xs8dWRZNpdZ0Smd0B9ddEhy0oB+gXyMzZgUJb9DmrZV0Mg=="],
|
||||
|
||||
"@tanstack/devtools-vite": ["@tanstack/devtools-vite@0.4.1", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/generator": "^7.28.3", "@babel/parser": "^7.28.4", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "chalk": "^5.6.2", "launch-editor": "^2.11.1", "picomatch": "^4.0.3" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0" } }, "sha512-PkMOomcWnl/pUkCqIjqL/csjPHtkMVBirDpJVOZR7XJZDxo5CuD7B+3KsujFCF4Dsn6QYlae97gCZvxi/CB76Q=="],
|
||||
"@tanstack/devtools-vite": ["@tanstack/devtools-vite@0.5.0", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/generator": "^7.28.3", "@babel/parser": "^7.28.4", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@tanstack/devtools-client": "0.0.5", "@tanstack/devtools-event-bus": "0.4.0", "chalk": "^5.6.2", "launch-editor": "^2.11.1", "picomatch": "^4.0.3" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0" } }, "sha512-Ew+ZdTnmTlVjm4q+/XY/dolx/E1BWMYpiRDyU/MXqHf5epri4MLl5C4UZJaO+ZuUCsKPpsW+ufoM99E2Z4rhug=="],
|
||||
|
||||
"@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
|
||||
|
||||
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.9.3", "", { "dependencies": { "@tanstack/devtools": "0.10.4" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-SJTYWXWZkbWznwUwZ11awinPGB5StVIVyJXT0BFM1zUgjuajRwT8xRHl1oXVzVqqjJP5kfj89jkbFrcQPpq7Ng=="],
|
||||
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.9.4", "", { "dependencies": { "@tanstack/devtools": "0.10.5" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-6wQf8gVKDks1VL+LI5SS4XWK8dQLIjcDF3iMZfidyesWJNmodWbWlRkdgCmK5SpDSbcygjbp3p+LG2nE/SZ1bQ=="],
|
||||
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.16", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-xwFQa7S7dhBhm3aJYwU79cITEYgAKSrcL6wokaROIvl2JyIeazn8jueWqUPJzFjv+QF6Q8euKRlKUEyb5q2ymg=="],
|
||||
"@tanstack/react-router": ["@tanstack/react-router@1.157.18", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.18", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-qs//HcVhEZ0K2/Sqejol0vOWaFIh4EoYTQQix9FhHOyWvdUpGoTJS0+g/qxEnZZm7r9QNOrnyrYZ5CDAqnII6g=="],
|
||||
|
||||
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.157.16", "", { "dependencies": { "@tanstack/router-devtools-core": "1.157.16" }, "peerDependencies": { "@tanstack/react-router": "^1.157.16", "@tanstack/router-core": "^1.157.16", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-g6ekyzumfLBX6T5e+Vu2r37Z2CFJKrWRFqIy3vZ6A3x7OcuPV8uXNjyrLSiT/IsGTiF8YzwI4nWJa4fyd7NlCw=="],
|
||||
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.157.18", "", { "dependencies": { "@tanstack/router-devtools-core": "1.157.18" }, "peerDependencies": { "@tanstack/react-router": "^1.157.18", "@tanstack/router-core": "^1.157.18", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-JHSOnwj8wkim1AppgPT1Jp+NtmiiJ4yLZ//Vo0sfrQSgOzlZgdFg4OdQP+9rYzuj3BNz+XoDdokXQhJxiSqSww=="],
|
||||
|
||||
"@tanstack/react-start": ["@tanstack/react-start@1.157.16", "", { "dependencies": { "@tanstack/react-router": "1.157.16", "@tanstack/react-start-client": "1.157.16", "@tanstack/react-start-server": "1.157.16", "@tanstack/router-utils": "^1.154.7", "@tanstack/start-client-core": "1.157.16", "@tanstack/start-plugin-core": "1.157.16", "@tanstack/start-server-core": "1.157.16", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" } }, "sha512-FO6UYjsZyNaC0ickSSvClqfVZemp9/HWnbRJQU2dOKYQsI+wnznhLp9IkgG90iFBLcuMAWhcNHMiIuz603GJBg=="],
|
||||
"@tanstack/react-start": ["@tanstack/react-start@1.157.18", "", { "dependencies": { "@tanstack/react-router": "1.157.18", "@tanstack/react-start-client": "1.157.18", "@tanstack/react-start-server": "1.157.18", "@tanstack/router-utils": "^1.154.7", "@tanstack/start-client-core": "1.157.18", "@tanstack/start-plugin-core": "1.157.18", "@tanstack/start-server-core": "1.157.18", "pathe": "^2.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" } }, "sha512-ytKblzB34SHmm/7euujl2rZvEjzIsvICFzzKHd7DETi11FSih/WIMU4RKErMtIIc8R2NETh9MPGFLb+XFDsU6A=="],
|
||||
|
||||
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.157.16", "", { "dependencies": { "@tanstack/react-router": "1.157.16", "@tanstack/router-core": "1.157.16", "@tanstack/start-client-core": "1.157.16", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-r3XTxYPJXZ/szhbloxqT6CQtsoEjw8DjbnZh/3ZsQv2PLKTOl925cy7YVdQc2cWZyXtn5e19Ig78R+8tsoTpig=="],
|
||||
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.157.18", "", { "dependencies": { "@tanstack/react-router": "1.157.18", "@tanstack/router-core": "1.157.18", "@tanstack/start-client-core": "1.157.18", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-8bk6x7skZp62LnEC2PKegmTo4RrOMrah0RY60S3ZUDXAJGs+CF9a+0moidpawUQbWvoO6T413TVOnNFHyPixJQ=="],
|
||||
|
||||
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-router": "1.157.16", "@tanstack/router-core": "1.157.16", "@tanstack/start-client-core": "1.157.16", "@tanstack/start-server-core": "1.157.16" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-1YkBss4SUQ+HqVC1yGN/j7VNwjvdHHd3K58fASe0bz+uf7GrkGJlRXPkMJdxJkkmefYHQfyBL+q7o723N4CMYA=="],
|
||||
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.157.18", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-router": "1.157.18", "@tanstack/router-core": "1.157.18", "@tanstack/start-client-core": "1.157.18", "@tanstack/start-server-core": "1.157.18" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-LQg9FjwXJpt2yS1EdEP9r67KE9Qeg/9fWhdso+wl7XqGc8It/IEYh4D9qci3o+TVKS2b+MhIRklM0tVJ/nh1jw=="],
|
||||
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "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-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="],
|
||||
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-eJuVgM7KZYTTr4uPorbUzUflmljMVcaX2g6VvhITLnHmg9SBx9RAgtQ1HmT+72mzyIbRSlQ1q0fY/m+of/fosA=="],
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.157.18", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-jGkyA3EEE01Sf6d4goi//poxQNb/Odc/GzpjZSW2zwG+wcXm9hEzcI6vU2IxhAU0dvvwQyQgtU1HXTcXQ/Xg4A=="],
|
||||
|
||||
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.157.16", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.157.16", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-XBJTs/kMZYK6J2zhbGucHNuypwDB1t2vi8K5To+V6dUnLGBEyfQTf01fegiF4rpL1yXgomdGnP6aTiOFgldbVg=="],
|
||||
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.157.18", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.157.18", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-+eh3XzBUuoGxJr8b9kCLdyJN+zPsAxtNggEvCal7iI8WE6q3ujjUPYiqHNI+MS4thtxaeUdAXlEjak/+fdBPdg=="],
|
||||
|
||||
"@tanstack/router-generator": ["@tanstack/router-generator@1.157.16", "", { "dependencies": { "@tanstack/router-core": "1.157.16", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-Ae2M00VTFjjED7glSCi/mMLENRzhEym6NgjoOx7UVNbCC/rLU/5ASDe5VIlDa8QLEqP5Pj088Gi51gjmRuICvQ=="],
|
||||
"@tanstack/router-generator": ["@tanstack/router-generator@1.157.18", "", { "dependencies": { "@tanstack/router-core": "1.157.18", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-t6nZdaX+pYWaudwg5Yasu/o8IAK8FPc4Jwq+rZpyaCgeZn895Vc407hxoRss40/hK1jk03b8x349+b1JekiSqA=="],
|
||||
|
||||
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.157.16", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.157.16", "@tanstack/router-generator": "1.157.16", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.157.16", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-YQg7L06xyCJAYyrEJNZGAnDL8oChILU+G/eSDIwEfcWn5iLk+47x1Gcdxr82++47PWmOPhzuTo8edDQXWs7kAA=="],
|
||||
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.157.18", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.157.18", "@tanstack/router-generator": "1.157.18", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.157.18", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-1UrRnIhD4Ar0PpXwzIkxD8nfjzmO7oYRh4CkSUO+Xc6aD5poNB62aUWPp3vS5jnXDNSk0vr+N4QAPebjPKw0Hw=="],
|
||||
|
||||
"@tanstack/router-utils": ["@tanstack/router-utils@1.154.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-61bGx32tMKuEpVRseu2sh1KQe8CfB7793Mch/kyQt0EP3tD7X0sXmimCl3truRiDGUtI0CaSoQV1NPjAII1RBA=="],
|
||||
|
||||
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.157.16", "", { "dependencies": { "@tanstack/router-core": "1.157.16", "@tanstack/start-fn-stubs": "1.154.7", "@tanstack/start-storage-context": "1.157.16", "seroval": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-O+7H133MWQTkOxmXJNhrLXiOhDcBlxvpEcCd/N25Ga6eyZ7/P5vvFzNkSSxeQNkZV+RiPWnA5B75gT+U+buz3w=="],
|
||||
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.157.18", "", { "dependencies": { "@tanstack/router-core": "1.157.18", "@tanstack/start-fn-stubs": "1.154.7", "@tanstack/start-storage-context": "1.157.18", "seroval": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-DehC8ONA3QTBbaB95sL8ID+lK284ETP8/k9RCifseXOzr5xWKNNGbe3+Fy8OYV1MtHIuOmCMqozzltPp5MTANg=="],
|
||||
|
||||
"@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.154.7", "", {}, "sha512-D69B78L6pcFN5X5PHaydv7CScQcKLzJeEYqs7jpuyyqGQHSUIZUjS955j+Sir8cHhuDIovCe2LmsYHeZfWf3dQ=="],
|
||||
|
||||
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.157.16", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.0-beta.40", "@tanstack/router-core": "1.157.16", "@tanstack/router-generator": "1.157.16", "@tanstack/router-plugin": "1.157.16", "@tanstack/router-utils": "1.154.7", "@tanstack/start-client-core": "1.157.16", "@tanstack/start-server-core": "1.157.16", "babel-dead-code-elimination": "^1.0.11", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "srvx": "^0.10.1", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" }, "peerDependencies": { "vite": ">=7.0.0" } }, "sha512-VmRXuvP5flryUAHeBM4Xb06n544qLtyA2cwmlQLRTUYtQiQEAdd9CvCGy8CPAly3f7eeXKqC7aX0v3MwWkLR8w=="],
|
||||
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.157.18", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@rolldown/pluginutils": "1.0.0-beta.40", "@tanstack/router-core": "1.157.18", "@tanstack/router-generator": "1.157.18", "@tanstack/router-plugin": "1.157.18", "@tanstack/router-utils": "1.154.7", "@tanstack/start-client-core": "1.157.18", "@tanstack/start-server-core": "1.157.18", "babel-dead-code-elimination": "^1.0.11", "cheerio": "^1.0.0", "exsolve": "^1.0.7", "pathe": "^2.0.3", "srvx": "^0.10.1", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^3.24.2" }, "peerDependencies": { "vite": ">=7.0.0" } }, "sha512-qUVfdEoLf/WYUB1WASR1hcxmlGvIBKiLEIFnpG/Kd/E01BwtHhDjv6WsEusg0n2WrWnYT2/6tuJRDPyXObV7IA=="],
|
||||
|
||||
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.157.16", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/router-core": "1.157.16", "@tanstack/start-client-core": "1.157.16", "@tanstack/start-storage-context": "1.157.16", "h3-v2": "npm:h3@2.0.1-rc.11", "seroval": "^1.4.2", "tiny-invariant": "^1.3.3" } }, "sha512-PEltFleYfiqz6+KcmzNXxc1lXgT7VDNKP6G6i1TirdHBDbRJ9CIY+ASLPlhrRwqwA2PL9PpFjXZl8u5bH/+Q9A=="],
|
||||
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.157.18", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/router-core": "1.157.18", "@tanstack/start-client-core": "1.157.18", "@tanstack/start-storage-context": "1.157.18", "h3-v2": "npm:h3@2.0.1-rc.11", "seroval": "^1.4.2", "tiny-invariant": "^1.3.3" } }, "sha512-0ixErUvQsVM9SwOOpjyUOpS9KZBDRv1aoM2+qnSGR3DxZUricy/XbCaDAMxReN/0aJQzo47Y5gjpSWaGWlImWw=="],
|
||||
|
||||
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.157.16", "", { "dependencies": { "@tanstack/router-core": "1.157.16" } }, "sha512-56izE0oihAw2YRwYUEds2H+uO5dyT2CahXCgWX62+l+FHou09M9mSep68n1lBKPdphC2ZU3cPV7wnvgeraJWHg=="],
|
||||
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.157.18", "", { "dependencies": { "@tanstack/router-core": "1.157.18" } }, "sha512-OqueMS78bULFTDw37uUR8s3yWdX9JVzW4uh/y8V9Iv3oEa6yAgGC2cfOy4My70UkkggAUhoVNopQZamPtL+EBQ=="],
|
||||
|
||||
"@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
|
||||
|
||||
@@ -650,7 +650,7 @@
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
|
||||
"@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
|
||||
|
||||
@@ -788,7 +788,7 @@
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-urls": ["data-urls@6.0.1", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^15.1.0" } }, "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ=="],
|
||||
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
|
||||
|
||||
"db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],
|
||||
|
||||
@@ -872,7 +872,7 @@
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"h3": ["h3@2.0.1-rc.8", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.0" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-IIMQG7qnXx1Ls75suuMHH4xtcvTFxsUguDIZB+dgdYr1RftLj59FkeWF1dOr+jnejDs8Eo+ZKV1CMqogFeqGRQ=="],
|
||||
"h3": ["h3@2.0.1-rc.11", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw=="],
|
||||
|
||||
"h3-v2": ["h3@2.0.1-rc.11", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw=="],
|
||||
|
||||
@@ -942,7 +942,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@27.4.0", "", { "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ=="],
|
||||
"jsdom": ["jsdom@28.0.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.11.0", "cssstyle": "^5.3.7", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.20.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
@@ -984,7 +984,7 @@
|
||||
|
||||
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
|
||||
"lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
@@ -1126,7 +1126,7 @@
|
||||
|
||||
"oxlint": ["oxlint@1.42.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.42.0", "@oxlint/darwin-x64": "1.42.0", "@oxlint/linux-arm64-gnu": "1.42.0", "@oxlint/linux-arm64-musl": "1.42.0", "@oxlint/linux-x64-gnu": "1.42.0", "@oxlint/linux-x64-musl": "1.42.0", "@oxlint/win32-arm64": "1.42.0", "@oxlint/win32-x64": "1.42.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-qnspC/lrp8FgKNaONLLn14dm+W5t0SSlus6V5NJpgI2YNT1tkFYZt4fBf14ESxf9AAh98WBASnW5f0gtw462Lg=="],
|
||||
|
||||
"oxlint-tsgolint": ["oxlint-tsgolint@0.11.3", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.11.3", "@oxlint-tsgolint/darwin-x64": "0.11.3", "@oxlint-tsgolint/linux-arm64": "0.11.3", "@oxlint-tsgolint/linux-x64": "0.11.3", "@oxlint-tsgolint/win32-arm64": "0.11.3", "@oxlint-tsgolint/win32-x64": "0.11.3" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-zkuGXJzE5WIoGQ6CHG3GbxncPNrvUG9giTKdXMqKrlieCRxa9hGMvMJM+7DFxKSaryVAEFrTQJNrGJHpeMmFPg=="],
|
||||
"oxlint-tsgolint": ["oxlint-tsgolint@0.11.4", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.11.4", "@oxlint-tsgolint/darwin-x64": "0.11.4", "@oxlint-tsgolint/linux-arm64": "0.11.4", "@oxlint-tsgolint/linux-x64": "0.11.4", "@oxlint-tsgolint/win32-arm64": "0.11.4", "@oxlint-tsgolint/win32-x64": "0.11.4" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-VyQc+69TxQwUdsEPiVFN7vNZdDVO/FHaEcHltnWs3O6rvwxv67uADlknQQO714sbRdEahOjgO5dFf+K9ili0gg=="],
|
||||
|
||||
"p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="],
|
||||
|
||||
@@ -1146,9 +1146,9 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"playwright": ["playwright@1.58.0", "", { "dependencies": { "playwright-core": "1.58.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ=="],
|
||||
"playwright": ["playwright@1.58.1", "", { "dependencies": { "playwright-core": "1.58.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.58.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw=="],
|
||||
"playwright-core": ["playwright-core@1.58.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
@@ -1348,9 +1348,9 @@
|
||||
|
||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="],
|
||||
"whatwg-url": ["whatwg-url@16.0.0", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ=="],
|
||||
|
||||
"which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="],
|
||||
|
||||
@@ -1402,15 +1402,17 @@
|
||||
|
||||
"cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
|
||||
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
|
||||
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"html-encoding-sniffer/@exodus/bytes": ["@exodus/bytes@1.10.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"nitro/h3": ["h3@2.0.1-rc.11", "", { "dependencies": { "rou3": "^0.7.12", "srvx": "^0.10.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-2myzjCqy32c1As9TjZW9fNZXtLqNedjFSrdFy2AjFBQQ3LzrnGoDdFDYfC0tV2e4vcyfJ2Sfo/F6NQhO2Ly/Mw=="],
|
||||
"jsdom/undici": ["undici@7.20.0", "", {}, "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
|
||||
Vendored
+8
@@ -28,6 +28,7 @@ import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
|
||||
import type * as lib_badges from "../lib/badges.js";
|
||||
import type * as lib_changelog from "../lib/changelog.js";
|
||||
import type * as lib_embeddings from "../lib/embeddings.js";
|
||||
import type * as lib_githubAccount from "../lib/githubAccount.js";
|
||||
import type * as lib_githubBackup from "../lib/githubBackup.js";
|
||||
import type * as lib_githubImport from "../lib/githubImport.js";
|
||||
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
|
||||
@@ -38,10 +39,12 @@ import type * as lib_searchText from "../lib/searchText.js";
|
||||
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
|
||||
import type * as lib_skillPublish from "../lib/skillPublish.js";
|
||||
import type * as lib_skillStats from "../lib/skillStats.js";
|
||||
import type * as lib_skillZip from "../lib/skillZip.js";
|
||||
import type * as lib_skills from "../lib/skills.js";
|
||||
import type * as lib_soulChangelog from "../lib/soulChangelog.js";
|
||||
import type * as lib_soulPublish from "../lib/soulPublish.js";
|
||||
import type * as lib_tokens from "../lib/tokens.js";
|
||||
import type * as lib_userSearch from "../lib/userSearch.js";
|
||||
import type * as lib_webhooks from "../lib/webhooks.js";
|
||||
import type * as maintenance from "../maintenance.js";
|
||||
import type * as rateLimits from "../rateLimits.js";
|
||||
@@ -60,6 +63,7 @@ import type * as telemetry from "../telemetry.js";
|
||||
import type * as tokens from "../tokens.js";
|
||||
import type * as uploads from "../uploads.js";
|
||||
import type * as users from "../users.js";
|
||||
import type * as vt from "../vt.js";
|
||||
import type * as webhooks from "../webhooks.js";
|
||||
|
||||
import type {
|
||||
@@ -89,6 +93,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/badges": typeof lib_badges;
|
||||
"lib/changelog": typeof lib_changelog;
|
||||
"lib/embeddings": typeof lib_embeddings;
|
||||
"lib/githubAccount": typeof lib_githubAccount;
|
||||
"lib/githubBackup": typeof lib_githubBackup;
|
||||
"lib/githubImport": typeof lib_githubImport;
|
||||
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
|
||||
@@ -99,10 +104,12 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/skillBackfill": typeof lib_skillBackfill;
|
||||
"lib/skillPublish": typeof lib_skillPublish;
|
||||
"lib/skillStats": typeof lib_skillStats;
|
||||
"lib/skillZip": typeof lib_skillZip;
|
||||
"lib/skills": typeof lib_skills;
|
||||
"lib/soulChangelog": typeof lib_soulChangelog;
|
||||
"lib/soulPublish": typeof lib_soulPublish;
|
||||
"lib/tokens": typeof lib_tokens;
|
||||
"lib/userSearch": typeof lib_userSearch;
|
||||
"lib/webhooks": typeof lib_webhooks;
|
||||
maintenance: typeof maintenance;
|
||||
rateLimits: typeof rateLimits;
|
||||
@@ -121,6 +128,7 @@ declare const fullApi: ApiFromModules<{
|
||||
tokens: typeof tokens;
|
||||
uploads: typeof uploads;
|
||||
users: typeof users;
|
||||
vt: typeof vt;
|
||||
webhooks: typeof webhooks;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Id } from './_generated/dataModel'
|
||||
import { BANNED_REAUTH_MESSAGE, handleSoftDeletedUserReauth } from './auth'
|
||||
|
||||
function makeCtx({
|
||||
user,
|
||||
banRecord,
|
||||
}: {
|
||||
user: { deletedAt?: number } | null
|
||||
banRecord?: Record<string, unknown> | null
|
||||
}) {
|
||||
const query = {
|
||||
withIndex: vi.fn().mockReturnValue({
|
||||
filter: vi.fn().mockReturnValue({
|
||||
first: vi.fn().mockResolvedValue(banRecord ?? null),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn().mockResolvedValue(user),
|
||||
patch: vi.fn().mockResolvedValue(null),
|
||||
query: vi.fn().mockReturnValue(query),
|
||||
},
|
||||
}
|
||||
return { ctx, query }
|
||||
}
|
||||
|
||||
describe('handleSoftDeletedUserReauth', () => {
|
||||
const userId = 'users:1' as Id<'users'>
|
||||
|
||||
it('skips when no existing user', async () => {
|
||||
const { ctx } = makeCtx({ user: null })
|
||||
|
||||
await handleSoftDeletedUserReauth(ctx as never, { userId, existingUserId: null })
|
||||
|
||||
expect(ctx.db.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips active users', async () => {
|
||||
const { ctx } = makeCtx({ user: { deletedAt: undefined } })
|
||||
|
||||
await handleSoftDeletedUserReauth(ctx as never, { userId, existingUserId: userId })
|
||||
|
||||
expect(ctx.db.query).not.toHaveBeenCalled()
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores soft-deleted users when not banned', async () => {
|
||||
const { ctx } = makeCtx({ user: { deletedAt: 123 }, banRecord: null })
|
||||
|
||||
await handleSoftDeletedUserReauth(ctx as never, { userId, existingUserId: userId })
|
||||
|
||||
expect(ctx.db.patch).toHaveBeenCalledWith(userId, {
|
||||
deletedAt: undefined,
|
||||
updatedAt: expect.any(Number),
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks banned users with a custom message', async () => {
|
||||
const { ctx } = makeCtx({ user: { deletedAt: 123 }, banRecord: { action: 'user.ban' } })
|
||||
|
||||
await expect(
|
||||
handleSoftDeletedUserReauth(ctx as never, { userId, existingUserId: userId }),
|
||||
).rejects.toThrow(BANNED_REAUTH_MESSAGE)
|
||||
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,36 @@
|
||||
import GitHub from '@auth/core/providers/github'
|
||||
import { convexAuth } from '@convex-dev/auth/server'
|
||||
import type { GenericMutationCtx } from 'convex/server'
|
||||
import { ConvexError } from 'convex/values'
|
||||
import type { DataModel, Id } from './_generated/dataModel'
|
||||
|
||||
export const BANNED_REAUTH_MESSAGE = 'Your account has been suspended.'
|
||||
|
||||
export async function handleSoftDeletedUserReauth(
|
||||
ctx: GenericMutationCtx<DataModel>,
|
||||
args: { userId: Id<'users'>; existingUserId: Id<'users'> | null },
|
||||
) {
|
||||
if (!args.existingUserId) return
|
||||
|
||||
const user = await ctx.db.get(args.userId)
|
||||
if (!user?.deletedAt) return
|
||||
|
||||
const userId = args.userId
|
||||
const banRecord = await ctx.db
|
||||
.query('auditLogs')
|
||||
.withIndex('by_target', (q) => q.eq('targetType', 'user').eq('targetId', userId.toString()))
|
||||
.filter((q) => q.eq(q.field('action'), 'user.ban'))
|
||||
.first()
|
||||
|
||||
if (banRecord) {
|
||||
throw new ConvexError(BANNED_REAUTH_MESSAGE)
|
||||
}
|
||||
|
||||
await ctx.db.patch(userId, {
|
||||
deletedAt: undefined,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
providers: [
|
||||
@@ -16,4 +47,17 @@ export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
/**
|
||||
* Handle re-authentication of soft-deleted users.
|
||||
*
|
||||
* Performance note: This callback runs on every OAuth sign-in, but the
|
||||
* audit log query ONLY executes when a soft-deleted user attempts to
|
||||
* sign in (user.deletedAt is set). For normal active users, this is
|
||||
* just a single `if` check on an already-loaded field - no extra queries.
|
||||
*/
|
||||
async afterUserCreatedOrUpdated(ctx, args) {
|
||||
await handleSoftDeletedUserReauth(ctx, args)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
+3
-11
@@ -3,6 +3,7 @@ import type { Doc } from './_generated/dataModel'
|
||||
import { mutation, query } from './_generated/server'
|
||||
import { assertModerator, requireUser } from './lib/access'
|
||||
import { type PublicUser, toPublicUser } from './lib/public'
|
||||
import { insertStatEvent } from './skillStatEvents'
|
||||
|
||||
export const listBySkill = query({
|
||||
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
|
||||
@@ -43,10 +44,7 @@ export const add = mutation({
|
||||
deletedBy: undefined,
|
||||
})
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, comments: skill.stats.comments + 1 },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
await insertStatEvent(ctx, { skillId: skill._id, kind: 'comment' })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -68,13 +66,7 @@ export const remove = mutation({
|
||||
deletedBy: user._id,
|
||||
})
|
||||
|
||||
const skill = await ctx.db.get(comment.skillId)
|
||||
if (skill) {
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, comments: Math.max(0, skill.stats.comments - 1) },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
await insertStatEvent(ctx, { skillId: comment.skillId, kind: 'uncomment' })
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
|
||||
@@ -31,4 +31,13 @@ crons.interval(
|
||||
{},
|
||||
)
|
||||
|
||||
crons.interval('vt-pending-scans', { minutes: 5 }, internal.vt.pollPendingScans, { batchSize: 100 })
|
||||
|
||||
crons.interval('vt-cache-backfill', { minutes: 30 }, internal.vt.backfillActiveSkillsVTCache, {
|
||||
batchSize: 100,
|
||||
})
|
||||
|
||||
// Daily re-scan of all active skills at 3am UTC
|
||||
crons.daily('vt-daily-rescan', { hourUTC: 3, minuteUTC: 0 }, internal.vt.rescanActiveSkills, {})
|
||||
|
||||
export default crons
|
||||
|
||||
+34
-9
@@ -1,7 +1,7 @@
|
||||
import { v } from 'convex/values'
|
||||
import { zipSync } from 'fflate'
|
||||
import { api } from './_generated/api'
|
||||
import { httpAction, mutation } from './_generated/server'
|
||||
import { buildDeterministicZip } from './lib/skillZip'
|
||||
import { insertStatEvent } from './skillStatEvents'
|
||||
|
||||
export const downloadZip = httpAction(async (ctx, request) => {
|
||||
@@ -19,6 +19,27 @@ export const downloadZip = httpAction(async (ctx, request) => {
|
||||
return new Response('Skill not found', { status: 404 })
|
||||
}
|
||||
|
||||
// Block downloads based on moderation status
|
||||
const mod = skillResult.moderationInfo
|
||||
if (mod?.isMalwareBlocked) {
|
||||
return new Response(
|
||||
'Blocked: this skill has been flagged as malicious by VirusTotal and cannot be downloaded.',
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
if (mod?.isPendingScan) {
|
||||
return new Response(
|
||||
'This skill is pending a security scan by VirusTotal. Please try again in a few minutes.',
|
||||
{ status: 423 },
|
||||
)
|
||||
}
|
||||
if (mod?.isRemoved) {
|
||||
return new Response('This skill has been removed by a moderator.', { status: 410 })
|
||||
}
|
||||
if (mod?.isHiddenByMod) {
|
||||
return new Response('This skill is currently unavailable.', { status: 403 })
|
||||
}
|
||||
|
||||
const skill = skillResult.skill
|
||||
let version = skillResult.latestVersion
|
||||
|
||||
@@ -41,16 +62,19 @@ export const downloadZip = httpAction(async (ctx, request) => {
|
||||
return new Response('Version not available', { status: 410 })
|
||||
}
|
||||
|
||||
const files: Record<string, Uint8Array> = {}
|
||||
const entries: Array<{ path: string; bytes: Uint8Array }> = []
|
||||
for (const file of version.files) {
|
||||
const blob = await ctx.storage.get(file.storageId)
|
||||
if (!blob) continue
|
||||
const buffer = new Uint8Array(await blob.arrayBuffer())
|
||||
files[file.path] = buffer
|
||||
entries.push({ path: file.path, bytes: buffer })
|
||||
}
|
||||
|
||||
const zipData = zipSync(files, { level: 6 })
|
||||
const zipArray = Uint8Array.from(zipData)
|
||||
const zipArray = buildDeterministicZip(entries, {
|
||||
ownerId: String(skill.ownerUserId),
|
||||
slug: skill.slug,
|
||||
version: version.version,
|
||||
publishedAt: version.createdAt,
|
||||
})
|
||||
const zipBlob = new Blob([zipArray], { type: 'application/zip' })
|
||||
|
||||
await ctx.runMutation(api.downloads.increment, { skillId: skill._id })
|
||||
@@ -68,10 +92,11 @@ export const downloadZip = httpAction(async (ctx, request) => {
|
||||
export const increment = mutation({
|
||||
args: { skillId: v.id('skills') },
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) return
|
||||
// Skip db.get to avoid adding the skill doc to the read set.
|
||||
// The calling HTTP action already validated the skill exists,
|
||||
// and the stat processor handles deleted skills gracefully.
|
||||
await insertStatEvent(ctx, {
|
||||
skillId: skill._id,
|
||||
skillId: args.skillId,
|
||||
kind: 'download',
|
||||
})
|
||||
},
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
soulsPostRouterV1Http,
|
||||
starsDeleteRouterV1Http,
|
||||
starsPostRouterV1Http,
|
||||
usersListV1Http,
|
||||
usersPostRouterV1Http,
|
||||
whoamiV1Http,
|
||||
} from './httpApiV1'
|
||||
|
||||
@@ -101,6 +103,18 @@ http.route({
|
||||
handler: whoamiV1Http,
|
||||
})
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.users}/`,
|
||||
method: 'POST',
|
||||
handler: usersPostRouterV1Http,
|
||||
})
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.users,
|
||||
method: 'GET',
|
||||
handler: usersListV1Http,
|
||||
})
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.souls,
|
||||
method: 'GET',
|
||||
|
||||
@@ -15,8 +15,33 @@ const { __handlers } = await import('./httpApiV1')
|
||||
|
||||
type ActionCtx = import('./_generated/server').ActionCtx
|
||||
|
||||
type RateLimitArgs = { key: string; limit: number; windowMs: number }
|
||||
|
||||
function isRateLimitArgs(args: unknown): args is RateLimitArgs {
|
||||
if (!args || typeof args !== 'object') return false
|
||||
const value = args as Record<string, unknown>
|
||||
return (
|
||||
typeof value.key === 'string' &&
|
||||
typeof value.limit === 'number' &&
|
||||
typeof value.windowMs === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function makeCtx(partial: Record<string, unknown>) {
|
||||
return partial as unknown as ActionCtx
|
||||
const partialRunQuery =
|
||||
typeof partial.runQuery === 'function'
|
||||
? (partial.runQuery as (query: unknown, args: Record<string, unknown>) => unknown)
|
||||
: null
|
||||
const runQuery = vi.fn(async (query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate()
|
||||
return partialRunQuery ? await partialRunQuery(query, args) : null
|
||||
})
|
||||
const runMutation =
|
||||
typeof partial.runMutation === 'function'
|
||||
? partial.runMutation
|
||||
: vi.fn().mockResolvedValue(okRate())
|
||||
|
||||
return { ...partial, runQuery, runMutation } as unknown as ActionCtx
|
||||
}
|
||||
|
||||
const okRate = () => ({
|
||||
@@ -524,6 +549,108 @@ describe('httpApiV1 handlers', () => {
|
||||
expect(response2.status).toBe(200)
|
||||
})
|
||||
|
||||
it('ban user requires auth', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request('https://example.com/api/v1/users/ban', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ handle: 'demo' }),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('ban user succeeds with handle', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
const runQuery = vi.fn().mockResolvedValue({ _id: 'users:2' })
|
||||
const runMutation = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(okRate())
|
||||
.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 2 })
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/users/ban', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ handle: 'demo' }),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.deletedSkills).toBe(2)
|
||||
})
|
||||
|
||||
it('ban user forwards reason', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
const runQuery = vi.fn().mockResolvedValue({ _id: 'users:2' })
|
||||
const runMutation = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(okRate())
|
||||
.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 })
|
||||
await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/users/ban', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ handle: 'demo', reason: 'malware' }),
|
||||
}),
|
||||
)
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
actorUserId: 'users:1',
|
||||
targetUserId: 'users:2',
|
||||
reason: 'malware',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('set role requires auth', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request('https://example.com/api/v1/users/role', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ handle: 'demo', role: 'moderator' }),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('set role succeeds with handle', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: 'users:1',
|
||||
user: { handle: 'p' },
|
||||
} as never)
|
||||
const runQuery = vi.fn().mockResolvedValue({ _id: 'users:2' })
|
||||
const runMutation = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(okRate())
|
||||
.mockResolvedValueOnce({ ok: true, role: 'moderator' })
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request('https://example.com/api/v1/users/role', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ handle: 'demo', role: 'moderator' }),
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.role).toBe('moderator')
|
||||
})
|
||||
|
||||
it('stars require auth', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate())
|
||||
|
||||
+176
-2
@@ -60,6 +60,14 @@ type GetBySlugResult = {
|
||||
} | null
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
owner: { _id: Id<'users'>; handle?: string; displayName?: string; image?: string } | null
|
||||
moderationInfo?: {
|
||||
isPendingScan: boolean
|
||||
isMalwareBlocked: boolean
|
||||
isSuspicious: boolean
|
||||
isHiddenByMod: boolean
|
||||
isRemoved: boolean
|
||||
reason?: string
|
||||
} | null
|
||||
} | null
|
||||
|
||||
type ListVersionsResult = {
|
||||
@@ -196,7 +204,7 @@ async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const limit = toOptionalNumber(url.searchParams.get('limit'))
|
||||
const rawCursor = url.searchParams.get('cursor')?.trim() || undefined
|
||||
const sort = parseListSort(url.searchParams.get('sort'))
|
||||
const cursor = sort === 'updated' ? rawCursor : undefined
|
||||
const cursor = sort === 'trending' ? undefined : rawCursor
|
||||
|
||||
const result = (await ctx.runQuery(api.skills.listPublicPage, {
|
||||
limit,
|
||||
@@ -272,6 +280,12 @@ async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
image: result.owner.image ?? null,
|
||||
}
|
||||
: null,
|
||||
moderation: result.moderationInfo
|
||||
? {
|
||||
isSuspicious: result.moderationInfo.isSuspicious ?? false,
|
||||
isMalwareBlocked: result.moderationInfo.isMalwareBlocked ?? false,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
@@ -526,6 +540,145 @@ async function whoamiV1Handler(ctx: ActionCtx, request: Request) {
|
||||
|
||||
export const whoamiV1Http = httpAction(whoamiV1Handler)
|
||||
|
||||
async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'write')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const segments = getPathSegments(request, '/api/v1/users/')
|
||||
if (segments.length !== 1) {
|
||||
return text('Not found', 404, rate.headers)
|
||||
}
|
||||
const action = segments[0]
|
||||
if (action !== 'ban' && action !== 'role') {
|
||||
return text('Not found', 404, rate.headers)
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = (await request.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return text('Invalid JSON', 400, rate.headers)
|
||||
}
|
||||
|
||||
const handleRaw = typeof payload.handle === 'string' ? payload.handle.trim() : ''
|
||||
const userIdRaw = typeof payload.userId === 'string' ? payload.userId.trim() : ''
|
||||
const reasonRaw = typeof payload.reason === 'string' ? payload.reason.trim() : ''
|
||||
if (!handleRaw && !userIdRaw) {
|
||||
return text('Missing userId or handle', 400, rate.headers)
|
||||
}
|
||||
|
||||
const roleRaw = typeof payload.role === 'string' ? payload.role.trim().toLowerCase() : ''
|
||||
if (action === 'role' && !roleRaw) {
|
||||
return text('Missing role', 400, rate.headers)
|
||||
}
|
||||
const role = roleRaw === 'user' || roleRaw === 'moderator' || roleRaw === 'admin' ? roleRaw : null
|
||||
if (action === 'role' && !role) {
|
||||
return text('Invalid role', 400, rate.headers)
|
||||
}
|
||||
|
||||
let actorUserId: Id<'users'>
|
||||
try {
|
||||
const auth = await requireApiTokenUser(ctx, request)
|
||||
actorUserId = auth.userId
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
}
|
||||
|
||||
let targetUserId: Id<'users'> | null = userIdRaw ? (userIdRaw as Id<'users'>) : null
|
||||
if (!targetUserId) {
|
||||
const handle = handleRaw.toLowerCase()
|
||||
const user = await ctx.runQuery(api.users.getByHandle, { handle })
|
||||
if (!user?._id) return text('User not found', 404, rate.headers)
|
||||
targetUserId = user._id
|
||||
}
|
||||
|
||||
if (action === 'ban') {
|
||||
const reason = reasonRaw.length > 0 ? reasonRaw : undefined
|
||||
if (reason && reason.length > 500) {
|
||||
return text('Reason too long (max 500 chars)', 400, rate.headers)
|
||||
}
|
||||
try {
|
||||
const result = await ctx.runMutation(internal.users.banUserInternal, {
|
||||
actorUserId,
|
||||
targetUserId,
|
||||
reason,
|
||||
})
|
||||
return json(result, 200, rate.headers)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Ban failed'
|
||||
if (message.toLowerCase().includes('forbidden')) {
|
||||
return text('Forbidden', 403, rate.headers)
|
||||
}
|
||||
if (message.toLowerCase().includes('not found')) {
|
||||
return text(message, 404, rate.headers)
|
||||
}
|
||||
return text(message, 400, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
return text('Invalid role', 400, rate.headers)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.runMutation(internal.users.setRoleInternal, {
|
||||
actorUserId,
|
||||
targetUserId,
|
||||
role,
|
||||
})
|
||||
return json({ ok: true, role: result.role ?? role }, 200, rate.headers)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Role change failed'
|
||||
if (message.toLowerCase().includes('forbidden')) {
|
||||
return text('Forbidden', 403, rate.headers)
|
||||
}
|
||||
if (message.toLowerCase().includes('not found')) {
|
||||
return text(message, 404, rate.headers)
|
||||
}
|
||||
return text(message, 400, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
export const usersPostRouterV1Http = httpAction(usersPostRouterV1Handler)
|
||||
|
||||
async function usersListV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, 'read')
|
||||
if (!rate.ok) return rate.response
|
||||
|
||||
const url = new URL(request.url)
|
||||
const limitRaw = toOptionalNumber(url.searchParams.get('limit'))
|
||||
const query = url.searchParams.get('q') ?? url.searchParams.get('query') ?? ''
|
||||
|
||||
let actorUserId: Id<'users'>
|
||||
try {
|
||||
const auth = await requireApiTokenUser(ctx, request)
|
||||
actorUserId = auth.userId
|
||||
} catch {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(limitRaw ?? 20, 1), 200)
|
||||
try {
|
||||
const result = await ctx.runQuery(internal.users.searchInternal, {
|
||||
actorUserId,
|
||||
query,
|
||||
limit,
|
||||
})
|
||||
return json(result, 200, rate.headers)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'User search failed'
|
||||
if (message.toLowerCase().includes('forbidden')) {
|
||||
return text('Forbidden', 403, rate.headers)
|
||||
}
|
||||
if (message.toLowerCase().includes('unauthorized')) {
|
||||
return text('Unauthorized', 401, rate.headers)
|
||||
}
|
||||
return text(message, 400, rate.headers)
|
||||
}
|
||||
}
|
||||
|
||||
export const usersListV1Http = httpAction(usersListV1Handler)
|
||||
|
||||
async function parseMultipartPublish(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
@@ -680,11 +833,30 @@ async function checkRateLimit(
|
||||
key: string,
|
||||
limit: number,
|
||||
): Promise<RateLimitResult> {
|
||||
return (await ctx.runMutation(internal.rateLimits.checkRateLimitInternal, {
|
||||
// Step 1: Read-only check — no write conflicts for denied requests
|
||||
const status = (await ctx.runQuery(internal.rateLimits.getRateLimitStatusInternal, {
|
||||
key,
|
||||
limit,
|
||||
windowMs: RATE_LIMIT_WINDOW_MS,
|
||||
})) as RateLimitResult
|
||||
|
||||
if (!status.allowed) {
|
||||
return status
|
||||
}
|
||||
|
||||
// Step 2: Consume a token (only when allowed, with double-check for races)
|
||||
const result = (await ctx.runMutation(internal.rateLimits.consumeRateLimitInternal, {
|
||||
key,
|
||||
limit,
|
||||
windowMs: RATE_LIMIT_WINDOW_MS,
|
||||
})) as { allowed: boolean; remaining: number }
|
||||
|
||||
return {
|
||||
allowed: result.allowed,
|
||||
remaining: result.remaining,
|
||||
limit: status.limit,
|
||||
resetAt: status.resetAt,
|
||||
}
|
||||
}
|
||||
|
||||
function pickMostRestrictive(primary: RateLimitResult, secondary: RateLimitResult | null) {
|
||||
@@ -1169,4 +1341,6 @@ export const __handlers = {
|
||||
starsPostRouterV1Handler,
|
||||
starsDeleteRouterV1Handler,
|
||||
whoamiV1Handler,
|
||||
usersPostRouterV1Handler,
|
||||
usersListV1Handler,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/* @vitest-environment node */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { internal } from '../_generated/api'
|
||||
import { requireGitHubAccountAge } from './githubAccount'
|
||||
|
||||
vi.mock('../_generated/api', () => ({
|
||||
internal: {
|
||||
users: {
|
||||
getByIdInternal: Symbol('getByIdInternal'),
|
||||
updateGithubMetaInternal: Symbol('updateGithubMetaInternal'),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
describe('requireGitHubAccountAge', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses cached githubCreatedAt when fresh', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: now.getTime() - 10 * ONE_DAY_MS,
|
||||
githubFetchedAt: now.getTime() - ONE_DAY_MS + 1000,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(runMutation).not.toHaveBeenCalled()
|
||||
expect(runQuery).toHaveBeenCalledWith(internal.users.getByIdInternal, { userId: 'users:1' })
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('rejects accounts younger than 7 days', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'newbie',
|
||||
githubCreatedAt: now.getTime() - 2 * ONE_DAY_MS,
|
||||
githubFetchedAt: now.getTime() - ONE_DAY_MS / 2,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account must be at least 7 days old/i)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('refreshes githubCreatedAt when cache is stale', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: now.getTime() - 2 * ONE_DAY_MS,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
created_at: '2020-01-01T00:00:00Z',
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.github.com/users/steipete',
|
||||
expect.objectContaining({ headers: { 'User-Agent': 'clawhub' } }),
|
||||
)
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.users.updateGithubMetaInternal, {
|
||||
userId: 'users:1',
|
||||
githubCreatedAt: Date.parse('2020-01-01T00:00:00Z'),
|
||||
githubFetchedAt: now.getTime(),
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('throws when GitHub lookup fails', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account lookup failed/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ConvexError } from 'convex/values'
|
||||
import { internal } from '../_generated/api'
|
||||
import type { Id } from '../_generated/dataModel'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
|
||||
const GITHUB_API = 'https://api.github.com'
|
||||
const MIN_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const FETCH_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
type GitHubUser = {
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'>) {
|
||||
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId })
|
||||
if (!user || user.deletedAt) throw new ConvexError('User not found')
|
||||
|
||||
const handle = user.handle?.trim()
|
||||
if (!handle) throw new ConvexError('GitHub handle required')
|
||||
|
||||
const now = Date.now()
|
||||
let createdAt = user.githubCreatedAt ?? null
|
||||
const fetchedAt = user.githubFetchedAt ?? 0
|
||||
const stale = !createdAt || now - fetchedAt > FETCH_TTL_MS
|
||||
|
||||
if (stale) {
|
||||
const response = await fetch(`${GITHUB_API}/users/${encodeURIComponent(handle)}`, {
|
||||
headers: { 'User-Agent': 'clawhub' },
|
||||
})
|
||||
if (!response.ok) throw new ConvexError('GitHub account lookup failed')
|
||||
|
||||
const payload = (await response.json()) as GitHubUser
|
||||
const parsed = payload.created_at ? Date.parse(payload.created_at) : Number.NaN
|
||||
if (!Number.isFinite(parsed)) throw new ConvexError('GitHub account lookup failed')
|
||||
|
||||
createdAt = parsed
|
||||
await ctx.runMutation(internal.users.updateGithubMetaInternal, {
|
||||
userId,
|
||||
githubCreatedAt: createdAt,
|
||||
githubFetchedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (!createdAt) throw new ConvexError('GitHub account lookup failed')
|
||||
|
||||
const ageMs = now - createdAt
|
||||
if (ageMs < MIN_ACCOUNT_AGE_MS) {
|
||||
const remainingMs = MIN_ACCOUNT_AGE_MS - ageMs
|
||||
const remainingDays = Math.max(1, Math.ceil(remainingMs / (24 * 60 * 60 * 1000)))
|
||||
throw new ConvexError(
|
||||
`GitHub account must be at least 7 days old to upload skills. Try again in ${remainingDays} day${
|
||||
remainingDays === 1 ? '' : 's'
|
||||
}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { ActionCtx, MutationCtx } from '../_generated/server'
|
||||
import { getSkillBadgeMap, isSkillHighlighted } from './badges'
|
||||
import { generateChangelogForPublish } from './changelog'
|
||||
import { generateEmbedding } from './embeddings'
|
||||
import { requireGitHubAccountAge } from './githubAccount'
|
||||
import type { PublicUser } from './public'
|
||||
import {
|
||||
buildEmbeddingText,
|
||||
@@ -67,6 +68,9 @@ export async function publishVersionForUser(
|
||||
if (!semver.valid(version)) {
|
||||
throw new ConvexError('Version must be valid semver')
|
||||
}
|
||||
|
||||
await requireGitHubAccountAge(ctx, userId)
|
||||
|
||||
const suppliedChangelog = args.changelog.trim()
|
||||
const changelogSource = suppliedChangelog ? ('user' as const) : ('auto' as const)
|
||||
|
||||
@@ -166,6 +170,10 @@ export async function publishVersionForUser(
|
||||
embedding,
|
||||
})) as PublishResult
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.vt.scanWithVirusTotal, {
|
||||
versionId: publishResult.versionId,
|
||||
})
|
||||
|
||||
const owner = (await ctx.runQuery(internal.users.getByIdInternal, {
|
||||
userId,
|
||||
})) as Doc<'users'> | null
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toDayKey } from './leaderboards'
|
||||
type SkillStatDeltas = {
|
||||
downloads?: number
|
||||
stars?: number
|
||||
comments?: number
|
||||
installsCurrent?: number
|
||||
installsAllTime?: number
|
||||
}
|
||||
@@ -22,8 +23,10 @@ export function applySkillStatDeltas(skill: Doc<'skills'>, deltas: SkillStatDelt
|
||||
? skill.statsInstallsAllTime
|
||||
: (skill.stats.installsAllTime ?? 0)
|
||||
|
||||
const currentComments = skill.stats.comments
|
||||
const nextDownloads = Math.max(0, currentDownloads + (deltas.downloads ?? 0))
|
||||
const nextStars = Math.max(0, currentStars + (deltas.stars ?? 0))
|
||||
const nextComments = Math.max(0, currentComments + (deltas.comments ?? 0))
|
||||
const nextInstallsCurrent = Math.max(0, currentInstallsCurrent + (deltas.installsCurrent ?? 0))
|
||||
const nextInstallsAllTime = Math.max(0, currentInstallsAllTime + (deltas.installsAllTime ?? 0))
|
||||
|
||||
@@ -36,6 +39,7 @@ export function applySkillStatDeltas(skill: Doc<'skills'>, deltas: SkillStatDelt
|
||||
...skill.stats,
|
||||
downloads: nextDownloads,
|
||||
stars: nextStars,
|
||||
comments: nextComments,
|
||||
installsCurrent: nextInstallsCurrent,
|
||||
installsAllTime: nextInstallsAllTime,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { unzipSync } from 'fflate'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildDeterministicZip, buildSkillMeta, type SkillZipMeta } from './skillZip'
|
||||
|
||||
describe('skillZip', () => {
|
||||
describe('buildSkillMeta', () => {
|
||||
it('returns metadata object with all fields', () => {
|
||||
const meta: SkillZipMeta = {
|
||||
ownerId: 'user123',
|
||||
slug: 'my-skill',
|
||||
version: '1.0.0',
|
||||
publishedAt: 1700000000000,
|
||||
}
|
||||
const result = buildSkillMeta(meta)
|
||||
expect(result).toEqual({
|
||||
ownerId: 'user123',
|
||||
slug: 'my-skill',
|
||||
version: '1.0.0',
|
||||
publishedAt: 1700000000000,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDeterministicZip', () => {
|
||||
it('creates a zip with provided entries', () => {
|
||||
const entries = [
|
||||
{ path: 'SKILL.md', bytes: new TextEncoder().encode('# My Skill') },
|
||||
{ path: 'README.txt', bytes: new TextEncoder().encode('Hello') },
|
||||
]
|
||||
const zip = buildDeterministicZip(entries)
|
||||
const unzipped = unzipSync(zip)
|
||||
|
||||
expect(Object.keys(unzipped).sort()).toEqual(['README.txt', 'SKILL.md'])
|
||||
expect(new TextDecoder().decode(unzipped['SKILL.md'])).toBe('# My Skill')
|
||||
expect(new TextDecoder().decode(unzipped['README.txt'])).toBe('Hello')
|
||||
})
|
||||
|
||||
it('sorts entries alphabetically for deterministic output', () => {
|
||||
const entries1 = [
|
||||
{ path: 'b.txt', bytes: new TextEncoder().encode('B') },
|
||||
{ path: 'a.txt', bytes: new TextEncoder().encode('A') },
|
||||
]
|
||||
const entries2 = [
|
||||
{ path: 'a.txt', bytes: new TextEncoder().encode('A') },
|
||||
{ path: 'b.txt', bytes: new TextEncoder().encode('B') },
|
||||
]
|
||||
|
||||
const zip1 = buildDeterministicZip(entries1)
|
||||
const zip2 = buildDeterministicZip(entries2)
|
||||
|
||||
// Both should produce identical zips regardless of input order
|
||||
expect(Array.from(zip1)).toEqual(Array.from(zip2))
|
||||
})
|
||||
|
||||
it('includes _meta.json when meta is provided', () => {
|
||||
const entries = [{ path: 'SKILL.md', bytes: new TextEncoder().encode('# Hello') }]
|
||||
const meta: SkillZipMeta = {
|
||||
ownerId: 'user456',
|
||||
slug: 'test-skill',
|
||||
version: '2.0.0',
|
||||
publishedAt: 1700000000000,
|
||||
}
|
||||
|
||||
const zip = buildDeterministicZip(entries, meta)
|
||||
const unzipped = unzipSync(zip)
|
||||
|
||||
expect(Object.keys(unzipped).sort()).toEqual(['SKILL.md', '_meta.json'])
|
||||
const metaContent = JSON.parse(new TextDecoder().decode(unzipped['_meta.json']))
|
||||
expect(metaContent).toEqual({
|
||||
ownerId: 'user456',
|
||||
slug: 'test-skill',
|
||||
version: '2.0.0',
|
||||
publishedAt: 1700000000000,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not include _meta.json when meta is undefined', () => {
|
||||
const entries = [{ path: 'SKILL.md', bytes: new TextEncoder().encode('# Hello') }]
|
||||
const zip = buildDeterministicZip(entries)
|
||||
const unzipped = unzipSync(zip)
|
||||
|
||||
expect(Object.keys(unzipped)).toEqual(['SKILL.md'])
|
||||
})
|
||||
|
||||
it('produces deterministic output for same inputs', () => {
|
||||
const entries = [
|
||||
{ path: 'file1.md', bytes: new TextEncoder().encode('content1') },
|
||||
{ path: 'file2.md', bytes: new TextEncoder().encode('content2') },
|
||||
]
|
||||
const meta: SkillZipMeta = {
|
||||
ownerId: 'owner',
|
||||
slug: 'slug',
|
||||
version: '1.0.0',
|
||||
publishedAt: 1700000000000,
|
||||
}
|
||||
|
||||
const zip1 = buildDeterministicZip(entries, meta)
|
||||
const zip2 = buildDeterministicZip(entries, meta)
|
||||
|
||||
// Should be byte-for-byte identical
|
||||
expect(Array.from(zip1)).toEqual(Array.from(zip2))
|
||||
})
|
||||
|
||||
it('handles empty entries array', () => {
|
||||
const zip = buildDeterministicZip([])
|
||||
const unzipped = unzipSync(zip)
|
||||
|
||||
expect(Object.keys(unzipped)).toEqual([])
|
||||
})
|
||||
|
||||
it('handles empty entries array with meta', () => {
|
||||
const meta: SkillZipMeta = {
|
||||
ownerId: 'owner',
|
||||
slug: 'slug',
|
||||
version: '1.0.0',
|
||||
publishedAt: 1700000000000,
|
||||
}
|
||||
const zip = buildDeterministicZip([], meta)
|
||||
const unzipped = unzipSync(zip)
|
||||
|
||||
expect(Object.keys(unzipped)).toEqual(['_meta.json'])
|
||||
})
|
||||
|
||||
it('handles nested paths', () => {
|
||||
const entries = [
|
||||
{ path: 'docs/readme.md', bytes: new TextEncoder().encode('docs') },
|
||||
{ path: 'src/index.ts', bytes: new TextEncoder().encode('code') },
|
||||
{ path: 'SKILL.md', bytes: new TextEncoder().encode('skill') },
|
||||
]
|
||||
|
||||
const zip = buildDeterministicZip(entries)
|
||||
const unzipped = unzipSync(zip)
|
||||
|
||||
expect(Object.keys(unzipped).sort()).toEqual(['SKILL.md', 'docs/readme.md', 'src/index.ts'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { zipSync } from 'fflate'
|
||||
|
||||
type ZipEntry = {
|
||||
path: string
|
||||
bytes: Uint8Array
|
||||
}
|
||||
|
||||
export type SkillZipMeta = {
|
||||
ownerId: string
|
||||
slug: string
|
||||
version: string
|
||||
publishedAt: number
|
||||
}
|
||||
|
||||
type ZipInput = Record<string, Uint8Array | [Uint8Array, { mtime?: Date }]>
|
||||
|
||||
const FIXED_ZIP_DATE = new Date(1980, 0, 1, 0, 0, 0)
|
||||
|
||||
export function buildSkillMeta(meta: SkillZipMeta) {
|
||||
return {
|
||||
ownerId: meta.ownerId,
|
||||
slug: meta.slug,
|
||||
version: meta.version,
|
||||
publishedAt: meta.publishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDeterministicZip(entries: ZipEntry[], meta?: SkillZipMeta) {
|
||||
const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path))
|
||||
const zipData: ZipInput = {}
|
||||
|
||||
for (const entry of sorted) {
|
||||
zipData[entry.path] = [entry.bytes, { mtime: FIXED_ZIP_DATE }]
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
const metaContent = new TextEncoder().encode(JSON.stringify(buildSkillMeta(meta), null, 2))
|
||||
zipData['_meta.json'] = [metaContent, { mtime: FIXED_ZIP_DATE }]
|
||||
}
|
||||
|
||||
return Uint8Array.from(zipSync(zipData, { level: 6 }))
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { internal } from '../_generated/api'
|
||||
import type { Doc, Id } from '../_generated/dataModel'
|
||||
import type { ActionCtx } from '../_generated/server'
|
||||
import { generateEmbedding } from './embeddings'
|
||||
import { requireGitHubAccountAge } from './githubAccount'
|
||||
import {
|
||||
buildEmbeddingText,
|
||||
getFrontmatterMetadata,
|
||||
@@ -90,6 +91,9 @@ export async function publishSoulVersionForUser(
|
||||
if (!semver.valid(version)) {
|
||||
throw new ConvexError('Version must be valid semver')
|
||||
}
|
||||
|
||||
await requireGitHubAccountAge(ctx, userId)
|
||||
|
||||
const suppliedChangelog = args.changelog.trim()
|
||||
const changelogSource = suppliedChangelog ? ('user' as const) : ('auto' as const)
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Doc } from '../_generated/dataModel'
|
||||
|
||||
type UserSearchResult = {
|
||||
items: Doc<'users'>[]
|
||||
total: number
|
||||
}
|
||||
|
||||
type UserSearchMatch = {
|
||||
user: Doc<'users'>
|
||||
score: number
|
||||
}
|
||||
|
||||
function normalizeCompact(value: string) {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
}
|
||||
|
||||
function scoreUser(user: Doc<'users'>, query: string, compactQuery: string) {
|
||||
const handle = user.handle?.toLowerCase() ?? ''
|
||||
const name = user.name?.toLowerCase() ?? ''
|
||||
const displayName = user.displayName?.toLowerCase() ?? ''
|
||||
const email = user.email?.toLowerCase() ?? ''
|
||||
const id = String(user._id).toLowerCase()
|
||||
|
||||
let score = 0
|
||||
|
||||
if (id === query) score = Math.max(score, 100)
|
||||
if (handle === query) score = Math.max(score, 96)
|
||||
if (displayName === query || name === query) score = Math.max(score, 90)
|
||||
|
||||
if (handle.startsWith(query)) score = Math.max(score, 82)
|
||||
if (displayName.startsWith(query) || name.startsWith(query)) score = Math.max(score, 72)
|
||||
|
||||
if (handle.includes(query)) score = Math.max(score, 62)
|
||||
if (displayName.includes(query) || name.includes(query)) score = Math.max(score, 52)
|
||||
if (email.includes(query)) score = Math.max(score, 42)
|
||||
if (id.includes(query)) score = Math.max(score, 40)
|
||||
|
||||
if (compactQuery.length >= 2) {
|
||||
const compactHandle = normalizeCompact(handle)
|
||||
const compactName = normalizeCompact(displayName || name)
|
||||
if (compactHandle === compactQuery) score = Math.max(score, 88)
|
||||
if (compactHandle.includes(compactQuery)) score = Math.max(score, 58)
|
||||
if (compactName.includes(compactQuery)) score = Math.max(score, 48)
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
export function buildUserSearchResults(users: Doc<'users'>[], query?: string): UserSearchResult {
|
||||
const trimmed = query?.trim() ?? ''
|
||||
if (!trimmed) return { items: users, total: users.length }
|
||||
|
||||
const normalized = trimmed.toLowerCase()
|
||||
const compactQuery = normalizeCompact(normalized)
|
||||
const matches: UserSearchMatch[] = []
|
||||
|
||||
for (const user of users) {
|
||||
const score = scoreUser(user, normalized, compactQuery)
|
||||
if (score > 0) matches.push({ user, score })
|
||||
}
|
||||
|
||||
matches.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score
|
||||
return b.user._creationTime - a.user._creationTime
|
||||
})
|
||||
|
||||
return { items: matches.map((entry) => entry.user), total: matches.length }
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export function buildDiscordPayload(
|
||||
},
|
||||
],
|
||||
footer: {
|
||||
text: 'OpenClaw',
|
||||
text: 'ClawHub',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
@@ -89,9 +89,9 @@ export function buildSkillUrl(skill: WebhookSkillPayload, siteUrl: string) {
|
||||
function buildDescription(event: WebhookEvent, skill: WebhookSkillPayload) {
|
||||
const summary = (skill.summary ?? '').trim()
|
||||
if (summary) return truncate(summary, 200)
|
||||
if (event === 'skill.highlighted') return 'Newly highlighted skill on OpenClaw.'
|
||||
if (skill.version) return `New version v${skill.version} published on OpenClaw.`
|
||||
return 'New skill published on OpenClaw.'
|
||||
if (event === 'skill.highlighted') return 'Newly highlighted skill on ClawHub.'
|
||||
if (skill.version) return `New version v${skill.version} published on ClawHub.`
|
||||
return 'New skill published on ClawHub.'
|
||||
}
|
||||
|
||||
function parseBoolean(value?: string) {
|
||||
|
||||
+44
-9
@@ -1,7 +1,11 @@
|
||||
import { v } from 'convex/values'
|
||||
import { internalMutation } from './_generated/server'
|
||||
import { internalMutation, internalQuery } from './_generated/server'
|
||||
|
||||
export const checkRateLimitInternal = internalMutation({
|
||||
/**
|
||||
* Read-only rate limit check. Returns current status without writing anything.
|
||||
* This eliminates write conflicts for denied requests entirely.
|
||||
*/
|
||||
export const getRateLimitStatusInternal = internalQuery({
|
||||
args: {
|
||||
key: v.string(),
|
||||
limit: v.number(),
|
||||
@@ -20,6 +24,43 @@ export const checkRateLimitInternal = internalMutation({
|
||||
.withIndex('by_key_window', (q) => q.eq('key', args.key).eq('windowStart', windowStart))
|
||||
.unique()
|
||||
|
||||
const count = existing?.count ?? 0
|
||||
const allowed = count < args.limit
|
||||
return {
|
||||
allowed,
|
||||
remaining: Math.max(0, args.limit - count),
|
||||
limit: args.limit,
|
||||
resetAt,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Consume one rate limit token. Only call this after getRateLimitStatusInternal
|
||||
* returns allowed=true. Includes a double-check to handle races between the
|
||||
* query and this mutation.
|
||||
*/
|
||||
export const consumeRateLimitInternal = internalMutation({
|
||||
args: {
|
||||
key: v.string(),
|
||||
limit: v.number(),
|
||||
windowMs: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now()
|
||||
const windowStart = Math.floor(now / args.windowMs) * args.windowMs
|
||||
|
||||
const existing = await ctx.db
|
||||
.query('rateLimits')
|
||||
.withIndex('by_key_window', (q) => q.eq('key', args.key).eq('windowStart', windowStart))
|
||||
.unique()
|
||||
|
||||
// Double-check: another request may have consumed the last token
|
||||
// between our query and this mutation
|
||||
if (existing && existing.count >= args.limit) {
|
||||
return { allowed: false, remaining: 0 }
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
await ctx.db.insert('rateLimits', {
|
||||
key: args.key,
|
||||
@@ -28,11 +69,7 @@ export const checkRateLimitInternal = internalMutation({
|
||||
limit: args.limit,
|
||||
updatedAt: now,
|
||||
})
|
||||
return { allowed: true, remaining: Math.max(0, args.limit - 1), limit: args.limit, resetAt }
|
||||
}
|
||||
|
||||
if (existing.count >= args.limit) {
|
||||
return { allowed: false, remaining: 0, limit: args.limit, resetAt }
|
||||
return { allowed: true, remaining: Math.max(0, args.limit - 1) }
|
||||
}
|
||||
|
||||
await ctx.db.patch(existing._id, {
|
||||
@@ -43,8 +80,6 @@ export const checkRateLimitInternal = internalMutation({
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: Math.max(0, args.limit - existing.count - 1),
|
||||
limit: args.limit,
|
||||
resetAt,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
+70
-26
@@ -17,7 +17,10 @@ const users = defineTable({
|
||||
displayName: v.optional(v.string()),
|
||||
bio: v.optional(v.string()),
|
||||
role: v.optional(v.union(v.literal('admin'), v.literal('moderator'), v.literal('user'))),
|
||||
githubCreatedAt: v.optional(v.number()),
|
||||
githubFetchedAt: v.optional(v.number()),
|
||||
deletedAt: v.optional(v.number()),
|
||||
banReason: v.optional(v.string()),
|
||||
createdAt: v.optional(v.number()),
|
||||
updatedAt: v.optional(v.number()),
|
||||
})
|
||||
@@ -29,6 +32,7 @@ const skills = defineTable({
|
||||
slug: v.string(),
|
||||
displayName: v.string(),
|
||||
summary: v.optional(v.string()),
|
||||
resourceId: v.optional(v.string()),
|
||||
ownerUserId: v.id('users'),
|
||||
canonicalSkillId: v.optional(v.id('skills')),
|
||||
forkOf: v.optional(
|
||||
@@ -42,32 +46,34 @@ const skills = defineTable({
|
||||
latestVersionId: v.optional(v.id('skillVersions')),
|
||||
tags: v.record(v.string(), v.id('skillVersions')),
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
badges: v.object({
|
||||
redactionApproved: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
highlighted: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
official: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
deprecated: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
badges: v.optional(
|
||||
v.object({
|
||||
redactionApproved: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
highlighted: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
official: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
deprecated: v.optional(
|
||||
v.object({
|
||||
byUserId: v.id('users'),
|
||||
at: v.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
moderationStatus: v.optional(
|
||||
v.union(v.literal('active'), v.literal('hidden'), v.literal('removed')),
|
||||
),
|
||||
@@ -75,6 +81,9 @@ const skills = defineTable({
|
||||
moderationReason: v.optional(v.string()),
|
||||
moderationFlags: v.optional(v.array(v.string())),
|
||||
lastReviewedAt: v.optional(v.number()),
|
||||
// VT scan tracking
|
||||
scanLastCheckedAt: v.optional(v.number()),
|
||||
scanCheckCount: v.optional(v.number()),
|
||||
hiddenAt: v.optional(v.number()),
|
||||
hiddenBy: v.optional(v.id('users')),
|
||||
reportCount: v.optional(v.number()),
|
||||
@@ -104,6 +113,8 @@ const skills = defineTable({
|
||||
.index('by_stats_installs_all_time', ['statsInstallsAllTime', 'updatedAt'])
|
||||
.index('by_batch', ['batch'])
|
||||
.index('by_active_updated', ['softDeletedAt', 'updatedAt'])
|
||||
.index('by_canonical', ['canonicalSkillId'])
|
||||
.index('by_fork_of', ['forkOf.skillId'])
|
||||
|
||||
const souls = defineTable({
|
||||
slug: v.string(),
|
||||
@@ -150,9 +161,20 @@ const skillVersions = defineTable({
|
||||
createdBy: v.id('users'),
|
||||
createdAt: v.number(),
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
sha256hash: v.optional(v.string()),
|
||||
vtAnalysis: v.optional(
|
||||
v.object({
|
||||
status: v.string(),
|
||||
verdict: v.optional(v.string()),
|
||||
analysis: v.optional(v.string()),
|
||||
source: v.optional(v.string()),
|
||||
checkedAt: v.number(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.index('by_skill', ['skillId'])
|
||||
.index('by_skill_version', ['skillId', 'version'])
|
||||
.index('by_sha256hash', ['sha256hash'])
|
||||
|
||||
const soulVersions = defineTable({
|
||||
soulId: v.id('souls'),
|
||||
@@ -273,6 +295,8 @@ const skillStatEvents = defineTable({
|
||||
v.literal('download'),
|
||||
v.literal('star'),
|
||||
v.literal('unstar'),
|
||||
v.literal('comment'),
|
||||
v.literal('uncomment'),
|
||||
v.literal('install_new'),
|
||||
v.literal('install_reactivate'),
|
||||
v.literal('install_deactivate'),
|
||||
@@ -332,6 +356,7 @@ const skillReports = defineTable({
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index('by_skill', ['skillId'])
|
||||
.index('by_skill_createdAt', ['skillId', 'createdAt'])
|
||||
.index('by_user', ['userId'])
|
||||
.index('by_skill_user', ['skillId', 'userId'])
|
||||
|
||||
@@ -375,6 +400,24 @@ const auditLogs = defineTable({
|
||||
.index('by_actor', ['actorUserId'])
|
||||
.index('by_target', ['targetType', 'targetId'])
|
||||
|
||||
const vtScanLogs = defineTable({
|
||||
type: v.union(v.literal('daily_rescan'), v.literal('backfill'), v.literal('pending_poll')),
|
||||
total: v.number(),
|
||||
updated: v.number(),
|
||||
unchanged: v.number(),
|
||||
errors: v.number(),
|
||||
flaggedSkills: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
slug: v.string(),
|
||||
status: v.string(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
durationMs: v.number(),
|
||||
createdAt: v.number(),
|
||||
}).index('by_type_date', ['type', 'createdAt'])
|
||||
|
||||
const apiTokens = defineTable({
|
||||
userId: v.id('users'),
|
||||
label: v.string(),
|
||||
@@ -464,6 +507,7 @@ export default defineSchema({
|
||||
stars,
|
||||
soulStars,
|
||||
auditLogs,
|
||||
vtScanLogs,
|
||||
apiTokens,
|
||||
rateLimits,
|
||||
githubBackupSyncState,
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ export type SoulSeed = {
|
||||
}
|
||||
|
||||
export const SOUL_SEED_HANDLE = 'seed'
|
||||
export const SOUL_SEED_DISPLAY_NAME = 'OpenClaw Seed'
|
||||
export const SOUL_SEED_DISPLAY_NAME = 'ClawHub Seed'
|
||||
export const SOUL_SEED_KEY = 'seed:souls-v1'
|
||||
|
||||
// biome-ignore format: seed payload
|
||||
|
||||
@@ -35,6 +35,8 @@ export type StatEventKind =
|
||||
| 'download'
|
||||
| 'star'
|
||||
| 'unstar'
|
||||
| 'comment'
|
||||
| 'uncomment'
|
||||
| 'install_new'
|
||||
| 'install_reactivate'
|
||||
| 'install_deactivate'
|
||||
@@ -86,6 +88,7 @@ export async function insertStatEvent(
|
||||
type AggregatedDeltas = {
|
||||
downloads: number
|
||||
stars: number
|
||||
comments: number
|
||||
installsAllTime: number
|
||||
installsCurrent: number
|
||||
/** Original timestamps for each download event (for daily stats bucketing) */
|
||||
@@ -117,6 +120,7 @@ function aggregateEvents(events: Doc<'skillStatEvents'>[]): AggregatedDeltas {
|
||||
const result: AggregatedDeltas = {
|
||||
downloads: 0,
|
||||
stars: 0,
|
||||
comments: 0,
|
||||
installsAllTime: 0,
|
||||
installsCurrent: 0,
|
||||
downloadEvents: [],
|
||||
@@ -135,6 +139,12 @@ function aggregateEvents(events: Doc<'skillStatEvents'>[]): AggregatedDeltas {
|
||||
case 'unstar':
|
||||
result.stars -= 1
|
||||
break
|
||||
case 'comment':
|
||||
result.comments += 1
|
||||
break
|
||||
case 'uncomment':
|
||||
result.comments -= 1
|
||||
break
|
||||
case 'install_new':
|
||||
// New user installing for the first time: count toward both lifetime and current
|
||||
result.installsAllTime += 1
|
||||
@@ -231,12 +241,14 @@ export const processSkillStatEventsInternal = internalMutation({
|
||||
if (
|
||||
deltas.downloads !== 0 ||
|
||||
deltas.stars !== 0 ||
|
||||
deltas.comments !== 0 ||
|
||||
deltas.installsAllTime !== 0 ||
|
||||
deltas.installsCurrent !== 0
|
||||
) {
|
||||
const patch = applySkillStatDeltas(skill, {
|
||||
downloads: deltas.downloads,
|
||||
stars: deltas.stars,
|
||||
comments: deltas.comments,
|
||||
installsAllTime: deltas.installsAllTime,
|
||||
installsCurrent: deltas.installsCurrent,
|
||||
})
|
||||
@@ -285,7 +297,7 @@ export const processSkillStatEventsInternal = internalMutation({
|
||||
|
||||
const CURSOR_KEY = 'skill_stat_events'
|
||||
const EVENT_BATCH_SIZE = 500
|
||||
const MAX_SKILLS_PER_RUN = 500
|
||||
const MAX_SKILLS_PER_RUN = 50
|
||||
|
||||
/**
|
||||
* Fetch a batch of events after the given cursor (by _creationTime).
|
||||
@@ -332,6 +344,7 @@ const skillDeltaValidator = v.object({
|
||||
skillId: v.id('skills'),
|
||||
downloads: v.number(),
|
||||
stars: v.number(),
|
||||
comments: v.number(),
|
||||
installsAllTime: v.number(),
|
||||
installsCurrent: v.number(),
|
||||
downloadEvents: v.array(v.number()),
|
||||
@@ -438,6 +451,7 @@ export const processSkillStatEventsAction = internalAction({
|
||||
{
|
||||
downloads: number
|
||||
stars: number
|
||||
comments: number
|
||||
installsAllTime: number
|
||||
installsCurrent: number
|
||||
downloadEvents: number[]
|
||||
@@ -471,6 +485,7 @@ export const processSkillStatEventsAction = internalAction({
|
||||
skillDelta = {
|
||||
downloads: 0,
|
||||
stars: 0,
|
||||
comments: 0,
|
||||
installsAllTime: 0,
|
||||
installsCurrent: 0,
|
||||
downloadEvents: [],
|
||||
@@ -491,6 +506,12 @@ export const processSkillStatEventsAction = internalAction({
|
||||
case 'unstar':
|
||||
skillDelta.stars -= 1
|
||||
break
|
||||
case 'comment':
|
||||
skillDelta.comments += 1
|
||||
break
|
||||
case 'uncomment':
|
||||
skillDelta.comments -= 1
|
||||
break
|
||||
case 'install_new':
|
||||
skillDelta.installsAllTime += 1
|
||||
skillDelta.installsCurrent += 1
|
||||
|
||||
+1361
-153
File diff suppressed because it is too large
Load Diff
+253
-15
@@ -1,9 +1,12 @@
|
||||
import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import { internalQuery, mutation, query } from './_generated/server'
|
||||
import { assertAdmin, requireUser } from './lib/access'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import type { MutationCtx } from './_generated/server'
|
||||
import { internalMutation, internalQuery, mutation, query } from './_generated/server'
|
||||
import { assertAdmin, assertModerator, requireUser } from './lib/access'
|
||||
import { toPublicUser } from './lib/public'
|
||||
import { buildUserSearchResults } from './lib/userSearch'
|
||||
|
||||
const DEFAULT_ROLE = 'user'
|
||||
const ADMIN_HANDLE = 'steipete'
|
||||
@@ -18,6 +21,46 @@ export const getByIdInternal = internalQuery({
|
||||
handler: async (ctx, args) => ctx.db.get(args.userId),
|
||||
})
|
||||
|
||||
export const searchInternal = internalQuery({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
query: v.optional(v.string()),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId)
|
||||
if (!actor || actor.deletedAt) throw new Error('Unauthorized')
|
||||
assertAdmin(actor)
|
||||
|
||||
const limit = Math.min(Math.max(args.limit ?? 20, 1), 200)
|
||||
const users = await ctx.db.query('users').order('desc').collect()
|
||||
const result = buildUserSearchResults(users, args.query)
|
||||
const items = result.items.slice(0, limit).map((user) => ({
|
||||
userId: user._id,
|
||||
handle: user.handle ?? null,
|
||||
displayName: user.displayName ?? null,
|
||||
name: user.name ?? null,
|
||||
role: user.role ?? null,
|
||||
}))
|
||||
return { items, total: result.total }
|
||||
},
|
||||
})
|
||||
|
||||
export const updateGithubMetaInternal = internalMutation({
|
||||
args: {
|
||||
userId: v.id('users'),
|
||||
githubCreatedAt: v.number(),
|
||||
githubFetchedAt: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.userId, {
|
||||
githubCreatedAt: args.githubCreatedAt,
|
||||
githubFetchedAt: args.githubFetchedAt,
|
||||
updatedAt: args.githubFetchedAt,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const me = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
@@ -33,19 +76,18 @@ export const ensure = mutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const { userId, user } = await requireUser(ctx)
|
||||
const now = Date.now()
|
||||
const updates: Record<string, unknown> = {}
|
||||
|
||||
const handle = user.handle ?? user.name ?? user.email?.split('@')[0]
|
||||
const handle = user.handle || user.name || user.email?.split('@')[0]
|
||||
if (!user.handle && handle) updates.handle = handle
|
||||
if (!user.displayName) updates.displayName = handle
|
||||
if (!user.role) {
|
||||
updates.role = handle === ADMIN_HANDLE ? 'admin' : DEFAULT_ROLE
|
||||
}
|
||||
if (!user.createdAt) updates.createdAt = user._creationTime
|
||||
updates.updatedAt = now
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates.updatedAt = Date.now()
|
||||
await ctx.db.patch(userId, updates)
|
||||
}
|
||||
|
||||
@@ -81,12 +123,15 @@ export const deleteAccount = mutation({
|
||||
})
|
||||
|
||||
export const list = query({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
assertAdmin(user)
|
||||
const limit = args.limit ?? 50
|
||||
return ctx.db.query('users').order('desc').take(limit)
|
||||
const limit = Math.min(Math.max(args.limit ?? 50, 1), 200)
|
||||
const query = args.search?.trim().toLowerCase()
|
||||
const users = await ctx.db.query('users').order('desc').collect()
|
||||
const result = buildUserSearchResults(users, query)
|
||||
return { items: result.items.slice(0, limit), total: result.total }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -108,15 +153,208 @@ export const setRole = mutation({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
assertAdmin(user)
|
||||
await ctx.db.patch(args.userId, { role: args.role, updatedAt: Date.now() })
|
||||
return setRoleWithActor(ctx, user, args.userId, args.role)
|
||||
},
|
||||
})
|
||||
|
||||
export const setRoleInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
targetUserId: v.id('users'),
|
||||
role: v.union(v.literal('admin'), v.literal('moderator'), v.literal('user')),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId)
|
||||
if (!actor || actor.deletedAt) throw new Error('User not found')
|
||||
return setRoleWithActor(ctx, actor, args.targetUserId, args.role)
|
||||
},
|
||||
})
|
||||
|
||||
async function setRoleWithActor(
|
||||
ctx: MutationCtx,
|
||||
actor: Doc<'users'>,
|
||||
targetUserId: Id<'users'>,
|
||||
role: 'admin' | 'moderator' | 'user',
|
||||
) {
|
||||
assertAdmin(actor)
|
||||
const target = await ctx.db.get(targetUserId)
|
||||
if (!target) throw new Error('User not found')
|
||||
const now = Date.now()
|
||||
await ctx.db.patch(targetUserId, { role, updatedAt: now })
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: actor._id,
|
||||
action: 'role.change',
|
||||
targetType: 'user',
|
||||
targetId: targetUserId,
|
||||
metadata: { role },
|
||||
createdAt: now,
|
||||
})
|
||||
return { ok: true as const, role }
|
||||
}
|
||||
|
||||
export const banUser = mutation({
|
||||
args: { userId: v.id('users'), reason: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
return banUserWithActor(ctx, user, args.userId, args.reason)
|
||||
},
|
||||
})
|
||||
|
||||
export const banUserInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id('users'),
|
||||
targetUserId: v.id('users'),
|
||||
reason: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId)
|
||||
if (!actor || actor.deletedAt) throw new Error('User not found')
|
||||
return banUserWithActor(ctx, actor, args.targetUserId, args.reason)
|
||||
},
|
||||
})
|
||||
|
||||
async function banUserWithActor(
|
||||
ctx: MutationCtx,
|
||||
actor: Doc<'users'>,
|
||||
targetUserId: Id<'users'>,
|
||||
reasonRaw?: string,
|
||||
) {
|
||||
assertModerator(actor)
|
||||
|
||||
if (targetUserId === actor._id) throw new Error('Cannot ban yourself')
|
||||
|
||||
const target = await ctx.db.get(targetUserId)
|
||||
if (!target) throw new Error('User not found')
|
||||
if (target.role === 'admin' && actor.role !== 'admin') {
|
||||
throw new Error('Forbidden')
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const reason = reasonRaw?.trim()
|
||||
if (reason && reason.length > 500) {
|
||||
throw new Error('Reason too long (max 500 chars)')
|
||||
}
|
||||
if (target.deletedAt) {
|
||||
return { ok: true as const, alreadyBanned: true, deletedSkills: 0 }
|
||||
}
|
||||
|
||||
const skills = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerUserId', targetUserId))
|
||||
.collect()
|
||||
|
||||
for (const skill of skills) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.hardDeleteInternal, {
|
||||
skillId: skill._id,
|
||||
actorUserId: actor._id,
|
||||
})
|
||||
}
|
||||
|
||||
const tokens = await ctx.db
|
||||
.query('apiTokens')
|
||||
.withIndex('by_user', (q) => q.eq('userId', targetUserId))
|
||||
.collect()
|
||||
for (const token of tokens) {
|
||||
await ctx.db.patch(token._id, { revokedAt: now })
|
||||
}
|
||||
|
||||
await ctx.db.patch(targetUserId, {
|
||||
deletedAt: now,
|
||||
role: 'user',
|
||||
updatedAt: now,
|
||||
banReason: reason || undefined,
|
||||
})
|
||||
|
||||
await ctx.runMutation(internal.telemetry.clearUserTelemetryInternal, { userId: targetUserId })
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: actor._id,
|
||||
action: 'user.ban',
|
||||
targetType: 'user',
|
||||
targetId: targetUserId,
|
||||
metadata: { deletedSkills: skills.length, reason: reason || undefined },
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
return { ok: true as const, alreadyBanned: false, deletedSkills: skills.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-ban a user whose skill was flagged malicious by VT.
|
||||
* Skips moderators/admins. No actor required — this is a system-level action.
|
||||
*/
|
||||
export const autobanMalwareAuthorInternal = internalMutation({
|
||||
args: {
|
||||
ownerUserId: v.id('users'),
|
||||
sha256hash: v.string(),
|
||||
slug: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const target = await ctx.db.get(args.ownerUserId)
|
||||
if (!target) return { ok: false, reason: 'user_not_found' }
|
||||
if (target.deletedAt) return { ok: true, alreadyBanned: true }
|
||||
|
||||
// Never auto-ban moderators or admins
|
||||
if (target.role === 'admin' || target.role === 'moderator') {
|
||||
console.log(`[autoban] Skipping ${target.handle ?? args.ownerUserId}: role=${target.role}`)
|
||||
return { ok: false, reason: 'protected_role' }
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// Soft-delete all their skills
|
||||
const skills = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerUserId', args.ownerUserId))
|
||||
.collect()
|
||||
|
||||
for (const skill of skills) {
|
||||
if (!skill.softDeletedAt) {
|
||||
await ctx.db.patch(skill._id, { softDeletedAt: now, updatedAt: now })
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke all API tokens
|
||||
const tokens = await ctx.db
|
||||
.query('apiTokens')
|
||||
.withIndex('by_user', (q) => q.eq('userId', args.ownerUserId))
|
||||
.collect()
|
||||
for (const token of tokens) {
|
||||
if (!token.revokedAt) {
|
||||
await ctx.db.patch(token._id, { revokedAt: now })
|
||||
}
|
||||
}
|
||||
|
||||
// Ban the user
|
||||
await ctx.db.patch(args.ownerUserId, {
|
||||
deletedAt: now,
|
||||
role: 'user',
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await ctx.runMutation(internal.telemetry.clearUserTelemetryInternal, {
|
||||
userId: args.ownerUserId,
|
||||
})
|
||||
|
||||
// Audit log — use the target as actor since there's no human actor
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
action: 'role.change',
|
||||
actorUserId: args.ownerUserId,
|
||||
action: 'user.autoban.malware',
|
||||
targetType: 'user',
|
||||
targetId: args.userId,
|
||||
metadata: { role: args.role },
|
||||
createdAt: Date.now(),
|
||||
targetId: args.ownerUserId,
|
||||
metadata: {
|
||||
trigger: 'vt.malicious',
|
||||
sha256hash: args.sha256hash,
|
||||
slug: args.slug,
|
||||
deletedSkills: skills.length,
|
||||
},
|
||||
createdAt: now,
|
||||
})
|
||||
|
||||
console.warn(
|
||||
`[autoban] Banned ${target.handle ?? args.ownerUserId} — malicious skill: ${args.slug}`,
|
||||
)
|
||||
|
||||
return { ok: true, alreadyBanned: false, deletedSkills: skills.length }
|
||||
},
|
||||
})
|
||||
|
||||
+1269
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ Reading order (new contributor):
|
||||
Feature/ops docs (already present):
|
||||
|
||||
- `docs/spec.md`: product + implementation spec (data model + flows).
|
||||
- `docs/security.md`: moderation, reporting, bans, upload gating.
|
||||
- `docs/telemetry.md`: what `clawhub sync` reports; opt-out.
|
||||
- `docs/webhook.md`: Discord webhook events/payload.
|
||||
- `docs/diffing.md`: version-to-version diff UI spec.
|
||||
|
||||
+51
-1
@@ -61,11 +61,22 @@ Stores your API token + cached registry URL.
|
||||
|
||||
- Lists latest updated skills via `/api/v1/skills?limit=...` (sorted by `updatedAt` desc).
|
||||
- Flags:
|
||||
- `--limit <n>` (1–200, default: 25)
|
||||
- `--limit <n>` (1-200, default: 25)
|
||||
- `--sort newest|downloads|rating|installs|installsAllTime|trending` (default: newest)
|
||||
- `--json` (machine-readable output)
|
||||
- Output: `<slug> v<version> <age> <summary>` (summary truncated to 50 chars).
|
||||
|
||||
### `inspect <slug>`
|
||||
|
||||
- Fetches skill metadata and version files without installing.
|
||||
- `--version <version>`: inspect a specific version (default: latest).
|
||||
- `--tag <tag>`: inspect a tagged version (e.g. `latest`).
|
||||
- `--versions`: list version history (first page).
|
||||
- `--limit <n>`: max versions to list (1-200).
|
||||
- `--files`: list files for the selected version.
|
||||
- `--file <path>`: fetch raw file content (text files only; 200KB limit).
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
### `install <slug>`
|
||||
|
||||
- Resolves latest version via `/api/v1/skills/<slug>`.
|
||||
@@ -92,6 +103,45 @@ Stores your API token + cached registry URL.
|
||||
- Publishes via `POST /api/v1/skills` (multipart).
|
||||
- Requires semver: `--version 1.2.3`.
|
||||
|
||||
### `delete <slug>`
|
||||
|
||||
- Soft-delete a skill (moderator/admin only).
|
||||
- Calls `DELETE /api/v1/skills/{slug}`.
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `undelete <slug>`
|
||||
|
||||
- Restore a hidden skill (moderator/admin only).
|
||||
- Calls `POST /api/v1/skills/{slug}/undelete`.
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `hide <slug>`
|
||||
|
||||
- Hide a skill (moderator/admin only).
|
||||
- Alias for `delete`.
|
||||
|
||||
### `unhide <slug>`
|
||||
|
||||
- Unhide a skill (moderator/admin only).
|
||||
- Alias for `undelete`.
|
||||
|
||||
### `ban-user <handleOrId>`
|
||||
|
||||
- Ban a user and delete owned skills (moderator/admin only).
|
||||
- Calls `POST /api/v1/users/ban`.
|
||||
- `--id` treats the argument as a user id instead of a handle.
|
||||
- `--fuzzy` resolves the handle via fuzzy user search (admin only).
|
||||
- `--reason` records an optional ban reason.
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `set-role <handleOrId> <role>`
|
||||
|
||||
- Change a user role (admin only).
|
||||
- Calls `POST /api/v1/users/role`.
|
||||
- `--id` treats the argument as a user id instead of a handle.
|
||||
- `--fuzzy` resolves the handle via fuzzy user search (admin only).
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `sync`
|
||||
|
||||
- Scans for local skill folders and publishes new/changed ones.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# Deploy
|
||||
|
||||
OpenClaw is two deployables:
|
||||
ClawHub is two deployables:
|
||||
|
||||
- Web app (TanStack Start) → typically Vercel.
|
||||
- Convex backend → Convex deployment (serves `/api/...` routes).
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ read_when:
|
||||
## Goals
|
||||
- Compare any file between two versions.
|
||||
- Default compare: `latest` vs `previous` (SemVer precedence).
|
||||
- UX feels native to OpenClaw (theme + typography + motion).
|
||||
- UX feels native to ClawHub (theme + typography + motion).
|
||||
- Inline or side-by-side toggle.
|
||||
- Public access.
|
||||
|
||||
|
||||
+72
-1
@@ -143,7 +143,78 @@ Publishes a new version.
|
||||
|
||||
### `DELETE /api/v1/skills/{slug}` / `POST /api/v1/skills/{slug}/undelete`
|
||||
|
||||
Soft-delete / restore a skill (owner/admin only).
|
||||
Soft-delete / restore a skill (moderator/admin only).
|
||||
|
||||
### `POST /api/v1/users/ban`
|
||||
|
||||
Ban a user and hard-delete owned skills (moderator/admin only).
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{ "handle": "user_handle", "reason": "optional ban reason" }
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```json
|
||||
{ "userId": "users_...", "reason": "optional ban reason" }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "ok": true, "alreadyBanned": false, "deletedSkills": 3 }
|
||||
```
|
||||
|
||||
### `POST /api/v1/users/role`
|
||||
|
||||
Change a user role (admin only).
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{ "handle": "user_handle", "role": "moderator" }
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```json
|
||||
{ "userId": "users_...", "role": "admin" }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "ok": true, "role": "moderator" }
|
||||
```
|
||||
|
||||
### `GET /api/v1/users`
|
||||
|
||||
List or search users (admin only).
|
||||
|
||||
Query params:
|
||||
|
||||
- `q` (optional): search query
|
||||
- `query` (optional): alias for `q`
|
||||
- `limit` (optional): max results (default 20, max 200)
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"userId": "users_...",
|
||||
"handle": "user_handle",
|
||||
"displayName": "User",
|
||||
"name": "User",
|
||||
"role": "moderator"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/stars/{slug}` / `DELETE /api/v1/stars/{slug}`
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ Example (starter):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "OpenClaw",
|
||||
"name": "ClawHub",
|
||||
"logo": "public/logo.svg",
|
||||
"navigation": [
|
||||
{ "group": "Start", "pages": ["docs/README", "docs/quickstart"] },
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
summary: 'Security + moderation controls (reports, bans, upload gating).'
|
||||
read_when:
|
||||
- Working on moderation or abuse controls
|
||||
- Reviewing upload restrictions
|
||||
- Troubleshooting hidden/removed skills
|
||||
---
|
||||
|
||||
# Security + Moderation
|
||||
|
||||
## Roles + permissions
|
||||
|
||||
- user: upload skills/souls (subject to GitHub age gate), report skills.
|
||||
- moderator: hide/restore skills, view hidden skills, unhide, soft-delete, ban users (except admins).
|
||||
- admin: all moderator actions + hard delete skills, change owners, change roles.
|
||||
|
||||
## Reporting + auto-hide
|
||||
|
||||
- Reports are unique per user + skill.
|
||||
- Report reason required (trimmed, max 500 chars). Abuse of reporting may result in account bans.
|
||||
- Per-user cap: 20 **active** reports.
|
||||
- Active = skill exists, not soft-deleted, not `moderationStatus = removed`,
|
||||
and the owner is not banned.
|
||||
- Auto-hide: when unique reports exceed 3 (4th report), the skill is:
|
||||
- soft-deleted (`softDeletedAt`)
|
||||
- `moderationStatus = hidden`
|
||||
- `moderationReason = auto.reports`
|
||||
- embeddings visibility set to `deleted`
|
||||
- audit log entry: `skill.auto_hide`
|
||||
- Public queries hide non-active moderation statuses; staff can still access via
|
||||
staff-only queries and unhide/restore/delete/ban.
|
||||
|
||||
## Bans
|
||||
|
||||
- Banning a user:
|
||||
- hard-deletes all owned skills
|
||||
- revokes API tokens
|
||||
- sets `deletedAt` on the user
|
||||
- Optional ban reason is stored in `users.banReason` and audit logs.
|
||||
- Moderators cannot ban admins; nobody can ban themselves.
|
||||
- Report counters effectively reset because deleted/banned skills are no longer
|
||||
considered active in the per-user report cap.
|
||||
|
||||
## Upload gate (GitHub account age)
|
||||
|
||||
- Skill + soul publish actions require GitHub account age ≥ 7 days.
|
||||
- Lookup uses GitHub `created_at` and caches on the user:
|
||||
- `githubCreatedAt` (source of truth)
|
||||
- `githubFetchedAt` (fetch timestamp)
|
||||
- Cache TTL: 24 hours.
|
||||
- Gate applies to web uploads, CLI publish, and GitHub import.
|
||||
+7
-5
@@ -1,12 +1,12 @@
|
||||
---
|
||||
summary: "OpenClaw spec: skills registry, versioning, vector search, moderation"
|
||||
summary: "ClawHub spec: skills registry, versioning, vector search, moderation"
|
||||
read_when:
|
||||
- Bootstrapping OpenClaw
|
||||
- Bootstrapping ClawHub
|
||||
- Implementing schema/auth/search/versioning
|
||||
- Reviewing API and upload/download flows
|
||||
---
|
||||
|
||||
# OpenClaw — product + implementation spec (v1)
|
||||
# ClawHub — product + implementation spec (v1)
|
||||
|
||||
## Goals
|
||||
- onlycrabs.ai mode for sharing `SOUL.md` bundles (host-based entry point).
|
||||
@@ -123,8 +123,9 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
|
||||
## Auth + roles
|
||||
- Convex Auth with GitHub OAuth App.
|
||||
- Default role `user`; bootstrap `steipete` to `admin` on first login.
|
||||
- Management console: moderators can hide/restore skills + mark duplicates; admins can change owners, approve badges, and hard-delete.
|
||||
- Management console: moderators can hide/restore skills + mark duplicates + ban users; admins can change owners, approve badges, hard-delete skills, and ban users (deletes owned skills).
|
||||
- Role changes are admin-only and audited.
|
||||
- Reporting: any user can report skills; per-user cap 20 active reports; skills auto-hide after >3 unique reports (mods can review/unhide/delete/ban).
|
||||
|
||||
## Upload flow (50MB per version)
|
||||
1) Client requests upload session.
|
||||
@@ -135,9 +136,10 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
|
||||
- file extensions/text content
|
||||
- SKILL.md exists and frontmatter parseable
|
||||
- version uniqueness
|
||||
- GitHub account age ≥ 7 days
|
||||
5) Server stores files + metadata, sets `latest` tag, updates stats.
|
||||
|
||||
Soul upload flow: same as skills, but only `SOUL.md` is allowed in the bundle.
|
||||
Soul upload flow: same as skills (including GitHub account age checks), but only `SOUL.md` is allowed.
|
||||
Seed data lives in `convex/seed.ts` for local dev.
|
||||
|
||||
## Versioning + tags
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# Telemetry
|
||||
|
||||
OpenClaw uses **minimal telemetry** to compute **install counts** (what’s actually in use) and to power better sorting/filtering.
|
||||
ClawHub uses **minimal telemetry** to compute **install counts** (what’s actually in use) and to power better sorting/filtering.
|
||||
This is based on the CLI `clawhub sync` command.
|
||||
|
||||
## When telemetry is collected
|
||||
@@ -70,7 +70,7 @@ This is evaluated lazily (on the next telemetry report) to avoid background jobs
|
||||
|
||||
## Transparency + user controls
|
||||
|
||||
OpenClaw provides a private “Installed” tab on your own profile:
|
||||
ClawHub provides a private “Installed” tab on your own profile:
|
||||
|
||||
- Shows the exact roots + installed skills we store.
|
||||
- Includes a **JSON export** view.
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ read_when:
|
||||
|
||||
# Webhooks (Discord)
|
||||
|
||||
OpenClaw can post Discord embeds when skills are published or highlighted.
|
||||
ClawHub can post Discord embeds when skills are published or highlighted.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -44,7 +44,7 @@ Discord receives a JSON payload with a single embed:
|
||||
{ "name": "Owner", "value": "@owner", "inline": true },
|
||||
{ "name": "Tags", "value": "latest, discord", "inline": false }
|
||||
],
|
||||
"footer": { "text": "OpenClaw" }
|
||||
"footer": { "text": "ClawHub" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ test('header menu routes render', async ({ page }) => {
|
||||
|
||||
if (label === 'Search') {
|
||||
await expect(page).toHaveURL(/\/?(\?|$)/)
|
||||
await expect(page.locator('h1', { hasText: 'OpenClaw' })).toBeVisible()
|
||||
await expect(page.locator('h1', { hasText: 'ClawHub' })).toBeVisible()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+24
-24
@@ -35,49 +35,49 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@resvg/resvg-wasm": "^2.6.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-devtools": "^0.9.2",
|
||||
"@tanstack/react-router": "^1.151.6",
|
||||
"@tanstack/react-router-devtools": "^1.151.6",
|
||||
"@tanstack/react-start": "^1.152.0",
|
||||
"@tanstack/router-plugin": "^1.151.6",
|
||||
"@tanstack/react-devtools": "^0.9.4",
|
||||
"@tanstack/react-router": "^1.157.18",
|
||||
"@tanstack/react-router-devtools": "^1.157.18",
|
||||
"@tanstack/react-start": "^1.157.18",
|
||||
"@tanstack/router-plugin": "^1.157.18",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.31.6",
|
||||
"convex": "^1.31.7",
|
||||
"convex-helpers": "^0.1.111",
|
||||
"fflate": "^0.8.2",
|
||||
"h3": "2.0.1-rc.8",
|
||||
"lucide-react": "^0.562.0",
|
||||
"h3": "2.0.1-rc.11",
|
||||
"lucide-react": "^0.563.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"nitro": "^3.0.1-alpha.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"nitro": "^3.0.1-alpha.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite-tsconfig-paths": "^6.0.4",
|
||||
"vite-tsconfig-paths": "^6.0.5",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.11",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tanstack/devtools-vite": "^0.4.1",
|
||||
"@biomejs/biome": "^2.3.13",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@tanstack/devtools-vite": "^0.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@types/node": "^25.0.9",
|
||||
"@types/react": "^19.2.8",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"jsdom": "^27.4.0",
|
||||
"only-allow": "^1.2.1",
|
||||
"oxlint": "^1.39.0",
|
||||
"oxlint-tsgolint": "^0.11.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"jsdom": "^28.0.0",
|
||||
"only-allow": "^1.2.2",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.11.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.17"
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# `clawhub`
|
||||
|
||||
OpenClaw CLI — install, update, search, and publish agent skills as folders.
|
||||
ClawHub CLI — install, update, search, and publish agent skills as folders.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clawhub",
|
||||
"version": "0.4.0",
|
||||
"description": "OpenClaw CLI \\u2014 install, update, search, and publish agent skills.",
|
||||
"version": "0.6.0",
|
||||
"description": "ClawHub CLI \\u2014 install, update, search, and publish agent skills.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('browserAuth', () => {
|
||||
const response = await fetch(server.redirectUri)
|
||||
expect(response.status).toBe(200)
|
||||
const text = await response.text()
|
||||
expect(text).toContain('OpenClaw CLI Login')
|
||||
expect(text).toContain('ClawHub CLI Login')
|
||||
server.close()
|
||||
})
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ const CALLBACK_HTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenClaw CLI Login</title>
|
||||
<title>ClawHub CLI Login</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; padding: 24px; }
|
||||
|
||||
@@ -5,7 +5,14 @@ import { Command } from 'commander'
|
||||
import { getCliBuildLabel, getCliVersion } from './cli/buildInfo.js'
|
||||
import { resolveClawdbotDefaultWorkspace } from './cli/clawdbotConfig.js'
|
||||
import { cmdLoginFlow, cmdLogout, cmdWhoami } from './cli/commands/auth.js'
|
||||
import { cmdDeleteSkill, cmdUndeleteSkill } from './cli/commands/delete.js'
|
||||
import {
|
||||
cmdDeleteSkill,
|
||||
cmdHideSkill,
|
||||
cmdUndeleteSkill,
|
||||
cmdUnhideSkill,
|
||||
} from './cli/commands/delete.js'
|
||||
import { cmdInspect } from './cli/commands/inspect.js'
|
||||
import { cmdBanUser, cmdSetRole } from './cli/commands/moderation.js'
|
||||
import { cmdPublish } from './cli/commands/publish.js'
|
||||
import { cmdExplore, cmdInstall, cmdList, cmdSearch, cmdUpdate } from './cli/commands/skills.js'
|
||||
import { cmdStarSkill } from './cli/commands/star.js'
|
||||
@@ -20,7 +27,7 @@ import { readGlobalConfig } from './config.js'
|
||||
const program = new Command()
|
||||
.name('clawhub')
|
||||
.description(
|
||||
`${styleTitle(`OpenClaw CLI ${getCliBuildLabel()}`)}\n${styleEnvBlock(
|
||||
`${styleTitle(`ClawHub CLI ${getCliBuildLabel()}`)}\n${styleEnvBlock(
|
||||
'install, update, search, and publish agent skills.',
|
||||
)}`,
|
||||
)
|
||||
@@ -221,6 +228,22 @@ program
|
||||
await cmdExplore(opts, { limit, sort: options.sort, json: options.json })
|
||||
})
|
||||
|
||||
program
|
||||
.command('inspect')
|
||||
.description('Fetch skill metadata and files without installing')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--version <version>', 'Version to inspect')
|
||||
.option('--tag <tag>', 'Tag to inspect (default: latest)')
|
||||
.option('--versions', 'List version history (first page)')
|
||||
.option('--limit <n>', 'Max versions to list (1-200)', (value) => Number.parseInt(value, 10))
|
||||
.option('--files', 'List files for the selected version')
|
||||
.option('--file <path>', 'Fetch raw file content (text <= 200KB)')
|
||||
.option('--json', 'Output JSON')
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
await cmdInspect(opts, slug, options)
|
||||
})
|
||||
|
||||
program
|
||||
.command('publish')
|
||||
.description('Publish skill from folder')
|
||||
@@ -238,7 +261,7 @@ program
|
||||
|
||||
program
|
||||
.command('delete')
|
||||
.description('Soft-delete a skill (owner/admin only)')
|
||||
.description('Soft-delete a skill (moderator/admin only)')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
@@ -246,9 +269,19 @@ program
|
||||
await cmdDeleteSkill(opts, slug, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('hide')
|
||||
.description('Hide a skill (moderator/admin only)')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
await cmdHideSkill(opts, slug, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('undelete')
|
||||
.description('Restore a soft-deleted skill (owner/admin only)')
|
||||
.description('Restore a hidden skill (moderator/admin only)')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
@@ -256,6 +289,42 @@ program
|
||||
await cmdUndeleteSkill(opts, slug, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('unhide')
|
||||
.description('Unhide a skill (moderator/admin only)')
|
||||
.argument('<slug>', 'Skill slug')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
await cmdUnhideSkill(opts, slug, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('ban-user')
|
||||
.description('Ban a user and delete owned skills (moderator/admin only)')
|
||||
.argument('<handleOrId>', 'User handle (default) or user id')
|
||||
.option('--id', 'Treat argument as user id')
|
||||
.option('--fuzzy', 'Resolve handle via fuzzy user search (admin only)')
|
||||
.option('--reason <reason>', 'Ban reason (optional)')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (handleOrId, options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
await cmdBanUser(opts, handleOrId, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('set-role')
|
||||
.description('Change a user role (admin only)')
|
||||
.argument('<handleOrId>', 'User handle (default) or user id')
|
||||
.argument('<role>', 'user | moderator | admin')
|
||||
.option('--id', 'Treat argument as user id')
|
||||
.option('--fuzzy', 'Resolve handle via fuzzy user search (admin only)')
|
||||
.option('--yes', 'Skip confirmation')
|
||||
.action(async (handleOrId, role, options) => {
|
||||
const opts = await resolveGlobalOpts()
|
||||
await cmdSetRole(opts, handleOrId, role, options, isInputAllowed())
|
||||
})
|
||||
|
||||
program
|
||||
.command('star')
|
||||
.description('Add a skill to your highlights')
|
||||
|
||||
@@ -37,7 +37,8 @@ export async function cmdLoginFlow(
|
||||
|
||||
const result = await receiver.waitForResult()
|
||||
const registry = result.registry?.trim() || opts.registry
|
||||
await cmdLogin({ ...opts, registry }, result.token, inputAllowed)
|
||||
const registrySource = result.registry?.trim() ? 'cli' : opts.registrySource
|
||||
await cmdLogin({ ...opts, registry, registrySource }, result.token, inputAllowed)
|
||||
}
|
||||
|
||||
export async function cmdLogin(
|
||||
@@ -47,7 +48,7 @@ export async function cmdLogin(
|
||||
) {
|
||||
if (!tokenFlag && !inputAllowed) fail('Token required (use --token or remove --no-input)')
|
||||
|
||||
const token = tokenFlag || (await promptHidden('OpenClaw token: '))
|
||||
const token = tokenFlag || (await promptHidden('ClawHub token: '))
|
||||
if (!token) fail('Token required')
|
||||
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
|
||||
@@ -29,7 +29,7 @@ vi.mock('../ui.js', () => ({
|
||||
promptConfirm: vi.fn(async () => true),
|
||||
}))
|
||||
|
||||
const { cmdDeleteSkill, cmdUndeleteSkill } = await import('./delete')
|
||||
const { cmdDeleteSkill, cmdHideSkill, cmdUndeleteSkill, cmdUnhideSkill } = await import('./delete')
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
@@ -49,6 +49,8 @@ describe('delete/undelete', () => {
|
||||
it('requires --yes when input is disabled', async () => {
|
||||
await expect(cmdDeleteSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
|
||||
await expect(cmdUndeleteSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
|
||||
await expect(cmdHideSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
|
||||
await expect(cmdUnhideSkill(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
|
||||
})
|
||||
|
||||
it('calls delete endpoint with --yes', async () => {
|
||||
@@ -70,4 +72,20 @@ describe('delete/undelete', () => {
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('supports hide/unhide aliases', async () => {
|
||||
mockApiRequest.mockResolvedValue({ ok: true })
|
||||
await cmdHideSkill(makeOpts(), 'demo', { yes: true }, false)
|
||||
await cmdUnhideSkill(makeOpts(), 'demo', { yes: true }, false)
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: 'DELETE', path: '/api/v1/skills/demo' }),
|
||||
expect.anything(),
|
||||
)
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: 'POST', path: '/api/v1/skills/demo/undelete' }),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,41 @@ import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
|
||||
|
||||
type SkillActionLabels = {
|
||||
verb: string
|
||||
progress: string
|
||||
past: string
|
||||
promptSuffix?: string
|
||||
}
|
||||
|
||||
const deleteLabels: SkillActionLabels = {
|
||||
verb: 'Delete',
|
||||
progress: 'Deleting',
|
||||
past: 'Deleted',
|
||||
promptSuffix: 'soft delete, requires moderator/admin',
|
||||
}
|
||||
|
||||
const undeleteLabels: SkillActionLabels = {
|
||||
verb: 'Undelete',
|
||||
progress: 'Undeleting',
|
||||
past: 'Undeleted',
|
||||
promptSuffix: 'requires moderator/admin',
|
||||
}
|
||||
|
||||
const hideLabels: SkillActionLabels = {
|
||||
verb: 'Hide',
|
||||
progress: 'Hiding',
|
||||
past: 'Hidden',
|
||||
promptSuffix: 'requires moderator/admin',
|
||||
}
|
||||
|
||||
const unhideLabels: SkillActionLabels = {
|
||||
verb: 'Unhide',
|
||||
progress: 'Unhiding',
|
||||
past: 'Unhidden',
|
||||
promptSuffix: 'requires moderator/admin',
|
||||
}
|
||||
|
||||
async function requireToken() {
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
@@ -17,6 +52,7 @@ export async function cmdDeleteSkill(
|
||||
slugArg: string,
|
||||
options: { yes?: boolean },
|
||||
inputAllowed: boolean,
|
||||
labels: SkillActionLabels = deleteLabels,
|
||||
) {
|
||||
const slug = slugArg.trim().toLowerCase()
|
||||
if (!slug) fail('Slug required')
|
||||
@@ -24,20 +60,20 @@ export async function cmdDeleteSkill(
|
||||
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail('Pass --yes (no input)')
|
||||
const ok = await promptConfirm(`Delete ${slug}? (soft delete)`)
|
||||
const ok = await promptConfirm(formatPrompt(labels, slug))
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const token = await requireToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner(`Deleting ${slug}`)
|
||||
const spinner = createSpinner(`${labels.progress} ${slug}`)
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{ method: 'DELETE', path: `${ApiRoutes.skills}/${encodeURIComponent(slug)}`, token },
|
||||
ApiV1DeleteResponseSchema,
|
||||
)
|
||||
spinner.succeed(`OK. Deleted ${slug}`)
|
||||
spinner.succeed(`OK. ${labels.past} ${slug}`)
|
||||
return parseArk(ApiV1DeleteResponseSchema, result, 'Delete response')
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error))
|
||||
@@ -50,6 +86,7 @@ export async function cmdUndeleteSkill(
|
||||
slugArg: string,
|
||||
options: { yes?: boolean },
|
||||
inputAllowed: boolean,
|
||||
labels: SkillActionLabels = undeleteLabels,
|
||||
) {
|
||||
const slug = slugArg.trim().toLowerCase()
|
||||
if (!slug) fail('Slug required')
|
||||
@@ -57,13 +94,13 @@ export async function cmdUndeleteSkill(
|
||||
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail('Pass --yes (no input)')
|
||||
const ok = await promptConfirm(`Undelete ${slug}?`)
|
||||
const ok = await promptConfirm(formatPrompt(labels, slug))
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const token = await requireToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner(`Undeleting ${slug}`)
|
||||
const spinner = createSpinner(`${labels.progress} ${slug}`)
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
@@ -74,10 +111,33 @@ export async function cmdUndeleteSkill(
|
||||
},
|
||||
ApiV1DeleteResponseSchema,
|
||||
)
|
||||
spinner.succeed(`OK. Undeleted ${slug}`)
|
||||
spinner.succeed(`OK. ${labels.past} ${slug}`)
|
||||
return parseArk(ApiV1DeleteResponseSchema, result, 'Undelete response')
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdHideSkill(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: { yes?: boolean },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
return cmdDeleteSkill(opts, slugArg, options, inputAllowed, hideLabels)
|
||||
}
|
||||
|
||||
export async function cmdUnhideSkill(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: { yes?: boolean },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
return cmdUndeleteSkill(opts, slugArg, options, inputAllowed, unhideLabels)
|
||||
}
|
||||
|
||||
function formatPrompt(labels: SkillActionLabels, slug: string) {
|
||||
const suffix = labels.promptSuffix ? ` (${labels.promptSuffix})` : ''
|
||||
return `${labels.verb} ${slug}?${suffix}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiRoutes } from '../../schema/index.js'
|
||||
import type { GlobalOpts } from '../types'
|
||||
|
||||
const mockApiRequest = vi.fn()
|
||||
const mockFetchText = vi.fn()
|
||||
vi.mock('../../http.js', () => ({
|
||||
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
|
||||
fetchText: (...args: unknown[]) => mockFetchText(...args),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai')
|
||||
vi.mock('../registry.js', () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}))
|
||||
|
||||
const mockSpinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
start: vi.fn(),
|
||||
succeed: vi.fn(),
|
||||
isSpinning: false,
|
||||
text: '',
|
||||
}
|
||||
vi.mock('../ui.js', () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message)
|
||||
},
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
}))
|
||||
|
||||
const { cmdInspect } = await import('./inspect')
|
||||
|
||||
const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const mockWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: '/work',
|
||||
dir: '/work/skills',
|
||||
site: 'https://clawhub.ai',
|
||||
registry: 'https://clawhub.ai',
|
||||
registrySource: 'default',
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLog.mockClear()
|
||||
mockWrite.mockClear()
|
||||
})
|
||||
|
||||
describe('cmdInspect', () => {
|
||||
it('fetches latest version files when --files is set', async () => {
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
skill: {
|
||||
slug: 'demo',
|
||||
displayName: 'Demo',
|
||||
summary: null,
|
||||
tags: { latest: '1.2.3' },
|
||||
stats: {},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '1.2.3', createdAt: 3, changelog: 'init' },
|
||||
owner: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
skill: { slug: 'demo', displayName: 'Demo' },
|
||||
version: { version: '1.2.3', createdAt: 3, changelog: 'init', files: [] },
|
||||
})
|
||||
|
||||
await cmdInspect(makeOpts(), 'demo', { files: true })
|
||||
|
||||
const firstArgs = mockApiRequest.mock.calls[0]?.[1]
|
||||
const secondArgs = mockApiRequest.mock.calls[1]?.[1]
|
||||
expect(firstArgs?.path).toBe(`${ApiRoutes.skills}/${encodeURIComponent('demo')}`)
|
||||
expect(secondArgs?.path).toBe(
|
||||
`${ApiRoutes.skills}/${encodeURIComponent('demo')}/versions/${encodeURIComponent('1.2.3')}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses tag param when fetching a file', async () => {
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
skill: {
|
||||
slug: 'demo',
|
||||
displayName: 'Demo',
|
||||
summary: null,
|
||||
tags: { latest: '2.0.0' },
|
||||
stats: {},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'init' },
|
||||
owner: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
skill: { slug: 'demo', displayName: 'Demo' },
|
||||
version: { version: '2.0.0', createdAt: 3, changelog: 'init', files: [] },
|
||||
})
|
||||
mockFetchText.mockResolvedValue('content')
|
||||
|
||||
await cmdInspect(makeOpts(), 'demo', { file: 'SKILL.md', tag: 'latest' })
|
||||
|
||||
const fetchArgs = mockFetchText.mock.calls[0]?.[1]
|
||||
const url = new URL(String(fetchArgs?.url))
|
||||
expect(url.pathname).toBe('/api/v1/skills/demo/file')
|
||||
expect(url.searchParams.get('path')).toBe('SKILL.md')
|
||||
expect(url.searchParams.get('tag')).toBe('latest')
|
||||
expect(url.searchParams.get('version')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects when both version and tag are provided', async () => {
|
||||
await expect(
|
||||
cmdInspect(makeOpts(), 'demo', { version: '1.0.0', tag: 'latest' }),
|
||||
).rejects.toThrow('Use either --version or --tag')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
import { apiRequest, fetchText } from '../../http.js'
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1SkillResponseSchema,
|
||||
ApiV1SkillVersionListResponseSchema,
|
||||
ApiV1SkillVersionResponseSchema,
|
||||
} from '../../schema/index.js'
|
||||
import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError } from '../ui.js'
|
||||
|
||||
type InspectOptions = {
|
||||
version?: string
|
||||
tag?: string
|
||||
versions?: boolean
|
||||
limit?: number
|
||||
files?: boolean
|
||||
file?: string
|
||||
json?: boolean
|
||||
}
|
||||
|
||||
type FileEntry = {
|
||||
path: string
|
||||
size: number | null
|
||||
sha256: string | null
|
||||
contentType: string | null
|
||||
}
|
||||
|
||||
export async function cmdInspect(opts: GlobalOpts, slug: string, options: InspectOptions = {}) {
|
||||
const trimmed = slug.trim()
|
||||
if (!trimmed) fail('Slug required')
|
||||
if (options.version && options.tag) fail('Use either --version or --tag')
|
||||
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const spinner = createSpinner('Fetching skill')
|
||||
try {
|
||||
const skillResult = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
|
||||
ApiV1SkillResponseSchema,
|
||||
)
|
||||
|
||||
if (!skillResult.skill) {
|
||||
spinner.fail('Skill not found')
|
||||
return
|
||||
}
|
||||
|
||||
const skill = skillResult.skill
|
||||
const tags = normalizeTags(skill.tags)
|
||||
const latestVersion = skillResult.latestVersion?.version ?? tags.latest ?? null
|
||||
const taggedVersion = options.tag ? (tags[options.tag] ?? null) : null
|
||||
if (options.tag && !taggedVersion) {
|
||||
spinner.fail(`Unknown tag "${options.tag}"`)
|
||||
return
|
||||
}
|
||||
const requestedVersion = options.version ?? taggedVersion ?? null
|
||||
|
||||
let versionResult: { version: unknown; skill: unknown } | null = null
|
||||
if (options.files || options.file || options.version || options.tag) {
|
||||
const targetVersion = requestedVersion ?? latestVersion
|
||||
if (!targetVersion) fail('Could not resolve latest version')
|
||||
spinner.text = `Fetching ${trimmed}@${targetVersion}`
|
||||
versionResult = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: 'GET',
|
||||
path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions/${encodeURIComponent(
|
||||
targetVersion,
|
||||
)}`,
|
||||
},
|
||||
ApiV1SkillVersionResponseSchema,
|
||||
)
|
||||
}
|
||||
|
||||
let versionsList: { items?: unknown[]; nextCursor?: string | null } | null = null
|
||||
if (options.versions) {
|
||||
const limit = clampLimit(options.limit ?? 25, 25)
|
||||
const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions`, registry)
|
||||
url.searchParams.set('limit', String(limit))
|
||||
spinner.text = `Fetching versions (${limit})`
|
||||
versionsList = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', url: url.toString() },
|
||||
ApiV1SkillVersionListResponseSchema,
|
||||
)
|
||||
}
|
||||
|
||||
let fileContent: string | null = null
|
||||
if (options.file) {
|
||||
const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/file`, registry)
|
||||
url.searchParams.set('path', options.file)
|
||||
if (options.version) {
|
||||
url.searchParams.set('version', options.version)
|
||||
} else if (options.tag) {
|
||||
url.searchParams.set('tag', options.tag)
|
||||
} else if (latestVersion) {
|
||||
url.searchParams.set('version', latestVersion)
|
||||
}
|
||||
spinner.text = `Fetching ${options.file}`
|
||||
fileContent = await fetchText(registry, { url: url.toString() })
|
||||
}
|
||||
|
||||
spinner.stop()
|
||||
|
||||
const output = {
|
||||
skill: skillResult.skill,
|
||||
latestVersion: skillResult.latestVersion,
|
||||
owner: skillResult.owner,
|
||||
version: versionResult?.version ?? null,
|
||||
versions: versionsList?.items ?? null,
|
||||
file: options.file ? { path: options.file, content: fileContent } : null,
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(output, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
const shouldPrintMeta = !options.file || options.files || options.versions || options.version
|
||||
if (shouldPrintMeta) {
|
||||
printSkillSummary({
|
||||
skill,
|
||||
latestVersion: skillResult.latestVersion,
|
||||
owner: skillResult.owner,
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldPrintMeta && versionResult?.version) {
|
||||
printVersionSummary(versionResult.version)
|
||||
}
|
||||
|
||||
if (versionsList?.items && Array.isArray(versionsList.items)) {
|
||||
if (versionsList.items.length === 0) {
|
||||
console.log('No versions found.')
|
||||
} else {
|
||||
console.log('Versions:')
|
||||
for (const item of versionsList.items) {
|
||||
console.log(formatVersionLine(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (versionResult?.version) {
|
||||
const files = normalizeFiles((versionResult.version as { files?: unknown }).files)
|
||||
if (options.files) {
|
||||
if (files.length === 0) {
|
||||
console.log('No files found.')
|
||||
} else {
|
||||
console.log('Files:')
|
||||
for (const file of files) {
|
||||
console.log(formatFileLine(file))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.file && fileContent !== null) {
|
||||
if (shouldPrintMeta) console.log(`\n${options.file}:\n`)
|
||||
process.stdout.write(fileContent)
|
||||
if (!fileContent.endsWith('\n')) process.stdout.write('\n')
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function printSkillSummary(result: {
|
||||
skill: {
|
||||
slug: string
|
||||
displayName: string
|
||||
summary?: string | null
|
||||
tags?: unknown
|
||||
stats?: unknown
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
latestVersion?: { version: string; createdAt: number; changelog: string } | null
|
||||
owner?: { handle?: string | null; displayName?: string | null; image?: string | null } | null
|
||||
}) {
|
||||
const { skill } = result
|
||||
console.log(`${skill.slug} ${skill.displayName}`)
|
||||
if (skill.summary) console.log(`Summary: ${skill.summary}`)
|
||||
const owner = result.owner?.handle || result.owner?.displayName
|
||||
if (owner) console.log(`Owner: ${owner}`)
|
||||
console.log(`Created: ${formatTimestamp(skill.createdAt)}`)
|
||||
console.log(`Updated: ${formatTimestamp(skill.updatedAt)}`)
|
||||
if (result.latestVersion?.version) {
|
||||
console.log(`Latest: ${result.latestVersion.version}`)
|
||||
}
|
||||
const tags = normalizeTags(skill.tags)
|
||||
const tagEntries = Object.entries(tags)
|
||||
if (tagEntries.length > 0) {
|
||||
console.log(`Tags: ${tagEntries.map(([tag, version]) => `${tag}=${version}`).join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function printVersionSummary(version: unknown) {
|
||||
if (!version || typeof version !== 'object') return
|
||||
const entry = version as { version?: unknown; createdAt?: unknown; changelog?: unknown }
|
||||
const value = typeof entry.version === 'string' ? entry.version : null
|
||||
if (!value) return
|
||||
console.log(`Selected: ${value}`)
|
||||
if (typeof entry.createdAt === 'number') {
|
||||
console.log(`Selected At: ${formatTimestamp(entry.createdAt)}`)
|
||||
}
|
||||
if (typeof entry.changelog === 'string' && entry.changelog.trim()) {
|
||||
console.log(`Changelog: ${truncate(entry.changelog, 120)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTags(tags: unknown): Record<string, string> {
|
||||
if (!tags || typeof tags !== 'object') return {}
|
||||
const entries = Object.entries(tags as Record<string, unknown>)
|
||||
const resolved: Record<string, string> = {}
|
||||
for (const [tag, version] of entries) {
|
||||
if (typeof version === 'string') resolved[tag] = version
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function normalizeFiles(files: unknown): FileEntry[] {
|
||||
if (!Array.isArray(files)) return []
|
||||
return files
|
||||
.map((file) => {
|
||||
if (!file || typeof file !== 'object') return null
|
||||
const entry = file as {
|
||||
path?: unknown
|
||||
size?: unknown
|
||||
sha256?: unknown
|
||||
contentType?: unknown
|
||||
}
|
||||
if (typeof entry.path !== 'string') return null
|
||||
const size = typeof entry.size === 'number' ? entry.size : Number(entry.size)
|
||||
const sha256 = typeof entry.sha256 === 'string' ? entry.sha256 : null
|
||||
const contentType = typeof entry.contentType === 'string' ? entry.contentType : null
|
||||
return {
|
||||
path: entry.path,
|
||||
size: Number.isFinite(size) ? size : null,
|
||||
sha256,
|
||||
contentType,
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is FileEntry => Boolean(entry))
|
||||
}
|
||||
|
||||
function formatVersionLine(item: unknown) {
|
||||
if (!item || typeof item !== 'object') return '-'
|
||||
const entry = item as { version?: unknown; createdAt?: unknown; changelog?: unknown }
|
||||
const version = typeof entry.version === 'string' ? entry.version : '?'
|
||||
const createdAt =
|
||||
typeof entry.createdAt === 'number' ? formatTimestamp(entry.createdAt) : 'unknown'
|
||||
const changelog = typeof entry.changelog === 'string' ? entry.changelog : ''
|
||||
const snippet = changelog ? ` ${truncate(changelog, 80)}` : ''
|
||||
return `${version} ${createdAt}${snippet}`
|
||||
}
|
||||
|
||||
function formatFileLine(file: FileEntry) {
|
||||
const size = file.size === null ? '?' : formatBytes(file.size)
|
||||
const sha = file.sha256 ?? '?'
|
||||
const type = file.contentType ? ` ${file.contentType}` : ''
|
||||
return `${file.path} ${size} ${sha}${type}`
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: number) {
|
||||
if (!Number.isFinite(timestamp)) return 'unknown'
|
||||
return new Date(timestamp).toISOString()
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes)) return '?'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let value = bytes
|
||||
let index = 0
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024
|
||||
index += 1
|
||||
}
|
||||
const rounded = value >= 10 ? Math.round(value) : Math.round(value * 10) / 10
|
||||
return `${rounded}${units[index]}`
|
||||
}
|
||||
|
||||
function clampLimit(limit: number, fallback: number) {
|
||||
if (!Number.isFinite(limit)) return fallback
|
||||
return Math.min(Math.max(1, Math.round(limit)), 200)
|
||||
}
|
||||
|
||||
function truncate(str: string, maxLen: number) {
|
||||
if (str.length <= maxLen) return str
|
||||
return `${str.slice(0, maxLen - 3)}...`
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalOpts } from '../types'
|
||||
|
||||
vi.mock('../../config.js', () => ({
|
||||
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawhub.ai', token: 'tkn' })),
|
||||
}))
|
||||
|
||||
vi.mock('../registry.js', () => ({
|
||||
getRegistry: vi.fn(async () => 'https://clawhub.ai'),
|
||||
}))
|
||||
|
||||
const mockApiRequest = vi.fn()
|
||||
vi.mock('../../http.js', () => ({
|
||||
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
mockApiRequest(registry, args, schema),
|
||||
}))
|
||||
|
||||
vi.mock('../ui.js', () => ({
|
||||
createSpinner: vi.fn(() => ({ succeed: vi.fn(), fail: vi.fn() })),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message)
|
||||
},
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => false,
|
||||
promptConfirm: vi.fn(async () => true),
|
||||
}))
|
||||
|
||||
const { cmdBanUser, cmdSetRole } = await import('./moderation')
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: '/work',
|
||||
dir: '/work/skills',
|
||||
site: 'https://clawhub.ai',
|
||||
registry: 'https://clawhub.ai',
|
||||
registrySource: 'default',
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('cmdBanUser', () => {
|
||||
it('requires --yes when input is disabled', async () => {
|
||||
await expect(cmdBanUser(makeOpts(), 'demo', {}, false)).rejects.toThrow(/--yes/i)
|
||||
})
|
||||
|
||||
it('posts handle payload', async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 1 })
|
||||
await cmdBanUser(makeOpts(), 'hightower6eu', { yes: true }, false)
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
path: '/api/v1/users/ban',
|
||||
body: { handle: 'hightower6eu' },
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('includes reason when provided', async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 })
|
||||
await cmdBanUser(
|
||||
makeOpts(),
|
||||
'hightower6eu',
|
||||
{ yes: true, reason: 'malware distribution' },
|
||||
false,
|
||||
)
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
path: '/api/v1/users/ban',
|
||||
body: { handle: 'hightower6eu', reason: 'malware distribution' },
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('posts user id payload when --id is set', async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 })
|
||||
await cmdBanUser(makeOpts(), 'user_123', { yes: true, id: true }, false)
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
path: '/api/v1/users/ban',
|
||||
body: { userId: 'user_123' },
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves user via fuzzy search', async () => {
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
userId: 'users_123',
|
||||
handle: 'moonshine-100rze',
|
||||
displayName: null,
|
||||
name: null,
|
||||
role: 'user',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 })
|
||||
await cmdBanUser(makeOpts(), 'moonshine-100rze', { yes: true, fuzzy: true }, false)
|
||||
expect(mockApiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
path: expect.stringContaining('/api/v1/users?'),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
expect(mockApiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
path: '/api/v1/users/ban',
|
||||
body: { userId: 'users_123' },
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('fails fuzzy search with multiple matches when not interactive', async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
userId: 'users_1',
|
||||
handle: 'moonshine-100rze',
|
||||
displayName: null,
|
||||
name: null,
|
||||
role: null,
|
||||
},
|
||||
{
|
||||
userId: 'users_2',
|
||||
handle: 'moonshine-100rze2',
|
||||
displayName: null,
|
||||
name: null,
|
||||
role: null,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
})
|
||||
await expect(
|
||||
cmdBanUser(makeOpts(), 'moonshine', { yes: true, fuzzy: true }, false),
|
||||
).rejects.toThrow(/multiple users matched/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cmdSetRole', () => {
|
||||
it('requires --yes when input is disabled', async () => {
|
||||
await expect(cmdSetRole(makeOpts(), 'demo', 'moderator', {}, false)).rejects.toThrow(/--yes/i)
|
||||
})
|
||||
|
||||
it('rejects invalid roles', async () => {
|
||||
await expect(cmdSetRole(makeOpts(), 'demo', 'owner', { yes: true }, false)).rejects.toThrow(
|
||||
/role/i,
|
||||
)
|
||||
})
|
||||
|
||||
it('posts handle payload', async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, role: 'moderator' })
|
||||
await cmdSetRole(makeOpts(), 'hightower6eu', 'moderator', { yes: true }, false)
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
path: '/api/v1/users/role',
|
||||
body: { handle: 'hightower6eu', role: 'moderator' },
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('posts user id payload when --id is set', async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, role: 'admin' })
|
||||
await cmdSetRole(makeOpts(), 'user_123', 'admin', { yes: true, id: true }, false)
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
path: '/api/v1/users/role',
|
||||
body: { userId: 'user_123', role: 'admin' },
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
import { isCancel, select } from '@clack/prompts'
|
||||
import { readGlobalConfig } from '../../config.js'
|
||||
import { apiRequest } from '../../http.js'
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1BanUserResponseSchema,
|
||||
ApiV1SetRoleResponseSchema,
|
||||
ApiV1UserSearchResponseSchema,
|
||||
parseArk,
|
||||
} from '../../schema/index.js'
|
||||
import { getRegistry } from '../registry.js'
|
||||
import type { GlobalOpts } from '../types.js'
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from '../ui.js'
|
||||
|
||||
async function requireToken() {
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
if (!token) fail('Not logged in. Run: clawhub login')
|
||||
return token
|
||||
}
|
||||
|
||||
export async function cmdBanUser(
|
||||
opts: GlobalOpts,
|
||||
identifierArg: string,
|
||||
options: { yes?: boolean; id?: boolean; fuzzy?: boolean; reason?: string },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const raw = identifierArg.trim()
|
||||
if (!raw) fail('Handle or user id required')
|
||||
|
||||
const reason = options.reason?.trim() || undefined
|
||||
|
||||
const token = await requireToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false
|
||||
const resolved = await resolveUserIdentifier(
|
||||
registry,
|
||||
token,
|
||||
raw,
|
||||
{ id: options.id, fuzzy: options.fuzzy },
|
||||
allowPrompt,
|
||||
)
|
||||
if (!resolved) return
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail('Pass --yes (no input)')
|
||||
const ok = await promptConfirm(
|
||||
`Ban ${resolved.label}? (requires moderator/admin; deletes owned skills)`,
|
||||
)
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const spinner = createSpinner(`Banning ${resolved.label}`)
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: 'POST',
|
||||
path: `${ApiRoutes.users}/ban`,
|
||||
token,
|
||||
body: resolved.userId
|
||||
? { userId: resolved.userId, reason }
|
||||
: { handle: resolved.handle, reason },
|
||||
},
|
||||
ApiV1BanUserResponseSchema,
|
||||
)
|
||||
const parsed = parseArk(ApiV1BanUserResponseSchema, result, 'Ban user response')
|
||||
if (parsed.alreadyBanned) {
|
||||
spinner.succeed(`OK. ${resolved.label} already banned`)
|
||||
return parsed
|
||||
}
|
||||
spinner.succeed(`OK. Banned ${resolved.label} (${formatDeletedSkills(parsed.deletedSkills)})`)
|
||||
return parsed
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdSetRole(
|
||||
opts: GlobalOpts,
|
||||
identifierArg: string,
|
||||
roleArg: string,
|
||||
options: { yes?: boolean; id?: boolean; fuzzy?: boolean },
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const raw = identifierArg.trim()
|
||||
if (!raw) fail('Handle or user id required')
|
||||
const role = normalizeRole(roleArg)
|
||||
|
||||
const token = await requireToken()
|
||||
const registry = await getRegistry(opts, { cache: true })
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false
|
||||
const resolved = await resolveUserIdentifier(
|
||||
registry,
|
||||
token,
|
||||
raw,
|
||||
{ id: options.id, fuzzy: options.fuzzy },
|
||||
allowPrompt,
|
||||
)
|
||||
if (!resolved) return
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail('Pass --yes (no input)')
|
||||
const ok = await promptConfirm(`Set role for ${resolved.label} to ${role}? (admin only)`)
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const spinner = createSpinner(`Setting role for ${resolved.label}`)
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: 'POST',
|
||||
path: `${ApiRoutes.users}/role`,
|
||||
token,
|
||||
body: resolved.userId
|
||||
? { userId: resolved.userId, role }
|
||||
: { handle: resolved.handle, role },
|
||||
},
|
||||
ApiV1SetRoleResponseSchema,
|
||||
)
|
||||
const parsed = parseArk(ApiV1SetRoleResponseSchema, result, 'Set role response')
|
||||
spinner.succeed(`OK. ${resolved.label} is now ${parsed.role}`)
|
||||
return parsed
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHandle(value: string) {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.startsWith('@') ? trimmed.slice(1).toLowerCase() : trimmed.toLowerCase()
|
||||
}
|
||||
|
||||
type ResolvedUser = {
|
||||
handle: string | null
|
||||
userId: string | null
|
||||
label: string
|
||||
}
|
||||
|
||||
type UserSearchItem = {
|
||||
userId: string
|
||||
handle: string | null
|
||||
displayName?: string | null
|
||||
name?: string | null
|
||||
role?: 'admin' | 'moderator' | 'user' | null
|
||||
}
|
||||
|
||||
async function resolveUserIdentifier(
|
||||
registry: string,
|
||||
token: string,
|
||||
raw: string,
|
||||
options: { id?: boolean; fuzzy?: boolean },
|
||||
allowPrompt: boolean,
|
||||
): Promise<ResolvedUser | null> {
|
||||
const usesId = Boolean(options.id)
|
||||
if (usesId) {
|
||||
return { handle: null, userId: raw, label: raw }
|
||||
}
|
||||
|
||||
const handle = normalizeHandle(raw)
|
||||
if (!options.fuzzy) {
|
||||
return { handle, userId: null, label: `@${handle}` }
|
||||
}
|
||||
|
||||
const matches = await searchUsers(registry, token, raw)
|
||||
if (matches.items.length === 0) {
|
||||
fail(`No users matched "${raw}".`)
|
||||
}
|
||||
|
||||
if (matches.items.length === 1) {
|
||||
const match = matches.items[0] as UserSearchItem
|
||||
return {
|
||||
handle: match.handle ?? null,
|
||||
userId: match.userId,
|
||||
label: formatUserLabel(match),
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowPrompt) {
|
||||
fail(`Multiple users matched "${raw}". Use --id.\n${formatUserList(matches.items)}`)
|
||||
}
|
||||
|
||||
const choice = await select({
|
||||
message: `Select a user for "${raw}"`,
|
||||
options: matches.items.map((item) => ({
|
||||
value: item.userId,
|
||||
label: formatUserLabel(item),
|
||||
})),
|
||||
})
|
||||
if (isCancel(choice)) return null
|
||||
const selected = matches.items.find((item) => item.userId === choice)
|
||||
if (!selected) return null
|
||||
return {
|
||||
handle: selected.handle ?? null,
|
||||
userId: selected.userId,
|
||||
label: formatUserLabel(selected),
|
||||
}
|
||||
}
|
||||
|
||||
async function searchUsers(registry: string, token: string, query: string) {
|
||||
const url = new URL(ApiRoutes.users, registry)
|
||||
url.searchParams.set('q', query.trim())
|
||||
url.searchParams.set('limit', '10')
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${url.pathname}?${url.searchParams.toString()}`, token },
|
||||
ApiV1UserSearchResponseSchema,
|
||||
)
|
||||
return parseArk(ApiV1UserSearchResponseSchema, result, 'User search response')
|
||||
}
|
||||
|
||||
function formatUserLabel(user: UserSearchItem) {
|
||||
const handle = user.handle ? `@${user.handle}` : 'unknown'
|
||||
const name = user.displayName ?? user.name
|
||||
const role = user.role ? ` (${user.role})` : ''
|
||||
const label = name ? `${handle} — ${name}` : handle
|
||||
return `${label}${role} · ${user.userId}`
|
||||
}
|
||||
|
||||
function formatUserList(users: UserSearchItem[]) {
|
||||
return users.map((user) => `- ${formatUserLabel(user)}`).join('\n')
|
||||
}
|
||||
|
||||
function normalizeRole(value: string) {
|
||||
const role = value.trim().toLowerCase()
|
||||
if (role === 'user' || role === 'moderator' || role === 'admin') return role
|
||||
fail('Role must be user|moderator|admin')
|
||||
}
|
||||
|
||||
function formatDeletedSkills(count: number) {
|
||||
if (!Number.isFinite(count)) return 'deleted skills unknown'
|
||||
if (count === 1) return 'deleted 1 skill'
|
||||
return `deleted ${count} skills`
|
||||
}
|
||||
@@ -73,16 +73,36 @@ export async function cmdInstall(
|
||||
|
||||
const spinner = createSpinner(`Resolving ${trimmed}`)
|
||||
try {
|
||||
const resolvedVersion =
|
||||
versionFlag ??
|
||||
(
|
||||
await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
|
||||
ApiV1SkillResponseSchema,
|
||||
)
|
||||
).latestVersion?.version ??
|
||||
null
|
||||
// Fetch skill metadata including moderation status
|
||||
const skillMeta = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(trimmed)}` },
|
||||
ApiV1SkillResponseSchema,
|
||||
)
|
||||
|
||||
// Check moderation status before proceeding
|
||||
if (skillMeta.moderation?.isMalwareBlocked) {
|
||||
spinner.fail(`Blocked: ${trimmed} is flagged as malicious`)
|
||||
fail('This skill has been flagged as malware and cannot be installed.')
|
||||
}
|
||||
|
||||
if (skillMeta.moderation?.isSuspicious && !force) {
|
||||
spinner.stop()
|
||||
console.log(
|
||||
`\n⚠️ Warning: "${trimmed}" is flagged as suspicious by VirusTotal Code Insight.\n` +
|
||||
' This skill may contain risky patterns (crypto keys, external APIs, eval, etc.)\n' +
|
||||
' Review the skill code before use.\n',
|
||||
)
|
||||
if (isInteractive()) {
|
||||
const confirm = await promptConfirm('Install anyway?')
|
||||
if (!confirm) fail('Installation cancelled')
|
||||
spinner.start(`Resolving ${trimmed}`)
|
||||
} else {
|
||||
fail('Use --force to install suspicious skills in non-interactive mode')
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedVersion = versionFlag ?? skillMeta.latestVersion?.version ?? null
|
||||
if (!resolvedVersion) fail('Could not resolve latest version')
|
||||
|
||||
spinner.text = `Downloading ${trimmed}@${resolvedVersion}`
|
||||
@@ -138,6 +158,39 @@ export async function cmdUpdate(
|
||||
const target = join(opts.dir, entry)
|
||||
const exists = await fileExists(target)
|
||||
|
||||
// Always fetch skill metadata to check moderation status
|
||||
const skillMeta = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
|
||||
ApiV1SkillResponseSchema,
|
||||
)
|
||||
|
||||
// Check moderation status before proceeding
|
||||
if (skillMeta.moderation?.isMalwareBlocked) {
|
||||
spinner.fail(`${entry}: blocked as malicious`)
|
||||
console.log(' This skill has been flagged as malware and cannot be updated.')
|
||||
continue
|
||||
}
|
||||
|
||||
if (skillMeta.moderation?.isSuspicious && !options.force) {
|
||||
spinner.stop()
|
||||
console.log(
|
||||
`\n⚠️ Warning: "${entry}" is flagged as suspicious by VirusTotal Code Insight.\n` +
|
||||
' This skill may contain risky patterns (crypto keys, external APIs, eval, etc.)\n',
|
||||
)
|
||||
if (allowPrompt) {
|
||||
const confirm = await promptConfirm('Update anyway?')
|
||||
if (!confirm) {
|
||||
console.log(`${entry}: skipped`)
|
||||
continue
|
||||
}
|
||||
spinner.start(`Checking ${entry}`)
|
||||
} else {
|
||||
console.log(`${entry}: skipped (use --force to update suspicious skills)`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let localFingerprint: string | null = null
|
||||
if (exists) {
|
||||
const filesOnDisk = await listTextFiles(target)
|
||||
@@ -151,12 +204,7 @@ export async function cmdUpdate(
|
||||
if (localFingerprint) {
|
||||
resolveResult = await resolveSkillVersion(registry, entry, localFingerprint)
|
||||
} else {
|
||||
const meta = await apiRequest(
|
||||
registry,
|
||||
{ method: 'GET', path: `${ApiRoutes.skills}/${encodeURIComponent(entry)}` },
|
||||
ApiV1SkillResponseSchema,
|
||||
)
|
||||
resolveResult = { match: null, latestVersion: meta.latestVersion ?? null }
|
||||
resolveResult = { match: null, latestVersion: skillMeta.latestVersion ?? null }
|
||||
}
|
||||
|
||||
const latest = resolveResult.latestVersion?.version ?? null
|
||||
|
||||
@@ -30,7 +30,7 @@ import type { Candidate, LocalSkill, SyncOptions } from './syncTypes.js'
|
||||
|
||||
export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllowed: boolean) {
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false
|
||||
intro('OpenClaw sync')
|
||||
intro('ClawHub sync')
|
||||
|
||||
const cfg = await readGlobalConfig()
|
||||
const token = cfg?.token
|
||||
|
||||
@@ -127,6 +127,36 @@ export async function apiRequestForm<T>(
|
||||
return json as T
|
||||
}
|
||||
|
||||
type TextRequestArgs = { path: string; token?: string } | { url: string; token?: string }
|
||||
|
||||
export async function fetchText(registry: string, args: TextRequestArgs): Promise<string> {
|
||||
const url = 'url' in args ? args.url : new URL(args.path, registry).toString()
|
||||
return pRetry(
|
||||
async () => {
|
||||
if (isBun) {
|
||||
return await fetchTextViaCurl(url, args)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: 'text/plain' }
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS)
|
||||
const response = await fetch(url, { method: 'GET', headers, signal: controller.signal })
|
||||
clearTimeout(timeout)
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
const message = text || `HTTP ${response.status}`
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
throw new Error(message)
|
||||
}
|
||||
throw new AbortError(message)
|
||||
}
|
||||
return text
|
||||
},
|
||||
{ retries: 2 },
|
||||
)
|
||||
}
|
||||
|
||||
export async function downloadZip(registry: string, args: { slug: string; version?: string }) {
|
||||
const url = new URL(ApiRoutes.download, registry)
|
||||
url.searchParams.set('slug', args.slug)
|
||||
@@ -254,6 +284,43 @@ async function fetchJsonFormViaCurl(url: string, args: FormRequestArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTextViaCurl(url: string, args: { token?: string }) {
|
||||
const headers = ['-H', 'Accept: text/plain']
|
||||
if (args.token) {
|
||||
headers.push('-H', `Authorization: Bearer ${args.token}`)
|
||||
}
|
||||
const curlArgs = [
|
||||
'--silent',
|
||||
'--show-error',
|
||||
'--location',
|
||||
'--max-time',
|
||||
String(REQUEST_TIMEOUT_SECONDS),
|
||||
'--write-out',
|
||||
'\n%{http_code}',
|
||||
'-X',
|
||||
'GET',
|
||||
...headers,
|
||||
url,
|
||||
]
|
||||
const result = spawnSync('curl', curlArgs, { encoding: 'utf8' })
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || 'curl failed')
|
||||
}
|
||||
const output = result.stdout ?? ''
|
||||
const splitAt = output.lastIndexOf('\n')
|
||||
if (splitAt === -1) throw new Error('curl response missing status')
|
||||
const body = output.slice(0, splitAt)
|
||||
const status = Number(output.slice(splitAt + 1).trim())
|
||||
if (!Number.isFinite(status)) throw new Error('curl response missing status')
|
||||
if (status < 200 || status >= 300) {
|
||||
if (status === 429 || status >= 500) {
|
||||
throw new Error(body || `HTTP ${status}`)
|
||||
}
|
||||
throw new AbortError(body || `HTTP ${status}`)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
async function fetchBinaryViaCurl(url: string) {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'clawhub-download-'))
|
||||
const filePath = join(tempDir, 'payload.bin')
|
||||
|
||||
@@ -18,5 +18,6 @@ export const ApiRoutes = {
|
||||
skills: '/api/v1/skills',
|
||||
stars: '/api/v1/stars',
|
||||
souls: '/api/v1/souls',
|
||||
users: '/api/v1/users',
|
||||
whoami: '/api/v1/whoami',
|
||||
} as const
|
||||
|
||||
@@ -125,6 +125,17 @@ export const ApiV1WhoamiResponseSchema = type({
|
||||
},
|
||||
})
|
||||
|
||||
export const ApiV1UserSearchResponseSchema = type({
|
||||
items: type({
|
||||
userId: 'string',
|
||||
handle: 'string|null',
|
||||
displayName: 'string|null?',
|
||||
name: 'string|null?',
|
||||
role: '"admin"|"moderator"|"user"|null?',
|
||||
}).array(),
|
||||
total: 'number',
|
||||
})
|
||||
|
||||
export const ApiV1SearchResponseSchema = type({
|
||||
results: type({
|
||||
slug: 'string?',
|
||||
@@ -174,6 +185,12 @@ export const ApiV1SkillResponseSchema = type({
|
||||
displayName: 'string|null?',
|
||||
image: 'string|null?',
|
||||
}).or('null'),
|
||||
moderation: type({
|
||||
isSuspicious: 'boolean',
|
||||
isMalwareBlocked: 'boolean',
|
||||
})
|
||||
.or('null')
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const ApiV1SkillVersionListResponseSchema = type({
|
||||
@@ -215,6 +232,17 @@ export const ApiV1DeleteResponseSchema = type({
|
||||
ok: 'true',
|
||||
})
|
||||
|
||||
export const ApiV1BanUserResponseSchema = type({
|
||||
ok: 'true',
|
||||
alreadyBanned: 'boolean',
|
||||
deletedSkills: 'number',
|
||||
})
|
||||
|
||||
export const ApiV1SetRoleResponseSchema = type({
|
||||
ok: 'true',
|
||||
role: '"admin"|"moderator"|"user"',
|
||||
})
|
||||
|
||||
export const ApiV1StarResponseSchema = type({
|
||||
ok: 'true',
|
||||
starred: 'boolean',
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# clawhub-schema
|
||||
|
||||
Shared runtime schemas (ArkType) for OpenClaw.
|
||||
Shared runtime schemas (ArkType) for ClawHub.
|
||||
|
||||
Vendored
+1
@@ -17,5 +17,6 @@ export declare const ApiRoutes: {
|
||||
readonly skills: "/api/v1/skills";
|
||||
readonly stars: "/api/v1/stars";
|
||||
readonly souls: "/api/v1/souls";
|
||||
readonly users: "/api/v1/users";
|
||||
readonly whoami: "/api/v1/whoami";
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -17,6 +17,7 @@ export const ApiRoutes = {
|
||||
skills: '/api/v1/skills',
|
||||
stars: '/api/v1/stars',
|
||||
souls: '/api/v1/souls',
|
||||
users: '/api/v1/users',
|
||||
whoami: '/api/v1/whoami',
|
||||
};
|
||||
//# sourceMappingURL=routes.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA;AAEV,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAA"}
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAA;AAEV,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAA"}
|
||||
Vendored
+4
@@ -221,6 +221,10 @@ export declare const ApiV1PublishResponseSchema: import("arktype/internal/varian
|
||||
export declare const ApiV1DeleteResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
}, {}>;
|
||||
export declare const ApiV1SetRoleResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
role: "user" | "admin" | "moderator";
|
||||
}, {}>;
|
||||
export declare const ApiV1StarResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
starred: boolean;
|
||||
|
||||
Vendored
+4
@@ -192,6 +192,10 @@ export const ApiV1PublishResponseSchema = type({
|
||||
export const ApiV1DeleteResponseSchema = type({
|
||||
ok: 'true',
|
||||
});
|
||||
export const ApiV1SetRoleResponseSchema = type({
|
||||
ok: 'true',
|
||||
role: '"admin"|"moderator"|"user"',
|
||||
});
|
||||
export const ApiV1StarResponseSchema = type({
|
||||
ok: 'true',
|
||||
starred: 'boolean',
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -18,5 +18,6 @@ export const ApiRoutes = {
|
||||
skills: '/api/v1/skills',
|
||||
stars: '/api/v1/stars',
|
||||
souls: '/api/v1/souls',
|
||||
users: '/api/v1/users',
|
||||
whoami: '/api/v1/whoami',
|
||||
} as const
|
||||
|
||||
@@ -98,6 +98,17 @@ describe('clawhub-schema', () => {
|
||||
expect(() => parseArk(LockfileSchema, null, 'Lockfile')).toThrow(/Lockfile:/)
|
||||
})
|
||||
|
||||
it('truncates error messages when there are more than 3 errors', () => {
|
||||
const invalidPayload = {
|
||||
slug: 123,
|
||||
displayName: 456,
|
||||
version: 789,
|
||||
changelog: true,
|
||||
files: 'not-an-array',
|
||||
}
|
||||
expect(() => parseArk(CliPublishRequestSchema, invalidPayload, 'Publish')).toThrow('+')
|
||||
})
|
||||
|
||||
it('parses search results arrays', () => {
|
||||
expect(parseArk(ApiSearchResponseSchema, { results: [] }, 'Search')).toEqual({ results: [] })
|
||||
|
||||
|
||||
@@ -136,6 +136,17 @@ export const ApiV1WhoamiResponseSchema = type({
|
||||
},
|
||||
})
|
||||
|
||||
export const ApiV1UserSearchResponseSchema = type({
|
||||
items: type({
|
||||
userId: 'string',
|
||||
handle: 'string|null',
|
||||
displayName: 'string|null?',
|
||||
name: 'string|null?',
|
||||
role: '"admin"|"moderator"|"user"|null?',
|
||||
}).array(),
|
||||
total: 'number',
|
||||
})
|
||||
|
||||
export const ApiV1SearchResponseSchema = type({
|
||||
results: type({
|
||||
slug: 'string?',
|
||||
@@ -226,6 +237,11 @@ export const ApiV1DeleteResponseSchema = type({
|
||||
ok: 'true',
|
||||
})
|
||||
|
||||
export const ApiV1SetRoleResponseSchema = type({
|
||||
ok: 'true',
|
||||
role: '"admin"|"moderator"|"user"',
|
||||
})
|
||||
|
||||
export const ApiV1StarResponseSchema = type({
|
||||
ok: 'true',
|
||||
starred: 'boolean',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "OpenClaw API",
|
||||
"title": "ClawHub API",
|
||||
"version": "1.0.0",
|
||||
"description": "Public REST API for skills. Rate limits: read 120/min per IP + 600/min per key; write 30/min per IP + 120/min per key."
|
||||
},
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
fill="#F6EFE4"
|
||||
font-size="92"
|
||||
font-weight="700"
|
||||
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">OpenClaw</text>
|
||||
font-family="Bricolage Grotesque, Manrope, -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica, Arial, sans-serif">ClawHub</text>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text x="114" y="332"
|
||||
|
||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -116,8 +116,8 @@ function wrapText(value: string, maxWidth: number, fontSize: number, maxLines: n
|
||||
}
|
||||
|
||||
export function buildSkillOgSvg(params: SkillOgSvgParams) {
|
||||
const rawTitle = params.title.trim() || 'OpenClaw Skill'
|
||||
const rawDescription = params.description.trim() || 'Published on OpenClaw.'
|
||||
const rawTitle = params.title.trim() || 'ClawHub Skill'
|
||||
const rawDescription = params.description.trim() || 'Published on ClawHub.'
|
||||
|
||||
const cardX = 72
|
||||
const cardY = 96
|
||||
|
||||
@@ -42,7 +42,10 @@ describe('SkillDetailPage', () => {
|
||||
})
|
||||
|
||||
it('shows a loading indicator while loading', () => {
|
||||
useQueryMock.mockImplementationOnce(() => undefined) // getBySlug
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === 'skip') return undefined
|
||||
return undefined
|
||||
})
|
||||
|
||||
render(<SkillDetailPage slug="weather" />)
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
|
||||
@@ -50,26 +53,33 @@ describe('SkillDetailPage', () => {
|
||||
})
|
||||
|
||||
it('shows not found when skill query resolves to null', async () => {
|
||||
useQueryMock.mockImplementationOnce(() => null) // getBySlug
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === 'skip') return undefined
|
||||
return null
|
||||
})
|
||||
|
||||
render(<SkillDetailPage slug="missing-skill" />)
|
||||
expect(await screen.findByText(/Skill not found/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('redirects legacy routes to canonical owner/slug', async () => {
|
||||
useQueryMock.mockImplementationOnce(() => ({
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'weather',
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
ownerUserId: 'users:1',
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: { handle: 'steipete', name: 'Peter' },
|
||||
latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {} },
|
||||
}))
|
||||
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',
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: { handle: 'steipete', name: 'Peter' },
|
||||
latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {} },
|
||||
}
|
||||
})
|
||||
|
||||
render(<SkillDetailPage slug="weather" redirectToCanonical />)
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy()
|
||||
@@ -83,4 +93,38 @@ describe('SkillDetailPage', () => {
|
||||
replace: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows report abuse note for authenticated users', async () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: 'users:1', role: 'user' },
|
||||
})
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === 'skip') return undefined
|
||||
if (args && typeof args === 'object' && 'skillId' in args) return []
|
||||
if (args && typeof args === 'object' && 'slug' in args) {
|
||||
return {
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'weather',
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
ownerUserId: 'users:1',
|
||||
tags: {},
|
||||
stats: { stars: 0, downloads: 0 },
|
||||
},
|
||||
owner: { handle: 'steipete', name: 'Peter' },
|
||||
latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {}, files: [] },
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
render(<SkillDetailPage slug="weather" />)
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Reports require a reason\. Abuse may result in a ban\./i),
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,9 +8,9 @@ export function Footer() {
|
||||
<div className="site-footer-divider" aria-hidden="true" />
|
||||
<div className="site-footer-row">
|
||||
<div className="site-footer-copy">
|
||||
{siteName} · A{' '}
|
||||
<a href="https://clawd.bot" target="_blank" rel="noreferrer">
|
||||
Clawdbot
|
||||
{siteName} · An{' '}
|
||||
<a href="https://openclaw.ai" target="_blank" rel="noreferrer">
|
||||
OpenClaw
|
||||
</a>{' '}
|
||||
project ·{' '}
|
||||
<a href="https://github.com/openclaw/clawhub" target="_blank" rel="noreferrer">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Menu, Monitor, Moon, Sun } from 'lucide-react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { gravatarUrl } from '../lib/gravatar'
|
||||
import { isModerator } from '../lib/roles'
|
||||
import { getOpenClawSiteUrl, getSiteMode, getSiteName } from '../lib/site'
|
||||
import { getClawHubSiteUrl, getSiteMode, getSiteName } from '../lib/site'
|
||||
import { applyTheme, useThemeMode } from '../lib/theme'
|
||||
import { startThemeTransition } from '../lib/theme-transition'
|
||||
import { useAuthStatus } from '../lib/useAuthStatus'
|
||||
@@ -25,7 +25,7 @@ export default function Header() {
|
||||
const siteMode = getSiteMode()
|
||||
const siteName = useMemo(() => getSiteName(siteMode), [siteMode])
|
||||
const isSoulMode = siteMode === 'souls'
|
||||
const clawdHubUrl = getOpenClawSiteUrl()
|
||||
const clawHubUrl = getClawHubSiteUrl()
|
||||
|
||||
const avatar = me?.image ?? (me?.email ? gravatarUrl(me.email) : undefined)
|
||||
const handle = me?.handle ?? me?.displayName ?? 'user'
|
||||
@@ -59,7 +59,7 @@ export default function Header() {
|
||||
<span className="brand-name">{siteName}</span>
|
||||
</Link>
|
||||
<nav className="nav-links">
|
||||
{isSoulMode ? <a href={clawdHubUrl}>OpenClaw</a> : null}
|
||||
{isSoulMode ? <a href={clawHubUrl}>ClawHub</a> : null}
|
||||
{isSoulMode ? (
|
||||
<Link
|
||||
to="/souls"
|
||||
@@ -133,7 +133,7 @@ export default function Header() {
|
||||
<DropdownMenuContent align="end">
|
||||
{isSoulMode ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={clawdHubUrl}>OpenClaw</a>
|
||||
<a href={clawHubUrl}>ClawHub</a>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem asChild>
|
||||
|
||||
@@ -12,16 +12,139 @@ import { canManageSkill, isModerator } from '../lib/roles'
|
||||
import { useAuthStatus } from '../lib/useAuthStatus'
|
||||
import { SkillDiffCard } from './SkillDiffCard'
|
||||
|
||||
type VtAnalysis = {
|
||||
status: string
|
||||
verdict?: string
|
||||
analysis?: string
|
||||
source?: string
|
||||
checkedAt: number
|
||||
}
|
||||
|
||||
function VirusTotalIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="1em"
|
||||
height="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 89"
|
||||
aria-label="VirusTotal"
|
||||
>
|
||||
<title>VirusTotal</title>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M45.292 44.5 0 89h100V0H0l45.292 44.5zM90 80H22l35.987-35.2L22 9h68v71z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function getScanStatusInfo(status: string) {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'benign':
|
||||
case 'clean':
|
||||
return { label: 'Benign', className: 'scan-status-clean' }
|
||||
case 'malicious':
|
||||
return { label: 'Malicious', className: 'scan-status-malicious' }
|
||||
case 'suspicious':
|
||||
return { label: 'Suspicious', className: 'scan-status-suspicious' }
|
||||
case 'loading':
|
||||
return { label: 'Loading...', className: 'scan-status-pending' }
|
||||
case 'pending':
|
||||
case 'not_found':
|
||||
return { label: 'Pending', className: 'scan-status-pending' }
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return { label: 'Error', className: 'scan-status-error' }
|
||||
default:
|
||||
return { label: status, className: 'scan-status-unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
function SecurityScanResults({
|
||||
sha256hash,
|
||||
vtAnalysis,
|
||||
variant = 'panel',
|
||||
}: {
|
||||
sha256hash?: string
|
||||
vtAnalysis?: VtAnalysis | null
|
||||
variant?: 'panel' | 'badge'
|
||||
}) {
|
||||
if (!sha256hash) return null
|
||||
|
||||
const status = vtAnalysis?.status ?? 'pending'
|
||||
const vtUrl = `https://www.virustotal.com/gui/file/${sha256hash}`
|
||||
const statusInfo = getScanStatusInfo(status)
|
||||
const isCodeInsight = vtAnalysis?.source === 'code_insight'
|
||||
const aiAnalysis = vtAnalysis?.analysis
|
||||
|
||||
const displayLabel = statusInfo.label
|
||||
|
||||
if (variant === 'badge') {
|
||||
return (
|
||||
<div className="version-scan-badge">
|
||||
<VirusTotalIcon className="version-scan-icon version-scan-icon-vt" />
|
||||
<span className={statusInfo.className}>{displayLabel}</span>
|
||||
<a
|
||||
href={vtUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="version-scan-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="scan-results-panel">
|
||||
<div className="scan-results-title">Security Scan</div>
|
||||
<div className="scan-results-list">
|
||||
<div className="scan-result-row">
|
||||
<div className="scan-result-scanner">
|
||||
<VirusTotalIcon className="scan-result-icon scan-result-icon-vt" />
|
||||
<span className="scan-result-scanner-name">VirusTotal</span>
|
||||
</div>
|
||||
<div className={`scan-result-status ${statusInfo.className}`}>{displayLabel}</div>
|
||||
<a href={vtUrl} target="_blank" rel="noopener noreferrer" className="scan-result-link">
|
||||
View report →
|
||||
</a>
|
||||
</div>
|
||||
{isCodeInsight && aiAnalysis && (status === 'malicious' || status === 'suspicious') ? (
|
||||
<div className={`code-insight-analysis ${status}`}>
|
||||
<div className="code-insight-label">Code Insight</div>
|
||||
<p className="code-insight-text">{aiAnalysis}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SkillDetailPageProps = {
|
||||
slug: string
|
||||
canonicalOwner?: string
|
||||
redirectToCanonical?: boolean
|
||||
}
|
||||
|
||||
type ModerationInfo = {
|
||||
isPendingScan: boolean
|
||||
isMalwareBlocked: boolean
|
||||
isSuspicious: boolean
|
||||
isHiddenByMod: boolean
|
||||
isRemoved: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
type SkillBySlugResult = {
|
||||
skill: PublicSkill
|
||||
skill: Doc<'skills'> | PublicSkill
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
owner: PublicUser | null
|
||||
owner: Doc<'users'> | PublicUser | null
|
||||
pendingReview?: boolean
|
||||
moderationInfo?: ModerationInfo | null
|
||||
forkOf: {
|
||||
kind: 'fork' | 'duplicate'
|
||||
version: string | null
|
||||
@@ -36,6 +159,32 @@ type SkillBySlugResult = {
|
||||
|
||||
type SkillFile = Doc<'skillVersions'>['files'][number]
|
||||
|
||||
function formatReportError(error: unknown) {
|
||||
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 (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'message' in data &&
|
||||
typeof (data as { message?: unknown }).message === 'string'
|
||||
) {
|
||||
const message = (data as { message?: string }).message?.trim()
|
||||
if (message) return message
|
||||
}
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const cleaned = error.message
|
||||
.replace(/\[CONVEX[^\]]*\]\s*/g, '')
|
||||
.replace(/\[Request ID:[^\]]*\]\s*/g, '')
|
||||
.replace(/^Server Error Called by client\s*/i, '')
|
||||
.replace(/^ConvexError:\s*/i, '')
|
||||
.trim()
|
||||
if (cleaned && cleaned !== 'Server Error') return cleaned
|
||||
}
|
||||
return 'Unable to submit report. Please try again.'
|
||||
}
|
||||
|
||||
export function SkillDetailPage({
|
||||
slug,
|
||||
canonicalOwner,
|
||||
@@ -43,7 +192,14 @@ export function SkillDetailPage({
|
||||
}: SkillDetailPageProps) {
|
||||
const navigate = useNavigate()
|
||||
const { isAuthenticated, me } = useAuthStatus()
|
||||
const result = useQuery(api.skills.getBySlug, { slug }) as SkillBySlugResult | undefined
|
||||
const isStaff = isModerator(me)
|
||||
const staffResult = useQuery(api.skills.getBySlugForStaff, isStaff ? { slug } : 'skip') as
|
||||
| SkillBySlugResult
|
||||
| undefined
|
||||
const publicResult = useQuery(api.skills.getBySlug, !isStaff ? { slug } : 'skip') as
|
||||
| SkillBySlugResult
|
||||
| undefined
|
||||
const result = isStaff ? staffResult : publicResult
|
||||
const toggleStar = useMutation(api.stars.toggle)
|
||||
const reportSkill = useMutation(api.skills.report)
|
||||
const addComment = useMutation(api.comments.add)
|
||||
@@ -80,7 +236,6 @@ export function SkillDetailPage({
|
||||
) as Array<{ comment: Doc<'comments'>; user: PublicUser | null }> | undefined
|
||||
|
||||
const canManage = canManageSkill(me, skill)
|
||||
const isStaff = isModerator(me)
|
||||
|
||||
const ownerHandle = owner?.handle ?? owner?.name ?? null
|
||||
const ownerParam = ownerHandle ?? (owner?._id ? String(owner._id) : null)
|
||||
@@ -92,6 +247,7 @@ export function SkillDetailPage({
|
||||
|
||||
const forkOf = result?.forkOf ?? null
|
||||
const canonical = result?.canonical ?? null
|
||||
const modInfo = result?.moderationInfo ?? null
|
||||
const forkOfLabel = forkOf?.kind === 'duplicate' ? 'duplicate of' : 'fork of'
|
||||
const forkOfOwnerHandle = forkOf?.owner?.handle ?? null
|
||||
const forkOfOwnerId = forkOf?.owner?.userId ?? null
|
||||
@@ -104,6 +260,26 @@ export function SkillDetailPage({
|
||||
canonical?.skill?.slug && canonical.skill.slug !== forkOf?.skill?.slug
|
||||
? buildSkillHref(canonicalOwnerHandle, canonicalOwnerId, canonical.skill.slug)
|
||||
: null
|
||||
const staffSkill = isStaff && skill ? (skill as Doc<'skills'>) : null
|
||||
const moderationStatus =
|
||||
staffSkill?.moderationStatus ?? (staffSkill?.softDeletedAt ? 'hidden' : undefined)
|
||||
const isHidden = moderationStatus === 'hidden' || Boolean(staffSkill?.softDeletedAt)
|
||||
const isRemoved = moderationStatus === 'removed'
|
||||
const isAutoHidden = isHidden && staffSkill?.moderationReason === 'auto.reports'
|
||||
const staffVisibilityTag = isRemoved
|
||||
? 'Removed'
|
||||
: isAutoHidden
|
||||
? 'Auto-hidden'
|
||||
: isHidden
|
||||
? 'Hidden'
|
||||
: null
|
||||
const staffModerationNote = staffVisibilityTag
|
||||
? isAutoHidden
|
||||
? 'Auto-hidden after 4+ unique reports.'
|
||||
: isRemoved
|
||||
? 'Removed from public view.'
|
||||
: 'Hidden from public view.'
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!wantsCanonicalRedirect || !ownerParam) return
|
||||
@@ -195,6 +371,51 @@ export function SkillDetailPage({
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="skill-detail-stack">
|
||||
{modInfo?.isPendingScan ? (
|
||||
<div className="pending-banner">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Security scan in progress</strong>
|
||||
<p>
|
||||
Your skill is being scanned by VirusTotal. It will be visible to others once the
|
||||
scan completes. This usually takes up to 5 minutes — grab a coffee or exfoliate your
|
||||
shell while you wait.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isMalwareBlocked ? (
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill blocked — malicious content detected</strong>
|
||||
<p>
|
||||
VirusTotal flagged this skill as malicious. Downloads are disabled. Review the scan
|
||||
results below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isSuspicious ? (
|
||||
<div className="pending-banner pending-banner-warning">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill flagged — suspicious patterns detected</strong>
|
||||
<p>
|
||||
VirusTotal flagged this skill as suspicious. Review the scan results before using.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isRemoved ? (
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill removed by moderator</strong>
|
||||
<p>This skill has been removed and is not visible to others.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : modInfo?.isHiddenByMod ? (
|
||||
<div className="pending-banner pending-banner-blocked">
|
||||
<div className="pending-banner-content">
|
||||
<strong>Skill hidden</strong>
|
||||
<p>This skill is currently hidden and not visible to others.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="card skill-hero">
|
||||
<div className={`skill-hero-top${hasPluginBundle ? ' has-plugin' : ''}`}>
|
||||
<div className="skill-hero-header">
|
||||
@@ -207,6 +428,9 @@ export function SkillDetailPage({
|
||||
</div>
|
||||
<p className="section-subtitle">{skill.summary ?? 'No summary provided.'}</p>
|
||||
|
||||
{isStaff && staffModerationNote ? (
|
||||
<div className="skill-hero-note">{staffModerationNote}</div>
|
||||
) : null}
|
||||
{nixPlugin ? (
|
||||
<div className="skill-hero-note">
|
||||
Bundles the skill pack, CLI binary, and config requirements in one Nix install.
|
||||
@@ -246,6 +470,11 @@ export function SkillDetailPage({
|
||||
{badge}
|
||||
</div>
|
||||
))}
|
||||
{isStaff && staffVisibilityTag ? (
|
||||
<div className={`tag${isAutoHidden || isRemoved ? ' tag-accent' : ''}`}>
|
||||
{staffVisibilityTag}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="skill-actions">
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
@@ -262,12 +491,19 @@ export function SkillDetailPage({
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const reason = window.prompt('Report this skill? Add a reason if you want.')
|
||||
const reason = window.prompt(
|
||||
'Report this skill? A reason is required. Abuse may result in a ban.',
|
||||
)
|
||||
if (reason === null) return
|
||||
const trimmedReason = reason.trim()
|
||||
if (!trimmedReason) {
|
||||
window.alert('Report reason required.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await reportSkill({
|
||||
skillId: skill._id,
|
||||
reason: reason.trim() || undefined,
|
||||
reason: trimmedReason,
|
||||
})
|
||||
if (result.reported) {
|
||||
window.alert('Thanks — your report has been submitted.')
|
||||
@@ -276,7 +512,7 @@ export function SkillDetailPage({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to report skill', error)
|
||||
window.alert('Unable to submit report. Please try again.')
|
||||
window.alert(formatReportError(error))
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -289,13 +525,27 @@ export function SkillDetailPage({
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{isAuthenticated ? (
|
||||
<div className="section-subtitle" style={{ margin: '6px 0 0' }}>
|
||||
Reports require a reason. Abuse may result in a ban.
|
||||
</div>
|
||||
) : null}
|
||||
<SecurityScanResults
|
||||
sha256hash={latestVersion?.sha256hash}
|
||||
vtAnalysis={latestVersion?.vtAnalysis}
|
||||
/>
|
||||
{latestVersion?.sha256hash ? (
|
||||
<p className="scan-disclaimer">
|
||||
Like a lobster shell, security has layers — review code before you run it.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="skill-hero-cta">
|
||||
<div className="skill-version-pill">
|
||||
<span className="skill-version-label">Current version</span>
|
||||
<strong>v{latestVersion?.version ?? '—'}</strong>
|
||||
</div>
|
||||
{!nixPlugin ? (
|
||||
{!nixPlugin && !modInfo?.isMalwareBlocked && !modInfo?.isRemoved ? (
|
||||
<a
|
||||
className="btn btn-primary"
|
||||
href={`${import.meta.env.VITE_CONVEX_SITE_URL}/api/v1/download?slug=${skill.slug}`}
|
||||
@@ -593,6 +843,15 @@ export function SkillDetailPage({
|
||||
<div style={{ color: '#5c554e', whiteSpace: 'pre-wrap' }}>
|
||||
{version.changelog}
|
||||
</div>
|
||||
<div className="version-scan-results">
|
||||
{version.sha256hash ? (
|
||||
<SecurityScanResults
|
||||
sha256hash={version.sha256hash}
|
||||
vtAnalysis={version.vtAnalysis}
|
||||
variant="badge"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{!nixPlugin ? (
|
||||
<div className="version-actions">
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getSkillBadges, isSkillDeprecated, isSkillHighlighted, isSkillOfficial } from './badges'
|
||||
|
||||
describe('badges', () => {
|
||||
describe('isSkillHighlighted', () => {
|
||||
it('returns false when badges is undefined', () => {
|
||||
expect(isSkillHighlighted({})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when badges is null', () => {
|
||||
expect(isSkillHighlighted({ badges: null })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when highlighted is not set', () => {
|
||||
expect(isSkillHighlighted({ badges: {} })).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when highlighted is set', () => {
|
||||
expect(
|
||||
isSkillHighlighted({
|
||||
badges: { highlighted: { byUserId: 'user1' as never, at: 123 } },
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSkillOfficial', () => {
|
||||
it('returns false when badges is undefined', () => {
|
||||
expect(isSkillOfficial({})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when official is set', () => {
|
||||
expect(
|
||||
isSkillOfficial({
|
||||
badges: { official: { byUserId: 'user1' as never, at: 123 } },
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSkillDeprecated', () => {
|
||||
it('returns false when badges is undefined', () => {
|
||||
expect(isSkillDeprecated({})).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when deprecated is set', () => {
|
||||
expect(
|
||||
isSkillDeprecated({
|
||||
badges: { deprecated: { byUserId: 'user1' as never, at: 123 } },
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSkillBadges', () => {
|
||||
it('returns empty array when no badges', () => {
|
||||
expect(getSkillBadges({})).toEqual([])
|
||||
})
|
||||
|
||||
it('returns Deprecated when deprecated is set', () => {
|
||||
expect(
|
||||
getSkillBadges({
|
||||
badges: { deprecated: { byUserId: 'user1' as never, at: 123 } },
|
||||
}),
|
||||
).toEqual(['Deprecated'])
|
||||
})
|
||||
|
||||
it('returns Official when official is set', () => {
|
||||
expect(
|
||||
getSkillBadges({
|
||||
badges: { official: { byUserId: 'user1' as never, at: 123 } },
|
||||
}),
|
||||
).toEqual(['Official'])
|
||||
})
|
||||
|
||||
it('returns Highlighted when highlighted is set', () => {
|
||||
expect(
|
||||
getSkillBadges({
|
||||
badges: { highlighted: { byUserId: 'user1' as never, at: 123 } },
|
||||
}),
|
||||
).toEqual(['Highlighted'])
|
||||
})
|
||||
|
||||
it('returns all badges in correct order', () => {
|
||||
expect(
|
||||
getSkillBadges({
|
||||
badges: {
|
||||
deprecated: { byUserId: 'user1' as never, at: 123 },
|
||||
official: { byUserId: 'user1' as never, at: 123 },
|
||||
highlighted: { byUserId: 'user1' as never, at: 123 },
|
||||
},
|
||||
}),
|
||||
).toEqual(['Deprecated', 'Official', 'Highlighted'])
|
||||
})
|
||||
})
|
||||
})
|
||||
+3
-3
@@ -14,7 +14,7 @@ describe('og helpers', () => {
|
||||
summary: 'Forecasts for your area.',
|
||||
version: '1.2.3',
|
||||
})
|
||||
expect(meta.title).toBe('Weather — OpenClaw')
|
||||
expect(meta.title).toBe('Weather — ClawHub')
|
||||
expect(meta.description).toBe('Forecasts for your area.')
|
||||
expect(meta.url).toContain('/steipete/weather')
|
||||
expect(meta.owner).toBe('steipete')
|
||||
@@ -48,8 +48,8 @@ describe('og helpers', () => {
|
||||
|
||||
it('uses defaults when owner and summary are missing', () => {
|
||||
const meta = buildSkillMeta({ slug: 'parser' })
|
||||
expect(meta.title).toBe('parser — OpenClaw')
|
||||
expect(meta.description).toMatch(/OpenClaw — a fast skill registry/i)
|
||||
expect(meta.title).toBe('parser — ClawHub')
|
||||
expect(meta.description).toMatch(/ClawHub — a fast skill registry/i)
|
||||
expect(meta.url).toContain('/unknown/parser')
|
||||
expect(meta.owner).toBeNull()
|
||||
expect(meta.image).toContain('slug=parser')
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
import { getOpenClawSiteUrl, getOnlyCrabsSiteUrl } from './site'
|
||||
import { getClawHubSiteUrl, getOnlyCrabsSiteUrl } from './site'
|
||||
|
||||
type SkillMetaSource = {
|
||||
slug: string
|
||||
@@ -33,13 +33,13 @@ type SoulMeta = {
|
||||
owner: string | null
|
||||
}
|
||||
|
||||
const DEFAULT_DESCRIPTION = 'OpenClaw — a fast skill registry for agents, with vector search.'
|
||||
const DEFAULT_DESCRIPTION = 'ClawHub — a fast skill registry for agents, with vector search.'
|
||||
const DEFAULT_SOUL_DESCRIPTION = 'SoulHub — the home for SOUL.md bundles and personal system lore.'
|
||||
const OG_SKILL_IMAGE_LAYOUT_VERSION = '5'
|
||||
const OG_SOUL_IMAGE_LAYOUT_VERSION = '1'
|
||||
|
||||
export function getSiteUrl() {
|
||||
return getOpenClawSiteUrl()
|
||||
return getClawHubSiteUrl()
|
||||
}
|
||||
|
||||
export function getSoulSiteUrl() {
|
||||
@@ -103,9 +103,9 @@ export function buildSkillMeta(source: SkillMetaSource): SkillMeta {
|
||||
const displayName = clean(source.displayName) || clean(source.slug)
|
||||
const summary = clean(source.summary)
|
||||
const version = clean(source.version)
|
||||
const title = `${displayName} — OpenClaw`
|
||||
const title = `${displayName} — ClawHub`
|
||||
const description =
|
||||
summary || (owner ? `Agent skill by @${owner} on OpenClaw.` : DEFAULT_DESCRIPTION)
|
||||
summary || (owner ? `Agent skill by @${owner} on ClawHub.` : DEFAULT_DESCRIPTION)
|
||||
const ownerPath = owner || ownerId || 'unknown'
|
||||
const url = `${siteUrl}/${ownerPath}/${source.slug}`
|
||||
const imageParams = new URLSearchParams()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
detectSiteMode,
|
||||
detectSiteModeFromUrl,
|
||||
getOpenClawSiteUrl,
|
||||
getClawHubSiteUrl,
|
||||
getOnlyCrabsHost,
|
||||
getOnlyCrabsSiteUrl,
|
||||
getSiteDescription,
|
||||
@@ -42,9 +42,9 @@ afterEach(() => {
|
||||
|
||||
describe('site helpers', () => {
|
||||
it('returns default and env configured site URLs', () => {
|
||||
expect(getOpenClawSiteUrl()).toBe('https://clawhub.ai')
|
||||
expect(getClawHubSiteUrl()).toBe('https://clawhub.ai')
|
||||
withMetaEnv({ VITE_SITE_URL: 'https://example.com' }, () => {
|
||||
expect(getOpenClawSiteUrl()).toBe('https://example.com')
|
||||
expect(getClawHubSiteUrl()).toBe('https://example.com')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -118,10 +118,10 @@ describe('site helpers', () => {
|
||||
})
|
||||
|
||||
it('derives site metadata from mode', () => {
|
||||
expect(getSiteName('skills')).toBe('OpenClaw')
|
||||
expect(getSiteName('skills')).toBe('ClawHub')
|
||||
expect(getSiteName('souls')).toBe('SoulHub')
|
||||
|
||||
expect(getSiteDescription('skills')).toContain('OpenClaw')
|
||||
expect(getSiteDescription('skills')).toContain('ClawHub')
|
||||
expect(getSiteDescription('souls')).toContain('SoulHub')
|
||||
|
||||
expect(getSiteUrlForMode('skills')).toBe('https://clawhub.ai')
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ const DEFAULT_CLAWHUB_SITE_URL = 'https://clawhub.ai'
|
||||
const DEFAULT_ONLYCRABS_SITE_URL = 'https://onlycrabs.ai'
|
||||
const DEFAULT_ONLYCRABS_HOST = 'onlycrabs.ai'
|
||||
|
||||
export function getOpenClawSiteUrl() {
|
||||
export function getClawHubSiteUrl() {
|
||||
return import.meta.env.VITE_SITE_URL ?? DEFAULT_CLAWHUB_SITE_URL
|
||||
}
|
||||
|
||||
@@ -70,15 +70,15 @@ export function getSiteMode(): SiteMode {
|
||||
}
|
||||
|
||||
export function getSiteName(mode: SiteMode = getSiteMode()) {
|
||||
return mode === 'souls' ? 'SoulHub' : 'OpenClaw'
|
||||
return mode === 'souls' ? 'SoulHub' : 'ClawHub'
|
||||
}
|
||||
|
||||
export function getSiteDescription(mode: SiteMode = getSiteMode()) {
|
||||
return mode === 'souls'
|
||||
? 'SoulHub — the home for SOUL.md bundles and personal system lore.'
|
||||
: 'OpenClaw — a fast skill registry for agents, with vector search.'
|
||||
: 'ClawHub — a fast skill registry for agents, with vector search.'
|
||||
}
|
||||
|
||||
export function getSiteUrlForMode(mode: SiteMode = getSiteMode()) {
|
||||
return mode === 'souls' ? getOnlyCrabsSiteUrl() : getOpenClawSiteUrl()
|
||||
return mode === 'souls' ? getOnlyCrabsSiteUrl() : getClawHubSiteUrl()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { strToU8, unzipSync, zipSync } from 'fflate'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { expandFiles } from './uploadFiles'
|
||||
import { expandDroppedItems, expandFiles } from './uploadFiles'
|
||||
|
||||
function readWithFileReader(blob: Blob) {
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
@@ -31,3 +31,138 @@ describe('expandFiles (jsdom)', () => {
|
||||
expect(expanded.map((file) => file.name)).toEqual(['SKILL.md', 'notes.txt'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandDroppedItems', () => {
|
||||
it('returns empty array when items is null', async () => {
|
||||
const result = await expandDroppedItems(null)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when items is empty', async () => {
|
||||
const items = {
|
||||
length: 0,
|
||||
[Symbol.iterator]: function* () {},
|
||||
} as unknown as DataTransferItemList
|
||||
const result = await expandDroppedItems(items)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('collects files from getAsFile when webkitGetAsEntry is unavailable', async () => {
|
||||
const file = new File(['hello'], 'test.md', { type: 'text/markdown' })
|
||||
const item = {
|
||||
getAsFile: () => file,
|
||||
webkitGetAsEntry: undefined,
|
||||
}
|
||||
const items = {
|
||||
length: 1,
|
||||
0: item,
|
||||
[Symbol.iterator]: function* () {
|
||||
yield item
|
||||
},
|
||||
} as unknown as DataTransferItemList
|
||||
const result = await expandDroppedItems(items)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.name).toBe('test.md')
|
||||
})
|
||||
|
||||
it('collects files via webkitGetAsEntry for file entries', async () => {
|
||||
const file = new File(['content'], 'SKILL.md', { type: 'text/markdown' })
|
||||
const fileEntry = {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name: 'SKILL.md',
|
||||
fullPath: '/SKILL.md',
|
||||
file: (callback: (f: File) => void) => callback(file),
|
||||
}
|
||||
const item = {
|
||||
getAsFile: () => null,
|
||||
webkitGetAsEntry: () => fileEntry,
|
||||
}
|
||||
const items = {
|
||||
length: 1,
|
||||
0: item,
|
||||
[Symbol.iterator]: function* () {
|
||||
yield item
|
||||
},
|
||||
} as unknown as DataTransferItemList
|
||||
const result = await expandDroppedItems(items)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.name).toBe('SKILL.md')
|
||||
})
|
||||
|
||||
it('recursively collects files from directory entries', async () => {
|
||||
const file1 = new File(['hello'], 'README.md', { type: 'text/markdown' })
|
||||
const file2 = new File(['world'], 'notes.txt', { type: 'text/plain' })
|
||||
|
||||
const fileEntry1 = {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name: 'README.md',
|
||||
fullPath: '/mydir/README.md',
|
||||
file: (callback: (f: File) => void) => callback(file1),
|
||||
}
|
||||
const fileEntry2 = {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name: 'notes.txt',
|
||||
fullPath: '/mydir/notes.txt',
|
||||
file: (callback: (f: File) => void) => callback(file2),
|
||||
}
|
||||
|
||||
let readEntriesCalled = false
|
||||
const dirEntry = {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name: 'mydir',
|
||||
fullPath: '/mydir',
|
||||
createReader: () => ({
|
||||
readEntries: (callback: (entries: unknown[]) => void) => {
|
||||
if (!readEntriesCalled) {
|
||||
readEntriesCalled = true
|
||||
callback([fileEntry1, fileEntry2])
|
||||
} else {
|
||||
callback([])
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const item = {
|
||||
getAsFile: () => null,
|
||||
webkitGetAsEntry: () => dirEntry,
|
||||
}
|
||||
const items = {
|
||||
length: 1,
|
||||
0: item,
|
||||
[Symbol.iterator]: function* () {
|
||||
yield item
|
||||
},
|
||||
} as unknown as DataTransferItemList
|
||||
|
||||
const result = await expandDroppedItems(items)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((f) => f.name).sort()).toEqual(['mydir/README.md', 'mydir/notes.txt'])
|
||||
})
|
||||
|
||||
it('skips entries that are neither files nor directories', async () => {
|
||||
const nonEntry = {
|
||||
isFile: false,
|
||||
isDirectory: false,
|
||||
name: 'unknown',
|
||||
}
|
||||
const item = {
|
||||
getAsFile: () => null,
|
||||
webkitGetAsEntry: () => nonEntry,
|
||||
}
|
||||
const items = {
|
||||
length: 1,
|
||||
0: item,
|
||||
[Symbol.iterator]: function* () {
|
||||
yield item
|
||||
},
|
||||
} as unknown as DataTransferItemList
|
||||
|
||||
const result = await expandDroppedItems(items)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
+21
-11
@@ -1,10 +1,12 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from 'convex/react'
|
||||
import { Package, Plus, Upload } from 'lucide-react'
|
||||
import { Clock, Package, Plus, Upload } from 'lucide-react'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import type { Doc } from '../../convex/_generated/dataModel'
|
||||
import type { PublicSkill } from '../lib/publicUser'
|
||||
|
||||
type DashboardSkill = PublicSkill & { pendingReview?: boolean }
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
component: Dashboard,
|
||||
})
|
||||
@@ -14,7 +16,7 @@ function Dashboard() {
|
||||
const mySkills = useQuery(
|
||||
api.skills.list,
|
||||
me?._id ? { ownerUserId: me._id, limit: 100 } : 'skip',
|
||||
) as PublicSkill[] | undefined
|
||||
) as DashboardSkill[] | undefined
|
||||
|
||||
if (!me) {
|
||||
return (
|
||||
@@ -60,18 +62,26 @@ function Dashboard() {
|
||||
)
|
||||
}
|
||||
|
||||
function SkillCard({ skill, ownerHandle }: { skill: PublicSkill; ownerHandle: string | null }) {
|
||||
function SkillCard({ skill, ownerHandle }: { skill: DashboardSkill; ownerHandle: string | null }) {
|
||||
return (
|
||||
<div className="dashboard-skill-card">
|
||||
<div className="dashboard-skill-info">
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerHandle ?? 'unknown', slug: skill.slug }}
|
||||
className="dashboard-skill-name"
|
||||
>
|
||||
{skill.displayName}
|
||||
</Link>
|
||||
<span className="dashboard-skill-slug">/{skill.slug}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<Link
|
||||
to="/$owner/$slug"
|
||||
params={{ owner: ownerHandle ?? 'unknown', slug: skill.slug }}
|
||||
className="dashboard-skill-name"
|
||||
>
|
||||
{skill.displayName}
|
||||
</Link>
|
||||
<span className="dashboard-skill-slug">/{skill.slug}</span>
|
||||
{skill.pendingReview ? (
|
||||
<span className="tag tag-pending">
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
Scanning
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{skill.summary && <p className="dashboard-skill-description">{skill.summary}</p>}
|
||||
<div className="dashboard-skill-stats">
|
||||
<span>⤓ {skill.stats.downloads}</span>
|
||||
|
||||
@@ -32,7 +32,7 @@ function SkillsHome() {
|
||||
<div className="hero-inner">
|
||||
<div className="hero-copy fade-up" data-delay="1">
|
||||
<span className="hero-badge">Lobster-light. Agent-right.</span>
|
||||
<h1 className="hero-title">OpenClaw, the skill dock for sharp agents.</h1>
|
||||
<h1 className="hero-title">ClawHub, the skill dock for sharp agents.</h1>
|
||||
<p className="hero-subtitle">
|
||||
Upload AgentSkills bundles, version them like npm, and make them searchable with
|
||||
vectors. No gatekeeping, just signal.
|
||||
|
||||
+208
-31
@@ -18,6 +18,17 @@ type ManagementSkillEntry = {
|
||||
owner: Doc<'users'> | null
|
||||
}
|
||||
|
||||
type ReportReasonEntry = {
|
||||
reason: string
|
||||
createdAt: number
|
||||
reporterHandle: string | null
|
||||
reporterId: Id<'users'>
|
||||
}
|
||||
|
||||
type ReportedSkillEntry = ManagementSkillEntry & {
|
||||
reports: ReportReasonEntry[]
|
||||
}
|
||||
|
||||
type RecentVersionEntry = {
|
||||
version: Doc<'skillVersions'>
|
||||
skill: Doc<'skills'> | null
|
||||
@@ -46,6 +57,13 @@ function resolveOwnerParam(handle: string | null | undefined, ownerId?: Id<'user
|
||||
return handle?.trim() || (ownerId ? String(ownerId) : 'unknown')
|
||||
}
|
||||
|
||||
function promptBanReason(label: string) {
|
||||
const result = window.prompt(`Ban reason for ${label} (optional)`)
|
||||
if (result === null) return null
|
||||
const trimmed = result.trim()
|
||||
return trimmed.length > 0 ? trimmed : undefined
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/management')({
|
||||
validateSearch: (search) => ({
|
||||
skill: typeof search.skill === 'string' && search.skill.trim() ? search.skill : undefined,
|
||||
@@ -59,19 +77,16 @@ function Management() {
|
||||
const staff = isModerator(me)
|
||||
const admin = isAdmin(me)
|
||||
|
||||
const users = useQuery(api.users.list, admin ? { limit: 50 } : 'skip') as
|
||||
| Doc<'users'>[]
|
||||
| undefined
|
||||
const selectedSlug = search.skill?.trim()
|
||||
const selectedSkill = useQuery(
|
||||
api.skills.getBySlug,
|
||||
api.skills.getBySlugForStaff,
|
||||
staff && selectedSlug ? { slug: selectedSlug } : 'skip',
|
||||
) as SkillBySlugResult | undefined
|
||||
const recentVersions = useQuery(api.skills.listRecentVersions, staff ? { limit: 20 } : 'skip') as
|
||||
| RecentVersionEntry[]
|
||||
| undefined
|
||||
const reportedSkills = useQuery(api.skills.listReportedSkills, staff ? { limit: 25 } : 'skip') as
|
||||
| ManagementSkillEntry[]
|
||||
| ReportedSkillEntry[]
|
||||
| undefined
|
||||
const duplicateCandidates = useQuery(
|
||||
api.skills.listDuplicateCandidates,
|
||||
@@ -79,6 +94,7 @@ function Management() {
|
||||
) as DuplicateCandidateEntry[] | undefined
|
||||
|
||||
const setRole = useMutation(api.users.setRole)
|
||||
const banUser = useMutation(api.users.banUser)
|
||||
const setBatch = useMutation(api.skills.setBatch)
|
||||
const setSoftDeleted = useMutation(api.skills.setSoftDeleted)
|
||||
const hardDelete = useMutation(api.skills.hardDelete)
|
||||
@@ -89,6 +105,16 @@ function Management() {
|
||||
|
||||
const [selectedDuplicate, setSelectedDuplicate] = useState('')
|
||||
const [selectedOwner, setSelectedOwner] = useState('')
|
||||
const [reportSearch, setReportSearch] = useState('')
|
||||
const [reportSearchDebounced, setReportSearchDebounced] = useState('')
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const [userSearchDebounced, setUserSearchDebounced] = useState('')
|
||||
|
||||
const userQuery = userSearchDebounced.trim()
|
||||
const userResult = useQuery(
|
||||
api.users.list,
|
||||
admin ? { limit: 200, search: userQuery || undefined } : 'skip',
|
||||
) as { items: Doc<'users'>[]; total: number } | undefined
|
||||
|
||||
const selectedSkillId = selectedSkill?.skill?._id ?? null
|
||||
const selectedOwnerUserId = selectedSkill?.skill?.ownerUserId ?? null
|
||||
@@ -100,6 +126,16 @@ function Management() {
|
||||
setSelectedOwner(String(selectedOwnerUserId))
|
||||
}, [selectedCanonicalSlug, selectedOwnerUserId, selectedSkillId])
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setReportSearchDebounced(reportSearch), 250)
|
||||
return () => clearTimeout(handle)
|
||||
}, [reportSearch])
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setUserSearchDebounced(userSearch), 250)
|
||||
return () => clearTimeout(handle)
|
||||
}, [userSearch])
|
||||
|
||||
if (!staff) {
|
||||
return (
|
||||
<main className="section">
|
||||
@@ -116,6 +152,47 @@ function Management() {
|
||||
)
|
||||
}
|
||||
|
||||
const reportQuery = reportSearchDebounced.trim().toLowerCase()
|
||||
const filteredReportedSkills = reportQuery
|
||||
? reportedSkills.filter((entry) => {
|
||||
const reportReasons = (entry.reports ?? []).map((report) => report.reason).join(' ')
|
||||
const reporterHandles = (entry.reports ?? [])
|
||||
.map((report) => report.reporterHandle)
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
const haystack = [
|
||||
entry.skill.displayName,
|
||||
entry.skill.slug,
|
||||
entry.owner?.handle,
|
||||
entry.owner?.name,
|
||||
reportReasons,
|
||||
reporterHandles,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
return haystack.includes(reportQuery)
|
||||
})
|
||||
: reportedSkills
|
||||
const reportCountLabel =
|
||||
filteredReportedSkills.length === 0 && reportedSkills.length > 0
|
||||
? 'No matching reports.'
|
||||
: 'No reports yet.'
|
||||
const reportSummary = `Showing ${filteredReportedSkills.length} of ${reportedSkills.length}`
|
||||
|
||||
const filteredUsers = userResult?.items ?? []
|
||||
const userTotal = userResult?.total ?? 0
|
||||
const userSummary = userResult
|
||||
? `Showing ${filteredUsers.length} of ${userTotal}`
|
||||
: 'Loading users…'
|
||||
const userEmptyLabel = userResult
|
||||
? filteredUsers.length === 0
|
||||
? userQuery
|
||||
? 'No matching users.'
|
||||
: 'No users yet.'
|
||||
: ''
|
||||
: 'Loading users…'
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<h1 className="section-title">Management console</h1>
|
||||
@@ -125,16 +202,29 @@ function Management() {
|
||||
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
Reported skills
|
||||
</h2>
|
||||
<div className="management-controls">
|
||||
<div className="management-control management-search">
|
||||
<span className="mono">Filter</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search reported skills"
|
||||
value={reportSearch}
|
||||
onChange={(event) => setReportSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="management-count">{reportSummary}</div>
|
||||
</div>
|
||||
<div className="management-list">
|
||||
{reportedSkills.length === 0 ? (
|
||||
<div className="stat">No reports yet.</div>
|
||||
{filteredReportedSkills.length === 0 ? (
|
||||
<div className="stat">{reportCountLabel}</div>
|
||||
) : (
|
||||
reportedSkills.map((entry) => {
|
||||
const { skill, latestVersion, owner } = entry
|
||||
filteredReportedSkills.map((entry) => {
|
||||
const { skill, latestVersion, owner, reports } = entry
|
||||
const ownerParam = resolveOwnerParam(
|
||||
owner?.handle ?? null,
|
||||
owner?._id ?? skill.ownerUserId,
|
||||
)
|
||||
const reportEntries = reports ?? []
|
||||
return (
|
||||
<div key={skill._id} className="management-item">
|
||||
<div className="management-item-main">
|
||||
@@ -148,6 +238,26 @@ function Management() {
|
||||
? ` · last ${formatTimestamp(skill.lastReportedAt)}`
|
||||
: ''}
|
||||
</div>
|
||||
{reportEntries.length > 0 ? (
|
||||
<div className="management-sublist">
|
||||
{reportEntries.map((report) => (
|
||||
<div
|
||||
key={`${report.reporterId}-${report.createdAt}`}
|
||||
className="management-report-item"
|
||||
>
|
||||
<span className="management-report-meta">
|
||||
{formatTimestamp(report.createdAt)}
|
||||
{report.reporterHandle ? ` · @${report.reporterHandle}` : ''}
|
||||
</span>
|
||||
<span>{report.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
No report reasons yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<button
|
||||
@@ -211,6 +321,11 @@ function Management() {
|
||||
const isOfficial = isSkillOfficial(skill)
|
||||
const isDeprecated = isSkillDeprecated(skill)
|
||||
const badges = getSkillBadges(skill)
|
||||
const ownerUserId = skill.ownerUserId ?? selectedOwnerUserId
|
||||
const ownerHandle = owner?.handle ?? owner?.name ?? 'user'
|
||||
const isOwnerAdmin = owner?.role === 'admin'
|
||||
const canBanOwner =
|
||||
staff && ownerUserId && ownerUserId !== me?._id && (admin || !isOwnerAdmin)
|
||||
|
||||
return (
|
||||
<div key={skill._id} className="management-item">
|
||||
@@ -261,7 +376,7 @@ function Management() {
|
||||
value={selectedOwner}
|
||||
onChange={(event) => setSelectedOwner(event.target.value)}
|
||||
>
|
||||
{(users ?? []).map((user) => (
|
||||
{filteredUsers.map((user) => (
|
||||
<option key={user._id} value={user._id}>
|
||||
@{user.handle ?? user.name ?? 'user'}
|
||||
</option>
|
||||
@@ -324,6 +439,24 @@ function Management() {
|
||||
Hard delete
|
||||
</button>
|
||||
) : null}
|
||||
{staff ? (
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
disabled={!canBanOwner}
|
||||
onClick={() => {
|
||||
if (!ownerUserId || ownerUserId === me?._id) return
|
||||
if (!window.confirm(`Ban @${ownerHandle} and delete their skills?`)) {
|
||||
return
|
||||
}
|
||||
const reason = promptBanReason(`@${ownerHandle}`)
|
||||
if (reason === null) return
|
||||
void banUser({ userId: ownerUserId, reason })
|
||||
}}
|
||||
>
|
||||
Ban user
|
||||
</button>
|
||||
) : null}
|
||||
{admin ? (
|
||||
<>
|
||||
<button
|
||||
@@ -494,29 +627,73 @@ function Management() {
|
||||
<h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
Users
|
||||
</h2>
|
||||
<div className="management-controls">
|
||||
<div className="management-control management-search">
|
||||
<span className="mono">Filter</span>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search users"
|
||||
value={userSearch}
|
||||
onChange={(event) => setUserSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="management-count">{userSummary}</div>
|
||||
</div>
|
||||
<div className="management-list">
|
||||
{(users ?? []).map((user) => (
|
||||
<div key={user._id} className="management-item">
|
||||
<div className="management-item-main">
|
||||
<span className="mono">@{user.handle ?? user.name ?? 'user'}</span>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="stat">{userEmptyLabel}</div>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<div key={user._id} className="management-item">
|
||||
<div className="management-item-main">
|
||||
<span className="mono">@{user.handle ?? user.name ?? 'user'}</span>
|
||||
{user.deletedAt ? (
|
||||
<div className="section-subtitle" style={{ margin: 0 }}>
|
||||
{user.banReason
|
||||
? `Banned ${formatTimestamp(user.deletedAt)} · ${user.banReason}`
|
||||
: `Deleted ${formatTimestamp(user.deletedAt)}`}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<select
|
||||
value={user.role ?? 'user'}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
if (value === 'admin' || value === 'moderator' || value === 'user') {
|
||||
void setRole({ userId: user._id, role: value })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
disabled={user._id === me?._id}
|
||||
onClick={() => {
|
||||
if (user._id === me?._id) return
|
||||
if (
|
||||
!window.confirm(
|
||||
`Ban @${user.handle ?? user.name ?? 'user'} and delete their skills?`,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const label = `@${user.handle ?? user.name ?? 'user'}`
|
||||
const reason = promptBanReason(label)
|
||||
if (reason === null) return
|
||||
void banUser({ userId: user._id, reason })
|
||||
}}
|
||||
>
|
||||
Ban user
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="management-actions">
|
||||
<select
|
||||
value={user.role ?? 'user'}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
if (value === 'admin' || value === 'moderator' || value === 'user') {
|
||||
void setRole({ userId: user._id, role: value })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
isTextFile,
|
||||
readText,
|
||||
uploadFile,
|
||||
} from './upload/utils'
|
||||
} from './upload/-utils'
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
|
||||
|
||||
+288
@@ -2687,6 +2687,15 @@ html.theme-transition::view-transition-new(theme) {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.management-search input {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.management-count {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.management-sublist {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -2703,6 +2712,18 @@ html.theme-transition::view-transition-new(theme) {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.management-report-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.management-report-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.management-item {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -2716,3 +2737,270 @@ html.theme-transition::view-transition-new(theme) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Security Scan Results */
|
||||
.scan-results-panel {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.scan-results-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.scan-result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scan-result-scanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scan-result-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.scan-result-icon-vt {
|
||||
color: #0030ff;
|
||||
}
|
||||
|
||||
.scan-result-status {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.scan-status-clean {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.scan-status-malicious {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.scan-status-suspicious {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.scan-status-pending {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.scan-status-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.scan-result-link {
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.scan-result-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Code Insight Analysis */
|
||||
.code-insight-analysis {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.code-insight-analysis.malicious {
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-left: 3px solid #dc2626;
|
||||
}
|
||||
|
||||
.code-insight-analysis.suspicious {
|
||||
background: rgba(245, 158, 11, 0.06);
|
||||
border-left: 3px solid #f59e0b;
|
||||
}
|
||||
|
||||
.code-insight-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.code-insight-analysis.malicious .code-insight-label {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.code-insight-analysis.suspicious .code-insight-label {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.code-insight-text {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ink);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-insight-text code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .code-insight-analysis.malicious {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .code-insight-analysis.suspicious {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .code-insight-text code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.version-scan-results {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.version-scan-toggle {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-scan-toggle:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.version-scan-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.version-scan-badge .scan-result-status {
|
||||
padding: 1px 6px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.version-scan-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.version-scan-icon-vt {
|
||||
color: #0030ff;
|
||||
}
|
||||
|
||||
.version-scan-link {
|
||||
color: var(--ink-soft);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.version-scan-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.scan-disclaimer {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink-soft);
|
||||
opacity: 0.8;
|
||||
margin: 8px 0 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Pending Review Banner */
|
||||
.pending-banner {
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink);
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(240, 196, 106, 0.15);
|
||||
border: 1px solid rgba(240, 196, 106, 0.4);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .pending-banner {
|
||||
background: rgba(243, 201, 122, 0.12);
|
||||
border-color: rgba(243, 201, 122, 0.35);
|
||||
}
|
||||
|
||||
.pending-banner-content strong {
|
||||
display: block;
|
||||
font-weight: 650;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.pending-banner-content p {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Blocked/removed banner variant */
|
||||
.pending-banner-blocked {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .pending-banner-blocked {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
/* Suspicious/warning banner variant */
|
||||
.pending-banner-warning {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border-color: rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .pending-banner-warning {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border-color: rgba(245, 158, 11, 0.35);
|
||||
}
|
||||
|
||||
/* Pending tag for dashboard */
|
||||
.tag-pending {
|
||||
background: rgba(240, 196, 106, 0.2);
|
||||
color: #8a6914;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tag-pending {
|
||||
background: rgba(243, 201, 122, 0.18);
|
||||
color: #f3c97a;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,29 @@ const convexBrowserPath = join(convexRoot, 'dist/esm/browser/index.js')
|
||||
const convexValuesPath = join(convexRoot, 'dist/esm/values/index.js')
|
||||
const convexAuthReactPath = require.resolve('@convex-dev/auth/react')
|
||||
|
||||
function handleRollupWarning(
|
||||
warning: { code?: string; message: string; id?: string },
|
||||
warn: (warning: { code?: string; message: string; id?: string }) => void,
|
||||
) {
|
||||
if (
|
||||
warning.code === 'MODULE_LEVEL_DIRECTIVE' &&
|
||||
warning.id?.includes('node_modules') &&
|
||||
/use client/i.test(warning.message)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
warning.code === 'UNUSED_EXTERNAL_IMPORT' &&
|
||||
/@tanstack\/start-|@tanstack\/router-core\/ssr\/(client|server)/.test(warning.message)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (warning.code === 'EMPTY_BUNDLE' || /Generated an empty chunk/i.test(warning.message)) {
|
||||
return
|
||||
}
|
||||
warn(warning)
|
||||
}
|
||||
|
||||
const config = defineConfig({
|
||||
resolve: {
|
||||
dedupe: ['convex', '@convex-dev/auth', 'react', 'react-dom'],
|
||||
@@ -34,6 +57,9 @@ const config = defineConfig({
|
||||
devtools(),
|
||||
nitro({
|
||||
serverDir: 'server',
|
||||
rollupConfig: {
|
||||
onwarn: handleRollupWarning,
|
||||
},
|
||||
}),
|
||||
// this is the plugin that enables path aliases
|
||||
viteTsConfigPaths({
|
||||
@@ -43,6 +69,12 @@ const config = defineConfig({
|
||||
tanstackStart(),
|
||||
viteReact(),
|
||||
],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 900,
|
||||
rollupOptions: {
|
||||
onwarn: handleRollupWarning,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default config
|
||||
|
||||
@@ -25,6 +25,7 @@ export default defineConfig({
|
||||
include: [
|
||||
'src/lib/**/*.{ts,tsx}',
|
||||
'convex/lib/skills.ts',
|
||||
'convex/lib/skillZip.ts',
|
||||
'convex/lib/tokens.ts',
|
||||
'convex/httpApi.ts',
|
||||
'packages/clawdhub/src/**/*.ts',
|
||||
|
||||
Reference in New Issue
Block a user