mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-15 09:22:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3c42b661b | ||
|
|
f72179f37d | ||
|
|
1d79f78426 | ||
|
|
6209fe3fff | ||
|
|
a7d1701f5a | ||
|
|
52da4954f6 | ||
|
|
0ee5958f7a | ||
|
|
5d01b99adb | ||
|
|
5dc834c27e | ||
|
|
82b9a69dad | ||
|
|
064804e2d3 | ||
|
|
6c0163f9f2 | ||
|
|
04a862d2b2 | ||
|
|
a7fc4bbae2 | ||
|
|
4701c555f3 | ||
|
|
c1f167721b | ||
|
|
1a94744484 | ||
|
|
9a5cfeee85 | ||
|
|
e69b7d4501 | ||
|
|
ecf09b868a | ||
|
|
4d16472f5b | ||
|
|
2e5ffdc565 | ||
|
|
4e13e729fb | ||
|
|
d17e100cca | ||
|
|
c732b38569 | ||
|
|
81ca04662c | ||
|
|
ce69ab6a38 | ||
|
|
a28d94c345 | ||
|
|
3701733797 | ||
|
|
b8ba595d06 | ||
|
|
ef2846b2e4 | ||
|
|
232e429dee | ||
|
|
932155cb8f | ||
|
|
d855d09ab0 | ||
|
|
16e87c147d | ||
|
|
cbe22e70b9 | ||
|
|
837331c967 | ||
|
|
6ce443496d | ||
|
|
8fd4f3b051 | ||
|
|
a2153909da | ||
|
|
75e1b4633e | ||
|
|
4cda4a1fa4 | ||
|
|
5fce3ca2f4 | ||
|
|
87bca06939 | ||
|
|
bf25b38c39 | ||
|
|
f20dd624a5 | ||
|
|
d3ed4434b9 | ||
|
|
1c430cc11d | ||
|
|
fb3bcbafb1 | ||
|
|
e6c3d6ff28 |
@@ -12,6 +12,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
|
||||
with:
|
||||
@@ -22,6 +24,24 @@ jobs:
|
||||
- name: Peer deps
|
||||
run: bun run check:peers
|
||||
|
||||
- name: Format
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
mapfile -d '' changed_files < <(
|
||||
git diff --name-only --diff-filter=ACMR -z \
|
||||
"${{ github.event.pull_request.base.sha }}" \
|
||||
"${{ github.event.pull_request.head.sha }}" \
|
||||
-- \
|
||||
'*.css' '*.js' '*.jsx' '*.json' '*.md' '*.mjs' '*.ts' '*.tsx' '*.yaml' '*.yml'
|
||||
)
|
||||
|
||||
if (( ${#changed_files[@]} == 0 )); then
|
||||
echo "No changed files supported by oxfmt."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
bun run format:check -- "${changed_files[@]}"
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: ClawHub Rescan Guidance
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue:
|
||||
description: "Issue number to check"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: clawhub-rescan-guidance-${{ github.event.issue.number || github.event.inputs.issue }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
rescan-guidance:
|
||||
runs-on: ubuntu-latest
|
||||
if: "${{ github.event_name == 'workflow_dispatch' || github.event.label.name == 'r: rescan-guidance' }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CLAWHUB_RESCAN_GUIDANCE_APPLY: "1"
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Comment when rescan guidance label is present
|
||||
run: |
|
||||
node scripts/github/clawhub-rescan-auto-response.mjs \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--issue "$ISSUE_NUMBER" \
|
||||
--comment-for-labeled-issue \
|
||||
--apply
|
||||
@@ -0,0 +1,62 @@
|
||||
name: ClawSweeper Dispatch
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened, edited, labeled, unlabeled]
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned external dispatch; no checkout or untrusted PR code execution
|
||||
types: [opened, reopened, synchronize, ready_for_review, edited, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }}
|
||||
env:
|
||||
HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }}
|
||||
CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093
|
||||
SUPERSEDES_IN_PROGRESS: ${{ (github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review') && 'true' || 'false' }}
|
||||
steps:
|
||||
- name: Debounce bursty metadata events
|
||||
if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }}
|
||||
run: sleep 20
|
||||
|
||||
- name: Create ClawSweeper dispatch token
|
||||
id: token
|
||||
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
|
||||
owner: openclaw
|
||||
repositories: clawsweeper
|
||||
|
||||
- name: Dispatch exact ClawSweeper review
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token || secrets.OPENCLAW_GH_TOKEN }}
|
||||
TARGET_REPO: ${{ github.repository }}
|
||||
ITEM_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
ITEM_KIND: ${{ github.event_name == 'pull_request_target' && 'pull_request' || 'issue' }}
|
||||
SOURCE_EVENT: ${{ github.event_name }}
|
||||
SOURCE_ACTION: ${{ github.event.action }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured."
|
||||
exit 0
|
||||
fi
|
||||
payload="$(jq -nc \
|
||||
--arg target_repo "$TARGET_REPO" \
|
||||
--argjson item_number "$ITEM_NUMBER" \
|
||||
--arg item_kind "$ITEM_KIND" \
|
||||
--arg source_event "$SOURCE_EVENT" \
|
||||
--arg source_action "$SOURCE_ACTION" \
|
||||
--argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \
|
||||
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress}}')"
|
||||
gh api repos/openclaw/clawsweeper/dispatches \
|
||||
--method POST \
|
||||
--input - <<< "$payload"
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
|
||||
- name: Install Playwright browser
|
||||
if: needs.validate-deploy-request.outputs.run_smoke == 'true'
|
||||
run: bunx playwright install --with-deps chromium
|
||||
run: bunx playwright install --with-deps chromium webkit
|
||||
|
||||
- name: Write authenticated storage state
|
||||
if: needs.validate-deploy-request.outputs.run_smoke == 'true' && env.PLAYWRIGHT_AUTH_STORAGE_STATE_JSON != ''
|
||||
|
||||
@@ -73,6 +73,9 @@ on:
|
||||
description: Published release id when dry_run is false.
|
||||
value: ${{ jobs.publish.outputs.release_id }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -334,7 +337,7 @@ jobs:
|
||||
PY
|
||||
|
||||
- name: Upload publish JSON artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: clawhub-package-publish-json
|
||||
path: ${{ runner.temp }}/package-publish.json
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- `bun run preview` — preview built app.
|
||||
- `bunx convex dev` — Convex dev deployment + function watcher.
|
||||
- `bunx convex codegen` — regenerate `convex/_generated`.
|
||||
- `bun run format:check` — formatting check.
|
||||
- `bun run lint` — Biome + oxlint (type-aware).
|
||||
- `bun run test` — Vitest (unit tests).
|
||||
- `bun run coverage` — coverage run; keep global >= 80%.
|
||||
@@ -37,6 +38,7 @@
|
||||
|
||||
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`…).
|
||||
- Keep changes scoped; avoid repo-wide search/replace.
|
||||
- Before commit/PR handoff, run `bun run format:check` and `bun run lint`; include commands run in the PR summary.
|
||||
- PRs: include summary + test commands run. Add screenshots for UI changes.
|
||||
- Before merging any PR, verify TypeScript cleanly with `bunx tsc -p packages/schema/tsconfig.json --noEmit` and `bunx tsc -p packages/clawhub/tsconfig.json --noEmit`; if Convex code changed, also run the repo typecheck path used by deploy so `bunx convex deploy` will not fail on `tsc`.
|
||||
- GitHub comments: for multiline `gh` comments/close messages, use `--body-file`, `--input`, or stdin/heredoc with real newlines; never pass literal `\\n` in shell strings.
|
||||
@@ -90,11 +92,13 @@
|
||||
- **Before writing or reviewing Convex queries, check deployment health.** Run `bunx convex insights` to check for OCC conflicts, `bytesReadLimit`, and `documentsReadLimit` errors. Run `bunx convex logs --failure` to see individual error messages and stack traces. This helps identify which functions are causing bandwidth issues so you can prioritize fixes.
|
||||
|
||||
<!-- convex-ai-start -->
|
||||
|
||||
This project uses [Convex](https://convex.dev) as its backend.
|
||||
|
||||
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
|
||||
|
||||
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
|
||||
|
||||
<!-- convex-ai-end -->
|
||||
|
||||
## Stat Field Migration Rules
|
||||
@@ -102,11 +106,11 @@ Convex agent skills for common tasks can be installed by running `npx convex ai-
|
||||
The `skills` table maintains two parallel sets of stat fields as part of an in-progress field migration:
|
||||
|
||||
| Legacy (nested, `@deprecated`) | Top-level (source of truth, indexable) |
|
||||
|---|---|
|
||||
| `stats.downloads` | `statsDownloads` |
|
||||
| `stats.stars` | `statsStars` |
|
||||
| `stats.installsCurrent` | `statsInstallsCurrent` |
|
||||
| `stats.installsAllTime` | `statsInstallsAllTime` |
|
||||
| ------------------------------ | -------------------------------------- |
|
||||
| `stats.downloads` | `statsDownloads` |
|
||||
| `stats.stars` | `statsStars` |
|
||||
| `stats.installsCurrent` | `statsInstallsCurrent` |
|
||||
| `stats.installsAllTime` | `statsInstallsAllTime` |
|
||||
|
||||
**Rules:**
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixes
|
||||
|
||||
- Moderation: calibrate VirusTotal Code Insight suspicious verdicts so uncorroborated AI-only findings do not keep otherwise clean skills quarantined (#1830, #1841) (thanks @deepujain).
|
||||
|
||||
## 0.11.0 - 2026-04-28
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -13,10 +13,15 @@
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/manrope": "^5.2.8",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@@ -38,6 +43,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"convex": "^1.34.1",
|
||||
"convex-helpers": "^0.1.114",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -88,7 +94,7 @@
|
||||
},
|
||||
"packages/clawhub": {
|
||||
"name": "clawhub",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"bin": {
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
"clawhub": "bin/clawdhub.js",
|
||||
@@ -473,15 +479,19 @@
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="],
|
||||
|
||||
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||
|
||||
@@ -495,19 +505,25 @@
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||
|
||||
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
@@ -841,6 +857,8 @@
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
@@ -1519,76 +1537,30 @@
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
"@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
"@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-scroll-area/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-toggle/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-toggle-group/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-toggle-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
@@ -1621,6 +1593,8 @@
|
||||
|
||||
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
@@ -1644,31 +1618,5 @@
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-toggle-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
Vendored
+8
@@ -95,10 +95,14 @@ import type * as lib_userSkillStats from "../lib/userSkillStats.js";
|
||||
import type * as lib_webhooks from "../lib/webhooks.js";
|
||||
import type * as llmEval from "../llmEval.js";
|
||||
import type * as maintenance from "../maintenance.js";
|
||||
import type * as model_packages_rescans from "../model/packages/rescans.js";
|
||||
import type * as model_rescans_policy from "../model/rescans/policy.js";
|
||||
import type * as model_skills_rescans from "../model/skills/rescans.js";
|
||||
import type * as packagePublishTokens from "../packagePublishTokens.js";
|
||||
import type * as packages from "../packages.js";
|
||||
import type * as publishers from "../publishers.js";
|
||||
import type * as rateLimits from "../rateLimits.js";
|
||||
import type * as rescanRequests from "../rescanRequests.js";
|
||||
import type * as search from "../search.js";
|
||||
import type * as seed from "../seed.js";
|
||||
import type * as seedSouls from "../seedSouls.js";
|
||||
@@ -212,10 +216,14 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/webhooks": typeof lib_webhooks;
|
||||
llmEval: typeof llmEval;
|
||||
maintenance: typeof maintenance;
|
||||
"model/packages/rescans": typeof model_packages_rescans;
|
||||
"model/rescans/policy": typeof model_rescans_policy;
|
||||
"model/skills/rescans": typeof model_skills_rescans;
|
||||
packagePublishTokens: typeof packagePublishTokens;
|
||||
packages: typeof packages;
|
||||
publishers: typeof publishers;
|
||||
rateLimits: typeof rateLimits;
|
||||
rescanRequests: typeof rescanRequests;
|
||||
search: typeof search;
|
||||
seed: typeof seed;
|
||||
seedSouls: typeof seedSouls;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { seedRescanUxFixturesHandler } from "./devSeed";
|
||||
import { MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE } from "./model/rescans/policy";
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return chainEq(constraints);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matches(doc: Record<string, unknown>, constraints: Record<string, unknown>) {
|
||||
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
|
||||
}
|
||||
|
||||
function createDb() {
|
||||
const tables: Record<string, Array<Record<string, unknown> & { _id: string }>> = {};
|
||||
const counters: Record<string, number> = {};
|
||||
|
||||
const list = (table: string) => {
|
||||
tables[table] ??= [];
|
||||
return tables[table];
|
||||
};
|
||||
|
||||
const db = {
|
||||
get: async (id: string) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
return list(table).find((doc) => doc._id === id) ?? null;
|
||||
},
|
||||
insert: async (table: string, doc: Record<string, unknown>) => {
|
||||
counters[table] = (counters[table] ?? 0) + 1;
|
||||
const inserted = {
|
||||
_id: `${table}:${counters[table]}`,
|
||||
_creationTime: counters[table],
|
||||
...doc,
|
||||
};
|
||||
list(table).push(inserted);
|
||||
return inserted._id;
|
||||
},
|
||||
patch: async (id: string, patch: Record<string, unknown>) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const doc = list(table).find((candidate) => candidate._id === id);
|
||||
if (doc) Object.assign(doc, patch);
|
||||
},
|
||||
delete: async (id: string) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const rows = list(table);
|
||||
const index = rows.findIndex((doc) => doc._id === id);
|
||||
if (index !== -1) rows.splice(index, 1);
|
||||
},
|
||||
query: (table: string) => ({
|
||||
withIndex: (_name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
const constraints: Record<string, unknown> = {};
|
||||
build(chainEq(constraints));
|
||||
const matched = () =>
|
||||
list(table).filter((doc) => matches(doc as Record<string, unknown>, constraints));
|
||||
return {
|
||||
collect: async () => matched(),
|
||||
unique: async () => matched()[0] ?? null,
|
||||
order: () => ({
|
||||
collect: async () => matched(),
|
||||
}),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return { db, tables };
|
||||
}
|
||||
|
||||
describe("devSeed rescan UX fixtures", () => {
|
||||
it("seeds flagged local owner inventory and deterministic rescan counts idempotently", async () => {
|
||||
const { db, tables } = createDb();
|
||||
const args = {
|
||||
flaggedSkillStorageId: "storage:skill",
|
||||
flaggedSkillMd: "# Flagged skill",
|
||||
flaggedPluginStorageId: "storage:plugin",
|
||||
flaggedPluginReadme: "# Flagged plugin",
|
||||
scannedPluginStorageId: "storage:scanned-plugin",
|
||||
scannedPluginReadme: "# Scanned plugin",
|
||||
};
|
||||
|
||||
await seedRescanUxFixturesHandler({ db } as never, args as never);
|
||||
await seedRescanUxFixturesHandler({ db } as never, args as never);
|
||||
await seedRescanUxFixturesHandler({ db } as never, { ...args, reset: true } as never);
|
||||
|
||||
expect(tables.users).toHaveLength(1);
|
||||
expect(tables.users?.[0]).toEqual(expect.objectContaining({ handle: "local" }));
|
||||
expect(tables.publishers).toHaveLength(1);
|
||||
expect(tables.skills).toHaveLength(1);
|
||||
expect(tables.skills?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerUserId: tables.users?.[0]?._id,
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
moderationStatus: "hidden",
|
||||
moderationVerdict: "malicious",
|
||||
}),
|
||||
);
|
||||
expect(tables.packages).toHaveLength(2);
|
||||
expect(tables.packages?.find((pkg) => pkg.name === "local-flagged-runtime-plugin")).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerUserId: tables.users?.[0]?._id,
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
scanStatus: "malicious",
|
||||
}),
|
||||
);
|
||||
expect(tables.packages?.find((pkg) => pkg.name === "local-scanned-runtime-plugin")).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerUserId: tables.users?.[0]?._id,
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
scanStatus: "suspicious",
|
||||
}),
|
||||
);
|
||||
|
||||
const scannedPackage = tables.packages?.find(
|
||||
(pkg) => pkg.name === "local-scanned-runtime-plugin",
|
||||
);
|
||||
const scannedRelease = tables.packageReleases?.find(
|
||||
(release) => release.packageId === scannedPackage?._id,
|
||||
);
|
||||
expect(scannedRelease).toEqual(
|
||||
expect.objectContaining({
|
||||
sha256hash: "seeded-scanned-plugin-hash",
|
||||
vtAnalysis: expect.objectContaining({ status: "clean" }),
|
||||
llmAnalysis: expect.objectContaining({ status: "suspicious" }),
|
||||
staticScan: expect.objectContaining({ status: "suspicious" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const skillRequests =
|
||||
tables.rescanRequests?.filter((request) => request.targetKind === "skill") ?? [];
|
||||
const pluginRequests =
|
||||
tables.rescanRequests?.filter((request) => request.targetKind === "plugin") ?? [];
|
||||
expect(skillRequests).toHaveLength(1);
|
||||
expect(pluginRequests).toHaveLength(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
|
||||
});
|
||||
});
|
||||
+1159
-24
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,26 @@ function findRateLimitCallArgs(mock: ReturnType<typeof vi.fn>) {
|
||||
return mock.mock.calls.map(([, args]) => args).find(isRateLimitArgs);
|
||||
}
|
||||
|
||||
function makeCatalogItem(
|
||||
name: string,
|
||||
options: {
|
||||
family: "code-plugin" | "bundle-plugin" | "skill";
|
||||
updatedAt: number;
|
||||
score?: number;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
name,
|
||||
displayName: name,
|
||||
family: options.family,
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: options.updatedAt,
|
||||
updatedAt: options.updatedAt,
|
||||
...(typeof options.score === "number" ? { score: options.score } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(partial: Record<string, unknown>) {
|
||||
const partialRunQuery =
|
||||
typeof partial.runQuery === "function"
|
||||
@@ -2114,6 +2134,7 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.user.handle).toBe("p");
|
||||
expect(json.user.role).toBeNull();
|
||||
});
|
||||
|
||||
it("delete and undelete require auth", async () => {
|
||||
@@ -2162,6 +2183,86 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(response2.status).toBe(200);
|
||||
});
|
||||
|
||||
it("skill rescan routes authenticated owners to the rescan mutation", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { handle: "p" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if ("key" in args) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
targetKind: "skill",
|
||||
name: args.slug,
|
||||
version: "1.2.3",
|
||||
status: "in_progress",
|
||||
remainingRequests: 2,
|
||||
maxRequests: 3,
|
||||
pendingRequestId: "rescanRequests:1",
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.skillsPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/demo/rescan", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: true,
|
||||
targetKind: "skill",
|
||||
name: "demo",
|
||||
remainingRequests: 2,
|
||||
maxRequests: 3,
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ actorUserId: "users:1", slug: "demo" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("package rescan routes authenticated owners to the rescan mutation", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { handle: "p" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if ("key" in args) return okRate();
|
||||
return {
|
||||
ok: true,
|
||||
targetKind: "package",
|
||||
name: args.name,
|
||||
version: "1.2.3",
|
||||
status: "in_progress",
|
||||
remainingRequests: 2,
|
||||
maxRequests: 3,
|
||||
pendingRequestId: "rescanRequests:1",
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/%40scope%2Fdemo/rescan", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: true,
|
||||
targetKind: "package",
|
||||
name: "@scope/demo",
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ actorUserId: "users:1", name: "@scope/demo" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("transfer request requires auth", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized"));
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
@@ -2499,7 +2600,11 @@ describe("httpApiV1 handlers", () => {
|
||||
});
|
||||
|
||||
it("packages search forwards executesCode and capabilityTag", async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue([]);
|
||||
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
|
||||
if ("paginationOpts" in args) return { page: [], isDone: true, continueCursor: "" };
|
||||
if ("query" in args) return [];
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
@@ -2511,10 +2616,9 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(runQuery).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
query: "test",
|
||||
limit: 5,
|
||||
executesCode: true,
|
||||
capabilityTag: "tools",
|
||||
paginationOpts: { cursor: null, numItems: 50 },
|
||||
}),
|
||||
);
|
||||
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
|
||||
@@ -2542,6 +2646,123 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("plugins list defaults to plugin package families", async () => {
|
||||
const codePlugin = {
|
||||
name: "code-plugin",
|
||||
displayName: "Code Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 20,
|
||||
updatedAt: 200,
|
||||
};
|
||||
const bundlePlugin = {
|
||||
name: "bundle-plugin",
|
||||
displayName: "Bundle Plugin",
|
||||
family: "bundle-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 10,
|
||||
updatedAt: 100,
|
||||
};
|
||||
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
|
||||
if (args.family === "code-plugin") {
|
||||
return { page: [codePlugin], isDone: true, continueCursor: "" };
|
||||
}
|
||||
if (args.family === "bundle-plugin") {
|
||||
return { page: [bundlePlugin], isDone: true, continueCursor: "" };
|
||||
}
|
||||
throw new Error(`unexpected family ${String(args.family)}`);
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.listPluginsV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/plugins?limit=7"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json()).items.map((entry: { name: string }) => entry.name)).toEqual([
|
||||
"code-plugin",
|
||||
"bundle-plugin",
|
||||
]);
|
||||
const families = runQuery.mock.calls.map(([, args]) => (args as { family?: string }).family);
|
||||
expect(families).toEqual(["code-plugin", "bundle-plugin"]);
|
||||
for (const [, args] of runQuery.mock.calls) {
|
||||
expect(args).toEqual(
|
||||
expect.objectContaining({
|
||||
paginationOpts: { cursor: null, numItems: 7 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("plugins list paginates with separate plugin family cursors", async () => {
|
||||
const codeNewest = makeCatalogItem("code-newest", {
|
||||
family: "code-plugin",
|
||||
updatedAt: 300,
|
||||
});
|
||||
const codeOlder = makeCatalogItem("code-older", {
|
||||
family: "code-plugin",
|
||||
updatedAt: 100,
|
||||
});
|
||||
const bundleMiddle = makeCatalogItem("bundle-middle", {
|
||||
family: "bundle-plugin",
|
||||
updatedAt: 200,
|
||||
});
|
||||
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
|
||||
const pagination = args.paginationOpts as { cursor: string | null };
|
||||
if (args.family === "code-plugin" && pagination.cursor === null) {
|
||||
return { page: [codeNewest], isDone: false, continueCursor: "code-cursor" };
|
||||
}
|
||||
if (args.family === "code-plugin" && pagination.cursor === "code-cursor") {
|
||||
return { page: [codeOlder], isDone: true, continueCursor: "" };
|
||||
}
|
||||
if (args.family === "bundle-plugin" && pagination.cursor === null) {
|
||||
return { page: [bundleMiddle], isDone: true, continueCursor: "" };
|
||||
}
|
||||
throw new Error(`unexpected args ${JSON.stringify(args)}`);
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const firstResponse = await __handlers.listPluginsV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/plugins?limit=1"),
|
||||
);
|
||||
expect(firstResponse.status).toBe(200);
|
||||
const firstJson = await firstResponse.json();
|
||||
expect(firstJson.items.map((entry: { name: string }) => entry.name)).toEqual(["code-newest"]);
|
||||
expect(firstJson.nextCursor).toMatch(/^pkgplugins:/);
|
||||
|
||||
const secondUrl = new URL("https://example.com/api/v1/plugins");
|
||||
secondUrl.searchParams.set("limit", "1");
|
||||
secondUrl.searchParams.set("cursor", firstJson.nextCursor);
|
||||
const secondResponse = await __handlers.listPluginsV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request(secondUrl),
|
||||
);
|
||||
expect(secondResponse.status).toBe(200);
|
||||
const secondJson = await secondResponse.json();
|
||||
expect(secondJson.items.map((entry: { name: string }) => entry.name)).toEqual([
|
||||
"bundle-middle",
|
||||
]);
|
||||
|
||||
const packageCalls = runQuery.mock.calls
|
||||
.map(([, args]) => args as { family?: string; paginationOpts?: { cursor: string | null } })
|
||||
.filter((args) => args.family === "code-plugin" || args.family === "bundle-plugin");
|
||||
expect(
|
||||
packageCalls.map((args) => ({
|
||||
family: args.family,
|
||||
cursor: args.paginationOpts?.cursor ?? null,
|
||||
})),
|
||||
).toEqual([
|
||||
{ family: "code-plugin", cursor: null },
|
||||
{ family: "bundle-plugin", cursor: null },
|
||||
{ family: "code-plugin", cursor: "code-cursor" },
|
||||
{ family: "bundle-plugin", cursor: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("packages search supports family=skill on the generic route", async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue([]);
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
@@ -2560,6 +2781,114 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("plugins search defaults to plugin package families", async () => {
|
||||
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
|
||||
if (args.family === "code-plugin") {
|
||||
return {
|
||||
page: [
|
||||
{
|
||||
name: "weather-code",
|
||||
displayName: "Weather Code",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 10,
|
||||
updatedAt: 100,
|
||||
},
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
};
|
||||
}
|
||||
if (args.family === "bundle-plugin") {
|
||||
return {
|
||||
page: [
|
||||
{
|
||||
name: "weather-bundle",
|
||||
displayName: "Weather Bundle",
|
||||
family: "bundle-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 20,
|
||||
updatedAt: 200,
|
||||
},
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected family ${String(args.family)}`);
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.pluginsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/plugins/search?q=weather&limit=7"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(
|
||||
(await response.json()).results.map(
|
||||
(entry: { package: { name: string } }) => entry.package.name,
|
||||
),
|
||||
).toEqual(["weather-bundle", "weather-code"]);
|
||||
const families = runQuery.mock.calls.map(([, args]) => (args as { family?: string }).family);
|
||||
expect(families).toEqual(["code-plugin", "bundle-plugin"]);
|
||||
for (const [, args] of runQuery.mock.calls) {
|
||||
expect(args).toEqual(
|
||||
expect.objectContaining({
|
||||
paginationOpts: { cursor: null, numItems: 50 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("plugins search dedupes and sorts results from both plugin families", async () => {
|
||||
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
|
||||
if (args.family === "code-plugin") {
|
||||
return {
|
||||
page: [
|
||||
makeCatalogItem("shared-plugin", { family: "code-plugin", updatedAt: 100 }),
|
||||
makeCatalogItem("plugin-code", { family: "code-plugin", updatedAt: 50 }),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
};
|
||||
}
|
||||
if (args.family === "bundle-plugin") {
|
||||
return {
|
||||
page: [
|
||||
makeCatalogItem("plugin-bundle", { family: "bundle-plugin", updatedAt: 80 }),
|
||||
makeCatalogItem("shared-plugin", { family: "bundle-plugin", updatedAt: 60 }),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected family ${String(args.family)}`);
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.pluginsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/plugins/search?q=plugin&limit=3"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(
|
||||
(await response.json()).results.map(
|
||||
(entry: { score: number; package: { family: string; name: string } }) => ({
|
||||
family: entry.package.family,
|
||||
name: entry.package.name,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{ family: "bundle-plugin", name: "plugin-bundle" },
|
||||
{ family: "code-plugin", name: "plugin-code" },
|
||||
{ family: "code-plugin", name: "shared-plugin" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("packages list forwards viewerUserId for authenticated private package browsing", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const runQuery = vi.fn().mockResolvedValue({ page: [], isDone: true, continueCursor: "" });
|
||||
@@ -2583,7 +2912,12 @@ describe("httpApiV1 handlers", () => {
|
||||
|
||||
it("packages search forwards viewerUserId for authenticated private package search", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const runQuery = vi.fn().mockResolvedValue([]);
|
||||
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
|
||||
if ("userId" in args) return { _id: args.userId };
|
||||
if ("paginationOpts" in args) return { page: [], isDone: true, continueCursor: "" };
|
||||
if ("query" in args) return [];
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
@@ -2595,9 +2929,9 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(runQuery).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
query: "secret",
|
||||
channel: "private",
|
||||
viewerUserId: "users:owner",
|
||||
paginationOpts: { cursor: null, numItems: 50 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -2629,7 +2963,8 @@ describe("httpApiV1 handlers", () => {
|
||||
if (query === internal.users.getByIdInternal) {
|
||||
throw new Error("Table mismatch");
|
||||
}
|
||||
if ("query" in args && args.query === "secret") return [];
|
||||
if ("paginationOpts" in args) return { page: [], isDone: true, continueCursor: "" };
|
||||
if ("query" in args) return [];
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
@@ -2647,9 +2982,9 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(runQuery).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
query: "secret",
|
||||
channel: "community",
|
||||
viewerUserId: undefined,
|
||||
paginationOpts: { cursor: null, numItems: 50 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
+308
-24
@@ -1,4 +1,3 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import {
|
||||
PackagePublishRequestSchema,
|
||||
PackageTrustedPublisherUpsertRequestSchema,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalActiveAuthUserIdFromAction } from "../lib/access";
|
||||
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
|
||||
import {
|
||||
fetchGitHubRepositoryIdentity,
|
||||
@@ -62,6 +62,7 @@ const internalRefs = internal as unknown as {
|
||||
getReleaseByPackageAndVersionInternal: unknown;
|
||||
getReleaseByIdInternal: unknown;
|
||||
insertAuditLogInternal: unknown;
|
||||
requestRescanForApiTokenInternal: unknown;
|
||||
softDeletePackageInternal: unknown;
|
||||
};
|
||||
packagePublishTokens: {
|
||||
@@ -90,12 +91,8 @@ async function getOptionalViewerUserIdForRequest(ctx: ActionCtx, request: Reques
|
||||
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request);
|
||||
if (apiTokenUserId) return apiTokenUserId;
|
||||
try {
|
||||
const userId = (await getAuthUserId(ctx)) ?? null;
|
||||
const userId = (await getOptionalActiveAuthUserIdFromAction(ctx)) ?? null;
|
||||
if (!userId) return null;
|
||||
const user = await runQueryRef<Doc<"users"> | null>(ctx, internal.users.getByIdInternal, {
|
||||
userId,
|
||||
});
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return null;
|
||||
return userId;
|
||||
} catch {
|
||||
// Public package reads should degrade to anonymous when cookie-backed auth is stale.
|
||||
@@ -107,6 +104,7 @@ type PackageListQueryArgs = {
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
@@ -254,6 +252,11 @@ type UnifiedCatalogCursorState = {
|
||||
skills: CatalogSourceCursorState;
|
||||
};
|
||||
|
||||
type PluginCatalogCursorState = {
|
||||
codePlugins: CatalogSourceCursorState;
|
||||
bundlePlugins: CatalogSourceCursorState;
|
||||
};
|
||||
|
||||
type CatalogPageResult = {
|
||||
page: CatalogListItem[];
|
||||
isDone: boolean;
|
||||
@@ -268,6 +271,7 @@ type CatalogSourceState = {
|
||||
};
|
||||
|
||||
const UNIFIED_CATALOG_CURSOR_PREFIX = "pkgcatalog:";
|
||||
const PLUGIN_CATALOG_CURSOR_PREFIX = "pkgplugins:";
|
||||
|
||||
function defaultCatalogSourceCursorState(): CatalogSourceCursorState {
|
||||
return { cursor: null, offset: 0, pageSize: null, done: false };
|
||||
@@ -308,6 +312,42 @@ function decodeUnifiedCatalogCursor(raw: string | null | undefined): UnifiedCata
|
||||
}
|
||||
}
|
||||
|
||||
function encodePluginCatalogCursor(state: PluginCatalogCursorState) {
|
||||
return `${PLUGIN_CATALOG_CURSOR_PREFIX}${JSON.stringify(state)}`;
|
||||
}
|
||||
|
||||
function decodePluginCatalogCursor(raw: string | null | undefined): PluginCatalogCursorState {
|
||||
const normalize = (
|
||||
input: Partial<CatalogSourceCursorState> | undefined,
|
||||
): CatalogSourceCursorState => ({
|
||||
cursor: typeof input?.cursor === "string" ? input.cursor : null,
|
||||
offset: typeof input?.offset === "number" && input.offset > 0 ? input.offset : 0,
|
||||
pageSize: typeof input?.pageSize === "number" && input.pageSize > 0 ? input.pageSize : null,
|
||||
done: input?.done === true,
|
||||
});
|
||||
|
||||
if (!raw?.startsWith(PLUGIN_CATALOG_CURSOR_PREFIX)) {
|
||||
return {
|
||||
codePlugins: { ...defaultCatalogSourceCursorState(), cursor: raw ?? null },
|
||||
bundlePlugins: defaultCatalogSourceCursorState(),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
raw.slice(PLUGIN_CATALOG_CURSOR_PREFIX.length),
|
||||
) as Partial<PluginCatalogCursorState>;
|
||||
return {
|
||||
codePlugins: normalize(parsed.codePlugins),
|
||||
bundlePlugins: normalize(parsed.bundlePlugins),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
codePlugins: defaultCatalogSourceCursorState(),
|
||||
bundlePlugins: defaultCatalogSourceCursorState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function initCatalogSource(state: CatalogSourceCursorState): CatalogSourceState {
|
||||
return {
|
||||
state: { ...state },
|
||||
@@ -324,7 +364,7 @@ function finalizeCatalogSource(source: CatalogSourceState): CatalogSourceCursorS
|
||||
cursor: source.pageCursor,
|
||||
offset: source.index,
|
||||
pageSize: source.state.pageSize,
|
||||
done: source.page.isDone,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -372,6 +412,102 @@ function compareCatalogItems(a: CatalogListItem, b: CatalogListItem) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
const HTTP_PACKAGE_SEARCH_PAGE_SIZE = 50;
|
||||
const HTTP_PACKAGE_SEARCH_SCAN_PAGES = 20;
|
||||
|
||||
function catalogSearchScore(item: CatalogListItem, queryText: string) {
|
||||
const needle = queryText.toLowerCase();
|
||||
const name = item.name.toLowerCase();
|
||||
const display = item.displayName.toLowerCase();
|
||||
const runtimeId = item.runtimeId?.toLowerCase() ?? "";
|
||||
const summary = (item.summary ?? "").toLowerCase();
|
||||
let score = 0;
|
||||
|
||||
if (name === needle) score += 200;
|
||||
else if (name.startsWith(needle)) score += 120;
|
||||
else if (name.includes(needle)) score += 80;
|
||||
|
||||
if (display === needle) score += 150;
|
||||
else if (display.startsWith(needle)) score += 70;
|
||||
else if (display.includes(needle)) score += 40;
|
||||
|
||||
if (runtimeId === needle) score += 180;
|
||||
else if (runtimeId.startsWith(needle)) score += 90;
|
||||
else if (runtimeId.includes(needle)) score += 45;
|
||||
|
||||
if (summary.includes(needle)) score += 20;
|
||||
if ((item.capabilityTags ?? []).some((entry) => entry.toLowerCase().includes(needle))) {
|
||||
score += 12;
|
||||
}
|
||||
if (item.isOfficial) score += 5;
|
||||
return score;
|
||||
}
|
||||
|
||||
function compareCatalogSearchEntries(a: CatalogSearchEntry, b: CatalogSearchEntry) {
|
||||
return (
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
compareCatalogItems(a.package, b.package)
|
||||
);
|
||||
}
|
||||
|
||||
async function searchPackageCatalogByListing(
|
||||
ctx: ActionCtx,
|
||||
args: {
|
||||
query: string;
|
||||
limit: number;
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
},
|
||||
): Promise<CatalogSearchEntry[]> {
|
||||
const queryText = args.query.trim().toLowerCase();
|
||||
if (!queryText) return [];
|
||||
|
||||
const matches: CatalogSearchEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
let done = false;
|
||||
let loops = 0;
|
||||
|
||||
while (!done && loops < HTTP_PACKAGE_SEARCH_SCAN_PAGES) {
|
||||
loops += 1;
|
||||
const result: {
|
||||
page: CatalogListItem[];
|
||||
isDone: boolean;
|
||||
continueCursor: string | null;
|
||||
} = await runQueryRef(ctx, internalRefs.packages.listPageForViewerInternal, {
|
||||
family: args.family,
|
||||
channel: args.channel,
|
||||
isOfficial: args.isOfficial,
|
||||
highlightedOnly: args.highlightedOnly,
|
||||
executesCode: args.executesCode,
|
||||
capabilityTag: args.capabilityTag,
|
||||
viewerUserId: args.viewerUserId,
|
||||
paginationOpts: { cursor, numItems: HTTP_PACKAGE_SEARCH_PAGE_SIZE },
|
||||
});
|
||||
|
||||
for (const item of result.page) {
|
||||
const key = `${item.family}:${item.name}`;
|
||||
if (seen.has(key)) continue;
|
||||
const score = catalogSearchScore(item, queryText);
|
||||
if (score <= 0) continue;
|
||||
seen.add(key);
|
||||
matches.push({ score, package: item });
|
||||
}
|
||||
|
||||
done = result.isDone;
|
||||
cursor = result.continueCursor;
|
||||
if (!cursor && !done) break;
|
||||
}
|
||||
|
||||
return matches.sort(compareCatalogSearchEntries).slice(0, args.limit);
|
||||
}
|
||||
|
||||
async function resolveSkillTags(
|
||||
ctx: ActionCtx,
|
||||
tags: Record<string, Id<"skillVersions">>,
|
||||
@@ -504,7 +640,7 @@ async function listPackages(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
family?: PackageListQueryArgs["family"],
|
||||
options?: { includeSkills?: boolean },
|
||||
options?: { includeSkills?: boolean; pluginFamilies?: Array<"code-plugin" | "bundle-plugin"> },
|
||||
) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
@@ -517,6 +653,11 @@ async function listPackages(
|
||||
const channelRaw = url.searchParams.get("channel")?.trim();
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const highlightedOnly =
|
||||
url.searchParams.get("featured") === "true" ||
|
||||
url.searchParams.get("featured") === "1" ||
|
||||
url.searchParams.get("highlightedOnly") === "true" ||
|
||||
url.searchParams.get("highlightedOnly") === "1";
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const effectiveFamily =
|
||||
family ??
|
||||
@@ -541,6 +682,7 @@ async function listPackages(
|
||||
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
paginationOpts: { cursor, numItems: limit },
|
||||
@@ -568,6 +710,7 @@ async function listPackages(
|
||||
}>(ctx, internalRefs.packages.listPageForViewerInternal, {
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -587,6 +730,7 @@ async function listPackages(
|
||||
}>(ctx, apiRefs.skills.listPackageCatalogPage, {
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
paginationOpts: { cursor: pageCursor, numItems },
|
||||
@@ -631,6 +775,85 @@ async function listPackages(
|
||||
);
|
||||
}
|
||||
|
||||
if (!effectiveFamily && options?.pluginFamilies?.length) {
|
||||
const decodedCursor = decodePluginCatalogCursor(cursor);
|
||||
const codePluginSource = initCatalogSource(decodedCursor.codePlugins);
|
||||
const bundlePluginSource = initCatalogSource(decodedCursor.bundlePlugins);
|
||||
const pageSize = limit;
|
||||
const items: CatalogListItem[] = [];
|
||||
const fetchPluginPage = async (
|
||||
pluginFamily: "code-plugin" | "bundle-plugin",
|
||||
pageCursor: string | null,
|
||||
numItems: number,
|
||||
) => {
|
||||
const result = await runQueryRef<{
|
||||
page: CatalogListItem[];
|
||||
isDone: boolean;
|
||||
continueCursor: string | null;
|
||||
}>(ctx, internalRefs.packages.listPageForViewerInternal, {
|
||||
family: pluginFamily,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
paginationOpts: { cursor: pageCursor, numItems },
|
||||
});
|
||||
return {
|
||||
page: result.page,
|
||||
isDone: result.isDone,
|
||||
continueCursor: result.continueCursor ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
while (items.length < limit) {
|
||||
const [codePluginCandidate, bundlePluginCandidate] = await Promise.all([
|
||||
options.pluginFamilies.includes("code-plugin")
|
||||
? ensureCatalogSourcePage(codePluginSource, pageSize, (pageCursor, numItems) =>
|
||||
fetchPluginPage("code-plugin", pageCursor, numItems),
|
||||
)
|
||||
: Promise.resolve(null),
|
||||
options.pluginFamilies.includes("bundle-plugin")
|
||||
? ensureCatalogSourcePage(bundlePluginSource, pageSize, (pageCursor, numItems) =>
|
||||
fetchPluginPage("bundle-plugin", pageCursor, numItems),
|
||||
)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (!codePluginCandidate && !bundlePluginCandidate) break;
|
||||
if (
|
||||
!bundlePluginCandidate ||
|
||||
(codePluginCandidate &&
|
||||
compareCatalogItems(codePluginCandidate, bundlePluginCandidate) <= 0)
|
||||
) {
|
||||
items.push(codePluginCandidate!);
|
||||
codePluginSource.index += 1;
|
||||
} else {
|
||||
items.push(bundlePluginCandidate);
|
||||
bundlePluginSource.index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
codePlugins: finalizeCatalogSource(codePluginSource),
|
||||
bundlePlugins: finalizeCatalogSource(bundlePluginSource),
|
||||
};
|
||||
const isDoneAll =
|
||||
nextState.codePlugins.done &&
|
||||
nextState.codePlugins.offset === 0 &&
|
||||
nextState.bundlePlugins.done &&
|
||||
nextState.bundlePlugins.offset === 0;
|
||||
return json(
|
||||
{
|
||||
items,
|
||||
nextCursor: isDoneAll ? null : encodePluginCatalogCursor(nextState),
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await runQueryRef<{
|
||||
page: unknown[];
|
||||
isDone: boolean;
|
||||
@@ -639,6 +862,7 @@ async function listPackages(
|
||||
family: effectiveFamily,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -656,7 +880,10 @@ export async function listPackagesV1Handler(ctx: ActionCtx, request: Request) {
|
||||
}
|
||||
|
||||
export async function listPluginsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
return await listPackages(ctx, request, undefined, { includeSkills: false });
|
||||
return await listPackages(ctx, request, undefined, {
|
||||
includeSkills: false,
|
||||
pluginFamilies: ["code-plugin", "bundle-plugin"],
|
||||
});
|
||||
}
|
||||
|
||||
export async function listCodePluginsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
@@ -823,6 +1050,31 @@ export async function mintPublishTokenV1Handler(ctx: ActionCtx, request: Request
|
||||
|
||||
export async function packagesPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/packages/");
|
||||
if (segments[1] === "rescan" && segments.length === 2) {
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
if (!rate.ok) return rate.response;
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
try {
|
||||
const result = await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.packages.requestRescanForApiTokenInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
name: segments[0]!,
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return text(
|
||||
error instanceof Error ? error.message : "Rescan request failed",
|
||||
400,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (segments[1] !== "trusted-publisher" || segments.length !== 2) {
|
||||
return text("Not found", 404);
|
||||
}
|
||||
@@ -1028,7 +1280,7 @@ async function getSkillVersionForRequest(
|
||||
async function searchPackages(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
options?: { includeSkills?: boolean },
|
||||
options?: { includeSkills?: boolean; pluginFamilies?: Array<"code-plugin" | "bundle-plugin"> },
|
||||
) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
@@ -1040,6 +1292,11 @@ async function searchPackages(
|
||||
const familyRaw = url.searchParams.get("family");
|
||||
const channelRaw = url.searchParams.get("channel");
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const highlightedOnly =
|
||||
url.searchParams.get("featured") === "true" ||
|
||||
url.searchParams.get("featured") === "1" ||
|
||||
url.searchParams.get("highlightedOnly") === "true" ||
|
||||
url.searchParams.get("highlightedOnly") === "1";
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const family =
|
||||
@@ -1066,32 +1323,60 @@ async function searchPackages(
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
},
|
||||
);
|
||||
} else if (family || !includeSkills) {
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(
|
||||
ctx,
|
||||
internalRefs.packages.searchForViewerInternal,
|
||||
{
|
||||
if (!family && options?.pluginFamilies?.length) {
|
||||
const pluginResults = await Promise.all(
|
||||
options.pluginFamilies.map((pluginFamily) =>
|
||||
searchPackageCatalogByListing(ctx, {
|
||||
query: queryText,
|
||||
limit,
|
||||
family: pluginFamily,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
results = pluginResults
|
||||
.flat()
|
||||
.filter((entry) => {
|
||||
const key = `${entry.package.family}:${entry.package.name}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort(compareCatalogSearchEntries)
|
||||
.slice(0, limit);
|
||||
} else {
|
||||
results = await searchPackageCatalogByListing(ctx, {
|
||||
query: queryText,
|
||||
limit,
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const [packageResults, skillResults] = await Promise.all([
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
|
||||
searchPackageCatalogByListing(ctx, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
@@ -1101,6 +1386,7 @@ async function searchPackages(
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
}),
|
||||
@@ -1113,12 +1399,7 @@ async function searchPackages(
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
compareCatalogItems(a.package, b.package),
|
||||
)
|
||||
.sort(compareCatalogSearchEntries)
|
||||
.slice(0, limit);
|
||||
}
|
||||
return json({ results }, 200, rate.headers);
|
||||
@@ -1428,7 +1709,10 @@ export async function pluginsGetRouterV1Handler(ctx: ActionCtx, request: Request
|
||||
const segments = getPathSegments(request, "/api/v1/plugins/");
|
||||
if (segments.length === 0) return text("Not found", 404);
|
||||
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
|
||||
return await searchPackages(ctx, request, { includeSkills: false });
|
||||
return await searchPackages(ctx, request, {
|
||||
includeSkills: false,
|
||||
pluginFamilies: ["code-plugin", "bundle-plugin"],
|
||||
});
|
||||
}
|
||||
return text("Not found", 404);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api, internal } from "../_generated/api";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
|
||||
@@ -231,6 +231,16 @@ type SkillSecuritySnapshot = {
|
||||
};
|
||||
};
|
||||
|
||||
const internalRefs = internal as unknown as {
|
||||
skills: {
|
||||
requestRescanForApiTokenInternal: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
async function runMutationRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Promise<T> {
|
||||
return (await ctx.runMutation(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
function isDefinitiveSecurityStatus(
|
||||
status: NormalizedSecurityStatus | null | undefined,
|
||||
): status is "clean" | "suspicious" | "malicious" {
|
||||
@@ -1165,6 +1175,29 @@ export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request
|
||||
}
|
||||
}
|
||||
|
||||
if (segments.length === 2 && action === "rescan") {
|
||||
if (!slug) return text("Slug required", 400, rate.headers);
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
try {
|
||||
const result = await runMutationRef(
|
||||
ctx,
|
||||
internalRefs.skills.requestRescanForApiTokenInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
slug,
|
||||
},
|
||||
);
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return text(
|
||||
error instanceof Error ? error.message : "Rescan request failed",
|
||||
400,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "transfer") {
|
||||
return handleSkillsTransferPost(ctx, request, segments, rate.headers);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export async function whoamiV1Handler(ctx: ActionCtx, request: Request) {
|
||||
handle: user.handle ?? null,
|
||||
displayName: user.displayName ?? null,
|
||||
image: user.image ?? null,
|
||||
role: user.role ?? null,
|
||||
},
|
||||
},
|
||||
200,
|
||||
|
||||
+62
-6
@@ -5,17 +5,61 @@ import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server";
|
||||
|
||||
export type Role = "admin" | "moderator" | "user";
|
||||
|
||||
const DEV_IMPERSONATE_LOCAL_HANDLE = "local";
|
||||
|
||||
function readEnv(name: string) {
|
||||
const value = process.env[name]?.trim();
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
function isDevImpersonationAllowed() {
|
||||
const requestedHandle = readEnv("CLAW_HUB_DEV_IMPERSONATE_USER_HANDLE");
|
||||
if (requestedHandle !== DEV_IMPERSONATE_LOCAL_HANDLE) return false;
|
||||
|
||||
const deployment = readEnv("CONVEX_DEPLOYMENT") ?? "";
|
||||
if (deployment.startsWith("prod:") || deployment.includes("production")) return false;
|
||||
return (
|
||||
deployment.startsWith("anonymous:") ||
|
||||
deployment.startsWith("dev:") ||
|
||||
deployment.startsWith("local:") ||
|
||||
readEnv("CLAW_HUB_ENABLE_DEV_IMPERSONATION") === "1"
|
||||
);
|
||||
}
|
||||
|
||||
async function getDevImpersonatedUserId(
|
||||
ctx: Pick<MutationCtx | QueryCtx, "db">,
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
if (!isDevImpersonationAllowed()) return undefined;
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", DEV_IMPERSONATE_LOCAL_HANDLE))
|
||||
.unique();
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return user._id;
|
||||
}
|
||||
|
||||
async function getDevImpersonatedUserIdFromAction(
|
||||
ctx: ActionCtx,
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
if (!isDevImpersonationAllowed()) return undefined;
|
||||
const user = await ctx.runQuery(internal.users.getByHandleInternal, {
|
||||
handle: DEV_IMPERSONATE_LOCAL_HANDLE,
|
||||
});
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return user._id;
|
||||
}
|
||||
|
||||
export async function getOptionalActiveAuthUserId(
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
try {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return undefined;
|
||||
if (!userId) return await getDevImpersonatedUserId(ctx);
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return userId;
|
||||
} catch {
|
||||
return undefined;
|
||||
return await getDevImpersonatedUserId(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,17 +68,23 @@ export async function getOptionalActiveAuthUserIdFromAction(
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
try {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return undefined;
|
||||
if (!userId) return await getDevImpersonatedUserIdFromAction(ctx);
|
||||
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return userId;
|
||||
} catch {
|
||||
return undefined;
|
||||
return await getDevImpersonatedUserIdFromAction(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireUser(ctx: MutationCtx | QueryCtx) {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
let userId: Id<"users"> | null | undefined = null;
|
||||
try {
|
||||
userId = await getAuthUserId(ctx);
|
||||
} catch {
|
||||
userId = null;
|
||||
}
|
||||
userId ??= await getDevImpersonatedUserId(ctx);
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
let user: Doc<"users"> | null;
|
||||
try {
|
||||
@@ -49,7 +99,13 @@ export async function requireUser(ctx: MutationCtx | QueryCtx) {
|
||||
export async function requireUserFromAction(
|
||||
ctx: ActionCtx,
|
||||
): Promise<{ userId: Id<"users">; user: Doc<"users"> }> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
let userId: Id<"users"> | null | undefined = null;
|
||||
try {
|
||||
userId = await getAuthUserId(ctx);
|
||||
} catch {
|
||||
userId = null;
|
||||
}
|
||||
userId ??= await getDevImpersonatedUserIdFromAction(ctx);
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
let user: Doc<"users"> | null;
|
||||
try {
|
||||
|
||||
@@ -23,6 +23,57 @@ describe("moderationEngine", () => {
|
||||
expect(result.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("flags hardcoded API secrets in skill documentation and redacts every evidence copy", () => {
|
||||
const exposedSecret = "ak_live_1234567890abcdefSECRET";
|
||||
const result = runStaticModerationScan({
|
||||
slug: "seo-admin",
|
||||
displayName: "SEO Admin",
|
||||
summary: "Manage production SEO content",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"# SEO Admin",
|
||||
"Production endpoint: https://example.com/admin/api",
|
||||
`API secret: ${exposedSecret} # rotate ${exposedSecret}`,
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.exposed_secret_literal");
|
||||
expect(result.status).toBe("suspicious");
|
||||
expect(result.findings[0]?.evidence).toContain("[REDACTED]");
|
||||
expect(result.findings[0]?.evidence).not.toContain(exposedSecret);
|
||||
});
|
||||
|
||||
it("does not flag placeholder or env-var secret examples", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
summary: "A normal integration skill",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 128 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Set `API secret: your-secret-here` before running the sample.",
|
||||
"const api_key = process.env.PROVIDER_API_KEY;",
|
||||
"api_secret = os.environ['PROVIDER_API_SECRET']",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).not.toContain("suspicious.exposed_secret_literal");
|
||||
expect(result.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("flags dynamic eval usage as suspicious", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "demo",
|
||||
@@ -527,4 +578,60 @@ describe("moderationEngine", () => {
|
||||
expect(snapshot.reasonCodes).toContain("suspicious.env_credential_access");
|
||||
expect(snapshot.reasonCodes).toContain("suspicious.vt_suspicious");
|
||||
});
|
||||
|
||||
it("does not let uncorroborated VT Code Insight suspicious override clean local scans", () => {
|
||||
const snapshot = buildModerationSnapshot({
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "",
|
||||
engineVersion: "v2.1.1",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
vtAnalysis: {
|
||||
status: "suspicious",
|
||||
scanner: "code_insight",
|
||||
source: "VirusTotal Code Insight",
|
||||
engineStats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 12,
|
||||
undetected: 54,
|
||||
},
|
||||
},
|
||||
llmStatus: "clean",
|
||||
});
|
||||
|
||||
expect(snapshot.verdict).toBe("clean");
|
||||
expect(snapshot.reasonCodes).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps VT Code Insight suspicious when AV engines also report suspicious", () => {
|
||||
const snapshot = buildModerationSnapshot({
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "",
|
||||
engineVersion: "v2.1.1",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
vtAnalysis: {
|
||||
status: "suspicious",
|
||||
scanner: "code_insight",
|
||||
source: "VirusTotal Code Insight",
|
||||
engineStats: {
|
||||
malicious: 0,
|
||||
suspicious: 1,
|
||||
harmless: 12,
|
||||
undetected: 53,
|
||||
},
|
||||
},
|
||||
llmStatus: "clean",
|
||||
});
|
||||
|
||||
expect(snapshot.verdict).toBe("suspicious");
|
||||
expect(snapshot.reasonCodes).toContain("suspicious.vt_suspicious");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,22 @@ import {
|
||||
} from "./moderationReasonCodes";
|
||||
|
||||
type TextFile = { path: string; content: string };
|
||||
type VirusTotalEngineStats = {
|
||||
malicious?: number;
|
||||
suspicious?: number;
|
||||
undetected?: number;
|
||||
harmless?: number;
|
||||
};
|
||||
|
||||
type VirusTotalAnalysis = {
|
||||
status?: string;
|
||||
scanner?: string;
|
||||
source?: string;
|
||||
engineStats?: VirusTotalEngineStats;
|
||||
metadata?: {
|
||||
stats?: VirusTotalEngineStats;
|
||||
};
|
||||
};
|
||||
|
||||
export type StaticScanInput = {
|
||||
slug: string;
|
||||
@@ -58,6 +74,10 @@ const HARDCODED_CONNECTION_ID_PATTERN =
|
||||
/["']connection_id["']\s*:\s*["'][0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}["']/i;
|
||||
const GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN =
|
||||
/https?:\/\/[^\s"'`]*\/spreadsheets\/([A-Za-z0-9_-]{20,})\/[^\s"'`]*/i;
|
||||
const SECRET_ASSIGNMENT_PATTERN =
|
||||
/\b(?:api[_\s-]?(?:secret|key)|secret[_\s-]?key|access[_\s-]?token|auth[_\s-]?token|bearer[_\s-]?token|password)\b\s*[:=]\s*["'`]?([A-Za-z0-9][A-Za-z0-9._~+/=-]{15,})["'`]?/i;
|
||||
const AUTH_HEADER_SECRET_PATTERN =
|
||||
/\b(?:authorization|x-api-key|x-api-secret)\b\s*[:=]\s*(?:Bearer\s+)?["'`]?([A-Za-z0-9][A-Za-z0-9._~+/=-]{15,})["'`]?/i;
|
||||
|
||||
function hasMaliciousInstallPrompt(content: string) {
|
||||
const hasTerminalInstruction =
|
||||
@@ -87,6 +107,31 @@ function looksLikePlaceholderIdentifier(identifier: string) {
|
||||
return /^[A-Z0-9_]+$/.test(identifier) || /(your|example|placeholder)/i.test(identifier);
|
||||
}
|
||||
|
||||
function looksLikePlaceholderSecret(secret: string) {
|
||||
const normalized = secret.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
if (/^(?:x+|_+|-+|\*+|\.{3})$/.test(normalized)) return true;
|
||||
if (/process\.env\.|os\.environ[.[]|getenv\s*\(/.test(normalized)) return true;
|
||||
return /(your|example|placeholder|change-?me|replace|redacted|dummy|sample|test-token|token-here|secret-here|api-key-here)/i.test(
|
||||
normalized,
|
||||
);
|
||||
}
|
||||
|
||||
function findHardcodedSecret(content: string) {
|
||||
const lines = content.split("\n");
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
const match = line.match(SECRET_ASSIGNMENT_PATTERN) ?? line.match(AUTH_HEADER_SECRET_PATTERN);
|
||||
const secret = match?.[1];
|
||||
if (!secret || looksLikePlaceholderSecret(secret)) continue;
|
||||
return {
|
||||
line: i + 1,
|
||||
text: line.replaceAll(secret, "[REDACTED]"),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addFinding(
|
||||
findings: ModerationFinding[],
|
||||
finding: Omit<ModerationFinding, "evidence"> & { evidence: string },
|
||||
@@ -297,6 +342,18 @@ function scanCodeFile(
|
||||
function scanMarkdownFile(path: string, content: string, findings: ModerationFinding[]) {
|
||||
if (!MARKDOWN_EXTENSION.test(path)) return;
|
||||
|
||||
const secretMatch = findHardcodedSecret(content);
|
||||
if (secretMatch) {
|
||||
addFinding(findings, {
|
||||
code: REASON_CODES.EXPOSED_SECRET_LITERAL,
|
||||
severity: "critical",
|
||||
file: path,
|
||||
line: secretMatch.line,
|
||||
message: "Documentation appears to expose a hardcoded API secret or token.",
|
||||
evidence: secretMatch.text,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMaliciousInstallPrompt(content)) {
|
||||
const match = findFirstLine(
|
||||
content,
|
||||
@@ -412,11 +469,42 @@ function dedupeEvidence(evidence: ModerationFinding[]) {
|
||||
return out.slice(0, 40);
|
||||
}
|
||||
|
||||
function addScannerStatusReason(reasonCodes: string[], scanner: "vt" | "llm", status?: string) {
|
||||
function isStaticScanClean(staticScan: StaticScanResult | undefined) {
|
||||
// Older moderation records can predate static scan persistence; absence means
|
||||
// there are no static findings available to corroborate an external signal.
|
||||
return !staticScan || staticScan.reasonCodes.length === 0 || staticScan.status === "clean";
|
||||
}
|
||||
|
||||
function isAvEngineStatsClean(stats: VirusTotalEngineStats | undefined) {
|
||||
if (!stats) return false;
|
||||
return (stats.malicious ?? 0) === 0 && (stats.suspicious ?? 0) === 0;
|
||||
}
|
||||
|
||||
function getVtEngineStats(analysis: VirusTotalAnalysis | undefined) {
|
||||
return analysis?.engineStats ?? analysis?.metadata?.stats;
|
||||
}
|
||||
|
||||
function isUncorroboratedVtCodeInsightSuspicious(params: {
|
||||
vtAnalysis?: VirusTotalAnalysis;
|
||||
staticScan?: StaticScanResult;
|
||||
llmStatus?: string;
|
||||
}) {
|
||||
if (params.vtAnalysis?.scanner !== "code_insight") return false;
|
||||
if (!isExternalScannerClean(params.llmStatus)) return false;
|
||||
if (!isStaticScanClean(params.staticScan)) return false;
|
||||
return isAvEngineStatsClean(getVtEngineStats(params.vtAnalysis));
|
||||
}
|
||||
|
||||
function addScannerStatusReason(
|
||||
reasonCodes: string[],
|
||||
scanner: "vt" | "llm",
|
||||
status?: string,
|
||||
options: { suppressSuspicious?: boolean } = {},
|
||||
) {
|
||||
const normalized = status?.trim().toLowerCase();
|
||||
if (normalized === "malicious") {
|
||||
reasonCodes.push(`malicious.${scanner}_malicious`);
|
||||
} else if (normalized === "suspicious") {
|
||||
} else if (normalized === "suspicious" && !options.suppressSuspicious) {
|
||||
reasonCodes.push(`suspicious.${scanner}_suspicious`);
|
||||
}
|
||||
}
|
||||
@@ -493,6 +581,7 @@ function isExternalScannerClean(status: string | undefined): boolean {
|
||||
|
||||
export function buildModerationSnapshot(params: {
|
||||
staticScan?: StaticScanResult;
|
||||
vtAnalysis?: VirusTotalAnalysis;
|
||||
vtStatus?: string;
|
||||
llmStatus?: string;
|
||||
sourceVersionId?: Id<"skillVersions">;
|
||||
@@ -510,7 +599,14 @@ export function buildModerationSnapshot(params: {
|
||||
}
|
||||
|
||||
const reasonCodes = [...staticCodes];
|
||||
addScannerStatusReason(reasonCodes, "vt", params.vtStatus);
|
||||
const vtStatus = params.vtStatus ?? params.vtAnalysis?.status;
|
||||
addScannerStatusReason(reasonCodes, "vt", vtStatus, {
|
||||
suppressSuspicious: isUncorroboratedVtCodeInsightSuspicious({
|
||||
vtAnalysis: params.vtAnalysis,
|
||||
staticScan: params.staticScan,
|
||||
llmStatus: params.llmStatus,
|
||||
}),
|
||||
});
|
||||
addScannerStatusReason(reasonCodes, "llm", params.llmStatus);
|
||||
|
||||
const normalizedCodes = normalizeReasonCodes(reasonCodes);
|
||||
|
||||
@@ -12,13 +12,14 @@ export type ModerationFinding = {
|
||||
evidence: string;
|
||||
};
|
||||
|
||||
export const MODERATION_ENGINE_VERSION = "v2.4.1";
|
||||
export const MODERATION_ENGINE_VERSION = "v2.4.2";
|
||||
|
||||
export const REASON_CODES = {
|
||||
DANGEROUS_EXEC: "suspicious.dangerous_exec",
|
||||
DYNAMIC_CODE: "suspicious.dynamic_code_execution",
|
||||
GENERATED_SOURCE_TEMPLATE: "suspicious.generated_source_template_injection",
|
||||
EXPOSED_RESOURCE_IDENTIFIER: "suspicious.exposed_resource_identifier",
|
||||
EXPOSED_SECRET_LITERAL: "suspicious.exposed_secret_literal",
|
||||
CREDENTIAL_HARVEST: "suspicious.env_credential_access",
|
||||
EXFILTRATION: "suspicious.potential_exfiltration",
|
||||
OBFUSCATED_CODE: "suspicious.obfuscated_code",
|
||||
|
||||
@@ -77,6 +77,34 @@ export function isPublisherRoleAllowed(role: PublisherRole, allowed: PublisherRo
|
||||
return allowed.some((candidate) => ranks[role] >= ranks[candidate]);
|
||||
}
|
||||
|
||||
export type OwnedResourceActor = {
|
||||
_id: Id<"users">;
|
||||
role?: Doc<"users">["role"];
|
||||
};
|
||||
|
||||
export async function assertCanManageOwnedResource(
|
||||
ctx: DbCtx,
|
||||
params: {
|
||||
actor: OwnedResourceActor;
|
||||
ownerUserId: Id<"users">;
|
||||
ownerPublisherId?: Id<"publishers"> | null;
|
||||
allowedPublisherRoles?: PublisherRole[];
|
||||
allowPlatformAdmin?: boolean;
|
||||
},
|
||||
) {
|
||||
if (params.allowPlatformAdmin && params.actor.role === "admin") return;
|
||||
if (params.ownerUserId === params.actor._id) return;
|
||||
if (!params.ownerPublisherId) throw new ConvexError("Forbidden");
|
||||
|
||||
const membership = await getPublisherMembership(ctx, params.ownerPublisherId, params.actor._id);
|
||||
if (
|
||||
!membership ||
|
||||
!isPublisherRoleAllowed(membership.role, params.allowedPublisherRoles ?? ["admin"])
|
||||
) {
|
||||
throw new ConvexError("Forbidden");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPublisherByHandle(ctx: DbCtx, handle: string | undefined | null) {
|
||||
const normalized = normalizePublisherHandle(handle);
|
||||
if (!normalized) return null;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import type { Doc, Id } from "../../_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "../../_generated/server";
|
||||
import type { OwnedResourceActor } from "../../lib/publishers";
|
||||
|
||||
export async function getLatestPackageRescanTarget(
|
||||
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
||||
packageId: Id<"packages">,
|
||||
) {
|
||||
const pkg = await ctx.db.get(packageId);
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
||||
throw new ConvexError("Plugin not found");
|
||||
}
|
||||
if (!pkg.latestReleaseId) throw new ConvexError("Plugin has no published release");
|
||||
const release = await ctx.db.get(pkg.latestReleaseId);
|
||||
if (!release || release.softDeletedAt) throw new ConvexError("Latest plugin release not found");
|
||||
return { pkg, release };
|
||||
}
|
||||
|
||||
export async function insertPackageRescanRequest(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
actor: OwnedResourceActor,
|
||||
target: {
|
||||
pkg: Doc<"packages">;
|
||||
release: Doc<"packageReleases">;
|
||||
},
|
||||
) {
|
||||
const now = Date.now();
|
||||
return await ctx.db.insert("rescanRequests", {
|
||||
targetKind: "plugin",
|
||||
packageId: target.pkg._id,
|
||||
packageReleaseId: target.release._id,
|
||||
targetVersion: target.release.version,
|
||||
requestedByUserId: actor._id,
|
||||
ownerUserId: target.pkg.ownerUserId,
|
||||
ownerPublisherId: target.pkg.ownerPublisherId,
|
||||
status: "in_progress",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import type { Doc, Id } from "../../_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "../../_generated/server";
|
||||
|
||||
export const MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE = 3;
|
||||
const ACTIVE_RESCAN_STATUS = "in_progress" as const;
|
||||
const NON_TERMINAL_SCAN_STATUSES = new Set(["loading", "not_found", "pending"]);
|
||||
const FAILED_SCAN_STATUSES = new Set(["error", "failed", "stale"]);
|
||||
|
||||
export type RescanTarget =
|
||||
| {
|
||||
kind: "skill";
|
||||
artifactId: Id<"skillVersions">;
|
||||
}
|
||||
| {
|
||||
kind: "plugin";
|
||||
artifactId: Id<"packageReleases">;
|
||||
};
|
||||
|
||||
export function serializeRescanRequest(request: Doc<"rescanRequests"> | null) {
|
||||
if (!request) return null;
|
||||
return {
|
||||
_id: request._id,
|
||||
targetKind: request.targetKind,
|
||||
targetVersion: request.targetVersion,
|
||||
requestedByUserId: request.requestedByUserId,
|
||||
status: request.status,
|
||||
error: request.error,
|
||||
createdAt: request.createdAt,
|
||||
updatedAt: request.updatedAt,
|
||||
completedAt: request.completedAt,
|
||||
};
|
||||
}
|
||||
|
||||
type ScanSignal = {
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
};
|
||||
|
||||
export type RescanScanState = {
|
||||
staticScan?: ScanSignal;
|
||||
vtAnalysis?: ScanSignal;
|
||||
llmAnalysis?: ScanSignal;
|
||||
};
|
||||
|
||||
function freshTerminalSignal(signal: ScanSignal | undefined, requestedAt: number) {
|
||||
if (!signal || signal.checkedAt < requestedAt) return null;
|
||||
const status = signal.status.trim().toLowerCase();
|
||||
if (NON_TERMINAL_SCAN_STATUSES.has(status)) return null;
|
||||
return status;
|
||||
}
|
||||
|
||||
function terminalRequestStatusForScanState(
|
||||
scanState: RescanScanState,
|
||||
requestedAt: number,
|
||||
): "completed" | "failed" | null {
|
||||
const statuses = [
|
||||
freshTerminalSignal(scanState.staticScan, requestedAt),
|
||||
freshTerminalSignal(scanState.vtAnalysis, requestedAt),
|
||||
freshTerminalSignal(scanState.llmAnalysis, requestedAt),
|
||||
];
|
||||
if (statuses.some((status) => status === null)) return null;
|
||||
if (statuses.some((status) => FAILED_SCAN_STATUSES.has(status!))) return "failed";
|
||||
return "completed";
|
||||
}
|
||||
|
||||
export async function listRequestsForTarget(
|
||||
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
||||
target: RescanTarget,
|
||||
) {
|
||||
if (target.kind === "skill") {
|
||||
return await ctx.db
|
||||
.query("rescanRequests")
|
||||
.withIndex("by_skill_version", (q) =>
|
||||
q.eq("targetKind", "skill").eq("skillVersionId", target.artifactId),
|
||||
)
|
||||
.order("desc")
|
||||
.take(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE + 1);
|
||||
}
|
||||
|
||||
return await ctx.db
|
||||
.query("rescanRequests")
|
||||
.withIndex("by_package_release", (q) =>
|
||||
q.eq("targetKind", "plugin").eq("packageReleaseId", target.artifactId),
|
||||
)
|
||||
.order("desc")
|
||||
.take(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE + 1);
|
||||
}
|
||||
|
||||
export async function getInProgressRequestForTarget(
|
||||
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
||||
target: RescanTarget,
|
||||
) {
|
||||
if (target.kind === "skill") {
|
||||
return await ctx.db
|
||||
.query("rescanRequests")
|
||||
.withIndex("by_skill_version_status", (q) =>
|
||||
q
|
||||
.eq("targetKind", "skill")
|
||||
.eq("skillVersionId", target.artifactId)
|
||||
.eq("status", ACTIVE_RESCAN_STATUS),
|
||||
)
|
||||
.order("desc")
|
||||
.first();
|
||||
}
|
||||
|
||||
return await ctx.db
|
||||
.query("rescanRequests")
|
||||
.withIndex("by_package_release_status", (q) =>
|
||||
q
|
||||
.eq("targetKind", "plugin")
|
||||
.eq("packageReleaseId", target.artifactId)
|
||||
.eq("status", ACTIVE_RESCAN_STATUS),
|
||||
)
|
||||
.order("desc")
|
||||
.first();
|
||||
}
|
||||
|
||||
export async function assertCanRequestRescan(
|
||||
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
||||
target: RescanTarget,
|
||||
) {
|
||||
const existingInProgress = await getInProgressRequestForTarget(ctx, target);
|
||||
if (existingInProgress) {
|
||||
throw new ConvexError("A rescan request is already in progress for this release");
|
||||
}
|
||||
|
||||
const existingRequests = await listRequestsForTarget(ctx, target);
|
||||
if (existingRequests.length >= MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE) {
|
||||
throw new ConvexError(
|
||||
`Rescan request limit reached for this release (${MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildRescanState(
|
||||
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
||||
target: RescanTarget,
|
||||
) {
|
||||
const requests = await listRequestsForTarget(ctx, target);
|
||||
const inProgressRequest =
|
||||
requests.find((request) => request.status === ACTIVE_RESCAN_STATUS) ?? null;
|
||||
const requestCount = Math.min(requests.length, MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
|
||||
return {
|
||||
maxRequests: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
|
||||
requestCount,
|
||||
remainingRequests: Math.max(0, MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE - requestCount),
|
||||
canRequest:
|
||||
requestCount < MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE && inProgressRequest === null,
|
||||
inProgressRequest: serializeRescanRequest(inProgressRequest),
|
||||
latestRequest: serializeRescanRequest(requests[0] ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
async function listInProgressRequestsForTarget(
|
||||
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
||||
target: RescanTarget,
|
||||
) {
|
||||
if (target.kind === "skill") {
|
||||
return await ctx.db
|
||||
.query("rescanRequests")
|
||||
.withIndex("by_skill_version_status", (q) =>
|
||||
q
|
||||
.eq("targetKind", "skill")
|
||||
.eq("skillVersionId", target.artifactId)
|
||||
.eq("status", ACTIVE_RESCAN_STATUS),
|
||||
)
|
||||
.order("desc")
|
||||
.take(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
|
||||
}
|
||||
|
||||
return await ctx.db
|
||||
.query("rescanRequests")
|
||||
.withIndex("by_package_release_status", (q) =>
|
||||
q
|
||||
.eq("targetKind", "plugin")
|
||||
.eq("packageReleaseId", target.artifactId)
|
||||
.eq("status", ACTIVE_RESCAN_STATUS),
|
||||
)
|
||||
.order("desc")
|
||||
.take(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
|
||||
}
|
||||
|
||||
export async function finalizeInProgressRescanRequestsForTarget(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
target: RescanTarget,
|
||||
scanState: RescanScanState,
|
||||
) {
|
||||
const requests = await listInProgressRequestsForTarget(ctx, target);
|
||||
const now = Date.now();
|
||||
for (const request of requests) {
|
||||
const status = terminalRequestStatusForScanState(scanState, request.createdAt);
|
||||
if (!status) continue;
|
||||
await ctx.db.patch(request._id, {
|
||||
status,
|
||||
updatedAt: now,
|
||||
completedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message.slice(0, 500) : "Unknown rescan dispatch error";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import type { Doc, Id } from "../../_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "../../_generated/server";
|
||||
import type { OwnedResourceActor } from "../../lib/publishers";
|
||||
|
||||
export async function getLatestSkillRescanTarget(
|
||||
ctx: Pick<QueryCtx | MutationCtx, "db">,
|
||||
skillId: Id<"skills">,
|
||||
) {
|
||||
const skill = await ctx.db.get(skillId);
|
||||
if (!skill || skill.softDeletedAt) throw new ConvexError("Skill not found");
|
||||
if (!skill.latestVersionId) throw new ConvexError("Skill has no published version");
|
||||
const version = await ctx.db.get(skill.latestVersionId);
|
||||
if (!version || version.softDeletedAt) throw new ConvexError("Latest skill version not found");
|
||||
return { skill, version };
|
||||
}
|
||||
|
||||
export async function insertSkillRescanRequest(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
actor: OwnedResourceActor,
|
||||
target: {
|
||||
skill: Doc<"skills">;
|
||||
version: Doc<"skillVersions">;
|
||||
},
|
||||
) {
|
||||
const now = Date.now();
|
||||
return await ctx.db.insert("rescanRequests", {
|
||||
targetKind: "skill",
|
||||
skillId: target.skill._id,
|
||||
skillVersionId: target.version._id,
|
||||
targetVersion: target.version.version,
|
||||
requestedByUserId: actor._id,
|
||||
ownerUserId: target.skill.ownerUserId,
|
||||
ownerPublisherId: target.skill.ownerPublisherId,
|
||||
status: "in_progress",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
+118
-33
@@ -123,6 +123,7 @@ const insertReleaseInternalHandler = (
|
||||
capabilities?: unknown;
|
||||
verification?: unknown;
|
||||
staticScan?: unknown;
|
||||
allowExistingRelease?: boolean;
|
||||
extractedPackageJson?: unknown;
|
||||
extractedPluginManifest?: unknown;
|
||||
normalizedBundleManifest?: unknown;
|
||||
@@ -577,16 +578,40 @@ function makeInsertReleaseCtx(
|
||||
}
|
||||
if (table === "packageReleases") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string) => {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
indexName: string,
|
||||
buildQuery?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
if (indexName === "by_package") {
|
||||
return {
|
||||
collect: vi.fn().mockResolvedValue(priorReleases),
|
||||
};
|
||||
}
|
||||
if (indexName === "by_package_version") {
|
||||
const filters = new Map<string, unknown>();
|
||||
const query = {
|
||||
eq(field: string, value: unknown) {
|
||||
filters.set(field, value);
|
||||
return query;
|
||||
},
|
||||
};
|
||||
buildQuery?.(query);
|
||||
return {
|
||||
unique: vi.fn().mockResolvedValue(
|
||||
priorReleases.find(
|
||||
(release) =>
|
||||
release.packageId === filters.get("packageId") &&
|
||||
release.version === filters.get("version"),
|
||||
) ?? null,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}),
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
@@ -956,36 +981,11 @@ describe("packages public queries", () => {
|
||||
expect(result.page.map((entry) => entry.name)).toEqual(["official-demo"]);
|
||||
});
|
||||
|
||||
it("keeps scanning official-only listings without a family filter", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
it("uses the official index for official-only listings without a family filter", async () => {
|
||||
const { ctx, indexNames, paginate } = makeDigestCtx({
|
||||
pages: [
|
||||
{
|
||||
page: [makeDigest("noise-1", { isOfficial: false })],
|
||||
isDone: false,
|
||||
continueCursor: "cursor:1",
|
||||
},
|
||||
{
|
||||
page: [makeDigest("noise-2", { isOfficial: false })],
|
||||
isDone: false,
|
||||
continueCursor: "cursor:2",
|
||||
},
|
||||
{
|
||||
page: [makeDigest("noise-3", { isOfficial: false })],
|
||||
isDone: false,
|
||||
continueCursor: "cursor:3",
|
||||
},
|
||||
{
|
||||
page: [makeDigest("noise-4", { isOfficial: false })],
|
||||
isDone: false,
|
||||
continueCursor: "cursor:4",
|
||||
},
|
||||
{
|
||||
page: [makeDigest("noise-5", { isOfficial: false })],
|
||||
isDone: false,
|
||||
continueCursor: "cursor:5",
|
||||
},
|
||||
{
|
||||
page: [makeDigest("official-late", { isOfficial: true, updatedAt: 10 })],
|
||||
page: [makeDigest("official-late", { isOfficial: true })],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
@@ -998,6 +998,8 @@ describe("packages public queries", () => {
|
||||
});
|
||||
|
||||
expect(result.page.map((entry) => entry.name)).toEqual(["official-late"]);
|
||||
expect(indexNames).toEqual(["by_active_official_updated"]);
|
||||
expect(paginate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("filters private packages and capability flags in public search", async () => {
|
||||
@@ -1375,7 +1377,7 @@ describe("packages public queries", () => {
|
||||
expect(ctx.db.query).not.toHaveBeenCalledWith("publisherMembers");
|
||||
});
|
||||
|
||||
it("caps public list scans below the Convex read limit budget", async () => {
|
||||
it("keeps public list pages to one paginated query per invocation", async () => {
|
||||
const { ctx, paginate } = makeDigestCtx({
|
||||
pages: Array.from({ length: 120 }, (_, index) => ({
|
||||
page: [makeDigest(`noise-${index}`, { executesCode: false })],
|
||||
@@ -1390,7 +1392,10 @@ describe("packages public queries", () => {
|
||||
});
|
||||
|
||||
expect(result.page).toEqual([]);
|
||||
expect(paginate).toHaveBeenCalledTimes(100);
|
||||
expect(result.isDone).toBe(false);
|
||||
expect(result.continueCursor).toBeTruthy();
|
||||
expect(paginate).toHaveBeenCalledTimes(1);
|
||||
expect(paginate).toHaveBeenCalledWith({ cursor: null, numItems: 100 });
|
||||
});
|
||||
|
||||
it("caps public search scans below the Convex read limit budget", async () => {
|
||||
@@ -2031,6 +2036,66 @@ describe("packages public queries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate package versions by default", async () => {
|
||||
const ctx = makeInsertReleaseCtx(makePackageDoc(), [
|
||||
makeReleaseDoc({
|
||||
_id: "packageReleases:existing",
|
||||
version: "1.0.0",
|
||||
integritySha256: "abc123",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
insertReleaseInternalHandler(ctx, {
|
||||
actorUserId: "users:owner",
|
||||
ownerUserId: "users:owner",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "retry",
|
||||
tags: ["latest"],
|
||||
summary: "demo",
|
||||
files: [],
|
||||
integritySha256: "abc123",
|
||||
}),
|
||||
).rejects.toThrow("Version 1.0.0 already exists");
|
||||
});
|
||||
|
||||
it("treats matching workflow duplicate package releases as idempotent", async () => {
|
||||
const ctx = makeInsertReleaseCtx(makePackageDoc(), [
|
||||
makeReleaseDoc({
|
||||
_id: "packageReleases:existing",
|
||||
version: "1.0.0",
|
||||
integritySha256: "abc123",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
insertReleaseInternalHandler(ctx, {
|
||||
actorUserId: "users:owner",
|
||||
ownerUserId: "users:owner",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "retry",
|
||||
tags: ["latest"],
|
||||
summary: "demo",
|
||||
files: [],
|
||||
integritySha256: "abc123",
|
||||
allowExistingRelease: true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
packageId: "packages:demo",
|
||||
releaseId: "packageReleases:existing",
|
||||
});
|
||||
|
||||
expect(ctx.insert).not.toHaveBeenCalled();
|
||||
expect(ctx.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds a latest tag when an untagged promoted release becomes the package latest", async () => {
|
||||
const ctx = makeInsertReleaseCtx(
|
||||
makePackageDoc({
|
||||
@@ -2614,6 +2679,15 @@ describe("packages public queries", () => {
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "rescanRequests") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
@@ -2879,7 +2953,18 @@ describe("package scan backfill", () => {
|
||||
if (id === "packages:demo") return pkg;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn(),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "rescanRequests") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn(async () => []),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected query table: ${table}`);
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
patch,
|
||||
replace: vi.fn(),
|
||||
|
||||
+530
-100
@@ -11,11 +11,19 @@ import { ConvexError, v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { action, internalAction, internalMutation, internalQuery, query } from "./functions";
|
||||
import {
|
||||
action,
|
||||
internalAction,
|
||||
internalMutation,
|
||||
internalQuery,
|
||||
mutation,
|
||||
query,
|
||||
} from "./functions";
|
||||
import {
|
||||
assertAdmin,
|
||||
assertModerator,
|
||||
getOptionalActiveAuthUserId,
|
||||
requireUser,
|
||||
requireUserFromAction,
|
||||
} from "./lib/access";
|
||||
import { requireGitHubAccountAge } from "./lib/githubAccount";
|
||||
@@ -33,7 +41,11 @@ import {
|
||||
} from "./lib/packageRegistry";
|
||||
import { isPackageBlockedFromPublic, resolvePackageReleaseScanStatus } from "./lib/packageSecurity";
|
||||
import { toPublicPublisher } from "./lib/public";
|
||||
import { getOwnerPublisher, getPublisherMembership } from "./lib/publishers";
|
||||
import {
|
||||
assertCanManageOwnedResource,
|
||||
getOwnerPublisher,
|
||||
getPublisherMembership,
|
||||
} from "./lib/publishers";
|
||||
import {
|
||||
findOversizedPublishFile,
|
||||
getPublishFileSizeError,
|
||||
@@ -43,14 +55,35 @@ import {
|
||||
import { tokenize } from "./lib/searchText";
|
||||
import { hashSkillFiles } from "./lib/skills";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import { getLatestPackageRescanTarget, insertPackageRescanRequest } from "./model/packages/rescans";
|
||||
import {
|
||||
assertCanRequestRescan,
|
||||
buildRescanState,
|
||||
errorMessage,
|
||||
finalizeInProgressRescanRequestsForTarget,
|
||||
} from "./model/rescans/policy";
|
||||
|
||||
const MAX_PACKAGE_SCAN_DOCUMENTS = 30_000;
|
||||
const MAX_PUBLIC_LIST_SCAN_PAGES = 200;
|
||||
const MAX_PUBLIC_LIST_PAGE_SIZE = 200;
|
||||
const MAX_SEARCH_PAGE_SIZE = 200;
|
||||
const MAX_SEARCH_SCAN_DOCUMENTS = 1_000;
|
||||
const MAX_SEARCH_SCAN_PAGES = 20;
|
||||
const MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES = 20;
|
||||
const INITIAL_PACKAGE_VT_SCAN_DELAY_MS = 30_000;
|
||||
const vtEngineStatsValidator = v.object({
|
||||
malicious: v.optional(v.number()),
|
||||
suspicious: v.optional(v.number()),
|
||||
undetected: v.optional(v.number()),
|
||||
harmless: v.optional(v.number()),
|
||||
});
|
||||
const vtAnalysisValidator = v.object({
|
||||
status: v.string(),
|
||||
verdict: v.optional(v.string()),
|
||||
analysis: v.optional(v.string()),
|
||||
source: v.optional(v.string()),
|
||||
scanner: v.optional(v.string()),
|
||||
engineStats: v.optional(vtEngineStatsValidator),
|
||||
checkedAt: v.number(),
|
||||
});
|
||||
const internalRefs = internal as unknown as {
|
||||
llmEval: {
|
||||
evaluatePackageReleaseWithLlm: unknown;
|
||||
@@ -76,6 +109,9 @@ const internalRefs = internal as unknown as {
|
||||
getByIdInternal: unknown;
|
||||
revokeInternal: unknown;
|
||||
};
|
||||
rescanRequests: {
|
||||
markStatusInternal: unknown;
|
||||
};
|
||||
skills: {
|
||||
getSkillBySlugInternal: unknown;
|
||||
};
|
||||
@@ -131,6 +167,8 @@ type PublicPackageListItem = {
|
||||
executesCode: boolean;
|
||||
verificationTier: PackageVerificationTier | null;
|
||||
};
|
||||
|
||||
type PackageBadgeKind = Doc<"packageBadges">["kind"];
|
||||
type PackageDigestLike = Pick<
|
||||
Doc<"packageSearchDigest">,
|
||||
| "packageId"
|
||||
@@ -246,6 +284,7 @@ type DashboardPackageListItem = {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
pendingReview?: true;
|
||||
rescanState: Awaited<ReturnType<typeof buildRescanState>> | null;
|
||||
latestRelease: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
@@ -372,6 +411,36 @@ function digestMatchesSearchFilters(
|
||||
return digestMatchesFilters(digest, args);
|
||||
}
|
||||
|
||||
async function upsertPackageBadge(
|
||||
ctx: MutationCtx,
|
||||
packageId: Id<"packages">,
|
||||
kind: PackageBadgeKind,
|
||||
userId: Id<"users">,
|
||||
at: number,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", kind))
|
||||
.unique();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { byUserId: userId, at });
|
||||
return;
|
||||
}
|
||||
await ctx.db.insert("packageBadges", { packageId, kind, byUserId: userId, at });
|
||||
}
|
||||
|
||||
async function removePackageBadge(
|
||||
ctx: MutationCtx,
|
||||
packageId: Id<"packages">,
|
||||
kind: PackageBadgeKind,
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package_kind", (q) => q.eq("packageId", packageId).eq("kind", kind))
|
||||
.unique();
|
||||
if (existing) await ctx.db.delete(existing._id);
|
||||
}
|
||||
|
||||
function toPublicPackageListItem(digest: PackageDigestLike): PublicPackageListItem {
|
||||
return {
|
||||
name: digest.name,
|
||||
@@ -416,6 +485,13 @@ async function toDashboardPackageListItem(
|
||||
createdAt: pkg.createdAt,
|
||||
updatedAt: pkg.updatedAt,
|
||||
pendingReview: pkg.scanStatus === "pending" ? true : undefined,
|
||||
rescanState:
|
||||
latestRelease && !latestRelease.softDeletedAt
|
||||
? await buildRescanState(ctx, {
|
||||
kind: "plugin",
|
||||
artifactId: latestRelease._id,
|
||||
})
|
||||
: null,
|
||||
latestRelease:
|
||||
latestRelease && !latestRelease.softDeletedAt
|
||||
? {
|
||||
@@ -891,6 +967,62 @@ function buildPackageCapabilityDigestQuery(
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchHighlightedPackageDigests(
|
||||
ctx: DbReaderCtx,
|
||||
args: {
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
},
|
||||
) {
|
||||
const viewerUserId = args.viewerUserId;
|
||||
const membershipCache = new Map<string, Promise<boolean>>();
|
||||
const badges = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_kind_at", (q) => q.eq("kind", "highlighted"))
|
||||
.order("desc")
|
||||
.take(MAX_PUBLIC_LIST_PAGE_SIZE);
|
||||
const digests: PackageDigestLike[] = [];
|
||||
for (const badge of badges) {
|
||||
const digest = await ctx.db
|
||||
.query("packageSearchDigest")
|
||||
.withIndex("by_package", (q) => q.eq("packageId", badge.packageId))
|
||||
.unique();
|
||||
if (!digest || digest.softDeletedAt) continue;
|
||||
if (!(await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache))) continue;
|
||||
if (!digestMatchesSearchFilters(digest, args)) continue;
|
||||
digests.push(digest);
|
||||
}
|
||||
return digests;
|
||||
}
|
||||
|
||||
async function fetchHighlightedPackagePage(
|
||||
ctx: DbReaderCtx,
|
||||
args: {
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
numItems: number;
|
||||
},
|
||||
) {
|
||||
const digests = await fetchHighlightedPackageDigests(ctx, args);
|
||||
return digests
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.isOfficial) - Number(a.isOfficial) ||
|
||||
b.updatedAt - a.updatedAt ||
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
.slice(0, args.numItems)
|
||||
.map(toPublicPackageListItem);
|
||||
}
|
||||
|
||||
async function getPackageByNormalizedName(ctx: DbReaderCtx, normalizedName: string) {
|
||||
return (await ctx.db
|
||||
.query("packages")
|
||||
@@ -936,12 +1068,12 @@ async function requireTrustedPublisherEditor(
|
||||
pkg: Doc<"packages">,
|
||||
actorUserId: Id<"users">,
|
||||
) {
|
||||
if (pkg.ownerUserId === actorUserId) return;
|
||||
if (!pkg.ownerPublisherId) throw new ConvexError("Forbidden");
|
||||
const membership = await getPublisherMembership(ctx, pkg.ownerPublisherId, actorUserId);
|
||||
if (!membership || membership.role === "publisher") {
|
||||
throw new ConvexError("Forbidden");
|
||||
}
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
actor: { _id: actorUserId },
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
allowPlatformAdmin: false,
|
||||
});
|
||||
}
|
||||
|
||||
export const getByName = query({
|
||||
@@ -967,6 +1099,41 @@ export const getByName = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const getByNameForStaff = query({
|
||||
args: { name: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
|
||||
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") return null;
|
||||
|
||||
const highlighted = await ctx.db
|
||||
.query("packageBadges")
|
||||
.withIndex("by_package_kind", (q) => q.eq("packageId", pkg._id).eq("kind", "highlighted"))
|
||||
.unique();
|
||||
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
|
||||
const owner = toPublicPublisher(
|
||||
await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
package: pkg,
|
||||
latestRelease: latestRelease && !latestRelease.softDeletedAt ? latestRelease : null,
|
||||
owner,
|
||||
highlighted: highlighted
|
||||
? {
|
||||
byUserId: highlighted.byUserId,
|
||||
at: highlighted.at,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getByNameForViewerInternal = internalQuery({
|
||||
args: {
|
||||
name: v.string(),
|
||||
@@ -1114,6 +1281,7 @@ export const listPublicPage = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
@@ -1132,6 +1300,7 @@ export const listPageForViewerInternal = internalQuery({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
viewerUserId: v.optional(v.id("users")),
|
||||
@@ -1148,6 +1317,7 @@ async function listPackagePageImpl(
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
@@ -1162,92 +1332,95 @@ async function listPackagePageImpl(
|
||||
const canViewPackage = async (digest: PackageDigestLike) =>
|
||||
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
|
||||
const targetCount = args.paginationOpts.numItems;
|
||||
|
||||
if (args.highlightedOnly) {
|
||||
const page = await fetchHighlightedPackagePage(ctx, {
|
||||
...args,
|
||||
numItems: targetCount,
|
||||
});
|
||||
return { page, isDone: true, continueCursor: "" };
|
||||
}
|
||||
|
||||
const collected: PublicPackageListItem[] = [];
|
||||
const decodedCursor = decodePublicPageCursor(args.paginationOpts.cursor);
|
||||
let cursor = decodedCursor.cursor;
|
||||
let offset = decodedCursor.offset;
|
||||
let pageSize = decodedCursor.pageSize;
|
||||
let done = decodedCursor.done;
|
||||
let loops = 0;
|
||||
let remainingScanBudget = MAX_PACKAGE_SCAN_DOCUMENTS;
|
||||
if (decodedCursor.done && decodedCursor.offset === 0) {
|
||||
return { page: collected, isDone: true, continueCursor: "" };
|
||||
}
|
||||
const pageCursor = decodedCursor.cursor;
|
||||
const offset = decodedCursor.offset;
|
||||
const effectivePageSize = Math.min(
|
||||
MAX_PUBLIC_LIST_PAGE_SIZE,
|
||||
Math.max(
|
||||
targetCount,
|
||||
decodedCursor.pageSize ?? 0,
|
||||
offset > 0 ? offset + targetCount : targetCount,
|
||||
),
|
||||
);
|
||||
const family = args.family;
|
||||
const channel = args.channel;
|
||||
const isOfficial = args.isOfficial;
|
||||
|
||||
while (
|
||||
(offset > 0 || !done) &&
|
||||
collected.length < targetCount &&
|
||||
loops < MAX_PUBLIC_LIST_SCAN_PAGES &&
|
||||
remainingScanBudget > 0
|
||||
) {
|
||||
loops += 1;
|
||||
const effectivePageSize = Math.min(
|
||||
remainingScanBudget,
|
||||
offset > 0 && pageSize
|
||||
? Math.max(pageSize, offset + 1)
|
||||
: Math.max(targetCount * 3, targetCount),
|
||||
);
|
||||
if (effectivePageSize <= 0) break;
|
||||
remainingScanBudget -= effectivePageSize;
|
||||
const pageCursor = cursor;
|
||||
const builder = args.capabilityTag
|
||||
? buildPackageCapabilityDigestQuery(ctx, {
|
||||
capabilityTag: args.capabilityTag,
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode: args.executesCode,
|
||||
})
|
||||
: buildPackageDigestQuery(ctx, {
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode: args.executesCode,
|
||||
});
|
||||
const page: {
|
||||
page: PackageDigestLike[];
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
} = await builder.order("desc").paginate({ cursor: pageCursor, numItems: effectivePageSize });
|
||||
for (let index = offset; index < page.page.length; index += 1) {
|
||||
const digest = page.page[index] as PackageDigestLike;
|
||||
if (!(await canViewPackage(digest))) continue;
|
||||
if (channel && digest.channel !== channel) continue;
|
||||
if (typeof isOfficial === "boolean" && digest.isOfficial !== isOfficial) {
|
||||
continue;
|
||||
}
|
||||
if (!digestMatchesFilters(digest, args)) continue;
|
||||
collected.push(toPublicPackageListItem(digest));
|
||||
if (collected.length >= targetCount) {
|
||||
const nextOffset = index + 1;
|
||||
if (nextOffset < page.page.length) {
|
||||
cursor = pageCursor;
|
||||
offset = nextOffset;
|
||||
pageSize = effectivePageSize;
|
||||
done = page.isDone;
|
||||
} else {
|
||||
cursor = page.continueCursor;
|
||||
offset = 0;
|
||||
pageSize = effectivePageSize;
|
||||
done = page.isDone;
|
||||
}
|
||||
return {
|
||||
page: collected,
|
||||
isDone: done && offset === 0,
|
||||
continueCursor: encodePublicPageCursor({ cursor, offset, pageSize, done }),
|
||||
};
|
||||
}
|
||||
const builder = args.capabilityTag
|
||||
? buildPackageCapabilityDigestQuery(ctx, {
|
||||
capabilityTag: args.capabilityTag,
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode: args.executesCode,
|
||||
})
|
||||
: buildPackageDigestQuery(ctx, {
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode: args.executesCode,
|
||||
});
|
||||
const page: {
|
||||
page: PackageDigestLike[];
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
} = await builder.order("desc").paginate({ cursor: pageCursor, numItems: effectivePageSize });
|
||||
for (let index = offset; index < page.page.length; index += 1) {
|
||||
const digest = page.page[index] as PackageDigestLike;
|
||||
if (!(await canViewPackage(digest))) continue;
|
||||
if (channel && digest.channel !== channel) continue;
|
||||
if (typeof isOfficial === "boolean" && digest.isOfficial !== isOfficial) {
|
||||
continue;
|
||||
}
|
||||
if (!digestMatchesFilters(digest, args)) continue;
|
||||
collected.push(toPublicPackageListItem(digest));
|
||||
if (collected.length >= targetCount) {
|
||||
const nextOffset = index + 1;
|
||||
const nextState =
|
||||
nextOffset < page.page.length
|
||||
? {
|
||||
cursor: pageCursor,
|
||||
offset: nextOffset,
|
||||
pageSize: effectivePageSize,
|
||||
done: page.isDone,
|
||||
}
|
||||
: {
|
||||
cursor: page.continueCursor,
|
||||
offset: 0,
|
||||
pageSize: effectivePageSize,
|
||||
done: page.isDone,
|
||||
};
|
||||
return {
|
||||
page: collected,
|
||||
isDone: nextState.done && nextState.offset === 0,
|
||||
continueCursor: encodePublicPageCursor(nextState),
|
||||
};
|
||||
}
|
||||
done = page.isDone;
|
||||
cursor = page.continueCursor;
|
||||
offset = 0;
|
||||
pageSize = effectivePageSize;
|
||||
}
|
||||
|
||||
return {
|
||||
page: collected,
|
||||
isDone: done,
|
||||
continueCursor: encodePublicPageCursor({ cursor, offset, pageSize, done }),
|
||||
isDone: page.isDone,
|
||||
continueCursor: encodePublicPageCursor({
|
||||
cursor: page.continueCursor,
|
||||
offset: 0,
|
||||
pageSize: effectivePageSize,
|
||||
done: page.isDone,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1262,6 +1435,7 @@ export const searchPublic = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
},
|
||||
@@ -1281,6 +1455,7 @@ export const searchForViewerInternal = internalQuery({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
viewerUserId: v.optional(v.id("users")),
|
||||
@@ -1298,6 +1473,7 @@ async function searchPackagesImpl(
|
||||
family?: PackageFamily;
|
||||
channel?: PackageChannel;
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
viewerUserId?: Id<"users">;
|
||||
@@ -1311,6 +1487,21 @@ async function searchPackagesImpl(
|
||||
const membershipCache = new Map<string, Promise<boolean>>();
|
||||
const canViewPackage = async (digest: PackageDigestLike) =>
|
||||
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
|
||||
if (args.highlightedOnly) {
|
||||
const digests = await fetchHighlightedPackageDigests(ctx, args);
|
||||
return digests
|
||||
.map((digest) => ({ score: packageSearchScore(digest, queryText), package: digest }))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
b.package.updatedAt - a.package.updatedAt,
|
||||
)
|
||||
.slice(0, targetCount)
|
||||
.map((entry) => ({ score: entry.score, package: toPublicPackageListItem(entry.package) }));
|
||||
}
|
||||
|
||||
const builder = args.capabilityTag
|
||||
? buildPackageCapabilityDigestQuery(ctx, {
|
||||
capabilityTag: args.capabilityTag,
|
||||
@@ -1967,6 +2158,9 @@ async function publishPackageImpl(
|
||||
staticScan,
|
||||
files,
|
||||
integritySha256,
|
||||
allowExistingRelease:
|
||||
auth.kind === "github-actions" ||
|
||||
(auth.kind === "user" && manualOverrideReason?.startsWith("GitHub Actions ")),
|
||||
extractedPackageJson: packageJson,
|
||||
extractedPluginManifest:
|
||||
family === "code-plugin" ? maybeParseJson(pluginManifestEntry?.text) : undefined,
|
||||
@@ -2126,6 +2320,7 @@ export const insertReleaseInternal = internalMutation({
|
||||
capabilities: v.optional(v.any()),
|
||||
verification: v.optional(v.any()),
|
||||
staticScan: v.optional(v.any()),
|
||||
allowExistingRelease: v.optional(v.boolean()),
|
||||
files: v.array(
|
||||
v.object({
|
||||
path: v.string(),
|
||||
@@ -2208,7 +2403,9 @@ export const insertReleaseInternal = internalMutation({
|
||||
.withIndex("by_runtime_id", (q) => q.eq("runtimeId", args.runtimeId))
|
||||
.unique();
|
||||
if (runtimeCollision && runtimeCollision._id !== existing?._id) {
|
||||
throw new ConvexError(`Plugin id "${nextRuntimeIdLabel}" is already claimed by another package`);
|
||||
throw new ConvexError(
|
||||
`Plugin id "${nextRuntimeIdLabel}" is already claimed by another package`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2245,7 +2442,20 @@ export const insertReleaseInternal = internalMutation({
|
||||
q.eq("packageId", existing._id).eq("version", args.version),
|
||||
)
|
||||
.unique();
|
||||
if (releaseExists) throw new ConvexError(`Version ${nextVersionLabel} already exists`);
|
||||
if (releaseExists) {
|
||||
if (
|
||||
args.allowExistingRelease &&
|
||||
!releaseExists.softDeletedAt &&
|
||||
releaseExists.integritySha256 === args.integritySha256
|
||||
) {
|
||||
return {
|
||||
ok: true as const,
|
||||
packageId: existing._id,
|
||||
releaseId: releaseExists._id,
|
||||
};
|
||||
}
|
||||
throw new ConvexError(`Version ${nextVersionLabel} already exists`);
|
||||
}
|
||||
}
|
||||
const priorReleases = existing
|
||||
? await ctx.db
|
||||
@@ -2374,15 +2584,7 @@ export const updateReleaseScanResultsInternal = internalMutation({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
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(),
|
||||
}),
|
||||
),
|
||||
vtAnalysis: v.optional(vtAnalysisValidator),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const release = await ctx.db.get(args.releaseId);
|
||||
@@ -2408,10 +2610,16 @@ export const updateReleaseScanResultsInternal = internalMutation({
|
||||
await ctx.db.patch(args.releaseId, patch);
|
||||
}
|
||||
if (args.vtAnalysis !== undefined) {
|
||||
await syncLatestPackageVerification(ctx, {
|
||||
const updatedRelease = {
|
||||
...activeRelease,
|
||||
...patch,
|
||||
} as Doc<"packageReleases">);
|
||||
} as Doc<"packageReleases">;
|
||||
await syncLatestPackageVerification(ctx, updatedRelease);
|
||||
await finalizeInProgressRescanRequestsForTarget(
|
||||
ctx,
|
||||
{ kind: "plugin", artifactId: args.releaseId },
|
||||
updatedRelease,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -2443,7 +2651,13 @@ export const updateReleaseLlmAnalysisInternal = internalMutation({
|
||||
handler: async (ctx, args) => {
|
||||
const release = await ctx.db.get(args.releaseId);
|
||||
if (!isReleaseActive(release)) return;
|
||||
const updatedRelease = { ...release, llmAnalysis: args.llmAnalysis };
|
||||
await ctx.db.patch(args.releaseId, { llmAnalysis: args.llmAnalysis });
|
||||
await finalizeInProgressRescanRequestsForTarget(
|
||||
ctx,
|
||||
{ kind: "plugin", artifactId: args.releaseId },
|
||||
updatedRelease,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2491,10 +2705,16 @@ export const updateReleaseStaticScanInternal = internalMutation({
|
||||
|
||||
await ctx.db.patch(args.releaseId, patch);
|
||||
|
||||
await syncLatestPackageVerification(ctx, {
|
||||
const updatedRelease = {
|
||||
...activeRelease,
|
||||
...patch,
|
||||
} as Doc<"packageReleases">);
|
||||
} as Doc<"packageReleases">;
|
||||
await syncLatestPackageVerification(ctx, updatedRelease);
|
||||
await finalizeInProgressRescanRequestsForTarget(
|
||||
ctx,
|
||||
{ kind: "plugin", artifactId: args.releaseId },
|
||||
updatedRelease,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2621,3 +2841,213 @@ export const backfillPackageReleaseScans = action({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
async function markPackageRescanRequest(
|
||||
ctx: { runMutation: (ref: never, args: never) => Promise<unknown> },
|
||||
requestId: Id<"rescanRequests">,
|
||||
status: "completed" | "failed",
|
||||
error?: string,
|
||||
) {
|
||||
await ctx.runMutation(
|
||||
internalRefs.rescanRequests.markStatusInternal as never,
|
||||
{
|
||||
requestId,
|
||||
status,
|
||||
error,
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
|
||||
export const getRescanState = query({
|
||||
args: {
|
||||
packageId: v.id("packages"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
const target = await getLatestPackageRescanTarget(ctx, args.packageId);
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
actor: user,
|
||||
ownerUserId: target.pkg.ownerUserId,
|
||||
ownerPublisherId: target.pkg.ownerPublisherId,
|
||||
allowPlatformAdmin: true,
|
||||
});
|
||||
return {
|
||||
targetKind: "plugin" as const,
|
||||
targetVersion: target.release.version,
|
||||
packageReleaseId: target.release._id,
|
||||
...(await buildRescanState(ctx, {
|
||||
kind: "plugin",
|
||||
artifactId: target.release._id,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getOwnerRescanStateByName = query({
|
||||
args: {
|
||||
name: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const viewerUserId = await getOptionalViewerUserId(ctx);
|
||||
if (!viewerUserId) return null;
|
||||
|
||||
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill" || !pkg.latestReleaseId) return null;
|
||||
|
||||
const release = await ctx.db.get(pkg.latestReleaseId);
|
||||
if (!release || release.softDeletedAt) return null;
|
||||
|
||||
const actor = await ctx.db.get(viewerUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) return null;
|
||||
if (actor.role !== "admin") {
|
||||
const canAccess = await viewerCanAccessPackageOwner(ctx, pkg, viewerUserId);
|
||||
if (!canAccess) return null;
|
||||
}
|
||||
|
||||
return {
|
||||
targetKind: "plugin" as const,
|
||||
targetVersion: release.version,
|
||||
packageReleaseId: release._id,
|
||||
...(await buildRescanState(ctx, {
|
||||
kind: "plugin",
|
||||
artifactId: release._id,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const requestRescan = mutation({
|
||||
args: {
|
||||
packageId: v.id("packages"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
const target = await getLatestPackageRescanTarget(ctx, args.packageId);
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
actor: user,
|
||||
ownerUserId: target.pkg.ownerUserId,
|
||||
ownerPublisherId: target.pkg.ownerPublisherId,
|
||||
allowPlatformAdmin: true,
|
||||
});
|
||||
await assertCanRequestRescan(ctx, {
|
||||
kind: "plugin",
|
||||
artifactId: target.release._id,
|
||||
});
|
||||
|
||||
const requestId = await insertPackageRescanRequest(ctx, user, target);
|
||||
await ctx.scheduler.runAfter(0, internal.packages.dispatchPackageRescanInternal, {
|
||||
requestId,
|
||||
releaseId: target.release._id,
|
||||
});
|
||||
|
||||
return {
|
||||
requestId,
|
||||
...(await buildRescanState(ctx, {
|
||||
kind: "plugin",
|
||||
artifactId: target.release._id,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const setBatch = mutation({
|
||||
args: { packageId: v.id("packages"), batch: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
const pkg = await ctx.db.get(args.packageId);
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
||||
throw new ConvexError("Plugin not found");
|
||||
}
|
||||
const nextBatch = args.batch?.trim() || undefined;
|
||||
const nextHighlighted = nextBatch === "highlighted";
|
||||
const now = Date.now();
|
||||
|
||||
if (nextHighlighted) {
|
||||
await upsertPackageBadge(ctx, pkg._id, "highlighted", user._id, now);
|
||||
} else {
|
||||
await removePackageBadge(ctx, pkg._id, "highlighted");
|
||||
}
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: user._id,
|
||||
action: "package.badge.highlighted",
|
||||
targetType: "package",
|
||||
targetId: pkg._id,
|
||||
metadata: { highlighted: nextHighlighted },
|
||||
createdAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const requestRescanForApiTokenInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
name: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
|
||||
const pkg = await getPackageByNormalizedName(ctx, normalizePackageName(args.name));
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
||||
throw new ConvexError("Plugin not found");
|
||||
}
|
||||
|
||||
const target = await getLatestPackageRescanTarget(ctx, pkg._id);
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
actor,
|
||||
ownerUserId: target.pkg.ownerUserId,
|
||||
ownerPublisherId: target.pkg.ownerPublisherId,
|
||||
allowPlatformAdmin: true,
|
||||
});
|
||||
await assertCanRequestRescan(ctx, {
|
||||
kind: "plugin",
|
||||
artifactId: target.release._id,
|
||||
});
|
||||
|
||||
const requestId = await insertPackageRescanRequest(ctx, actor, target);
|
||||
await ctx.scheduler.runAfter(0, internal.packages.dispatchPackageRescanInternal, {
|
||||
requestId,
|
||||
releaseId: target.release._id,
|
||||
});
|
||||
|
||||
const state = await buildRescanState(ctx, {
|
||||
kind: "plugin",
|
||||
artifactId: target.release._id,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
targetKind: "package" as const,
|
||||
name: target.pkg.normalizedName,
|
||||
version: target.release.version,
|
||||
status: state.inProgressRequest?.status ?? state.latestRequest?.status ?? "in_progress",
|
||||
remainingRequests: state.remainingRequests,
|
||||
maxRequests: state.maxRequests,
|
||||
pendingRequestId: requestId,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const dispatchPackageRescanInternal = internalAction({
|
||||
args: {
|
||||
requestId: v.id("rescanRequests"),
|
||||
releaseId: v.id("packageReleases"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
await runActionRef(ctx, internalRefs.packages.scanPackageReleaseStaticallyInternal, {
|
||||
releaseId: args.releaseId,
|
||||
});
|
||||
await runActionRef(ctx, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: args.releaseId,
|
||||
});
|
||||
await runActionRef(ctx, internalRefs.llmEval.evaluatePackageReleaseWithLlm, {
|
||||
releaseId: args.releaseId,
|
||||
});
|
||||
} catch (error) {
|
||||
await markPackageRescanRequest(ctx, args.requestId, "failed", errorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import { internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, requireUser } from "./lib/access";
|
||||
import { assertAdmin, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
||||
import { toPublicPublisher } from "./lib/public";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
@@ -404,7 +403,7 @@ export const resolvePublishTargetForUserInternal = internalMutation({
|
||||
export const listMine = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
const userId = await getOptionalActiveAuthUserId(ctx);
|
||||
if (!userId) return [];
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return [];
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { v } from "convex/values";
|
||||
import { internalMutation } from "./functions";
|
||||
|
||||
export const markStatusInternal = internalMutation({
|
||||
args: {
|
||||
requestId: v.id("rescanRequests"),
|
||||
status: v.union(v.literal("completed"), v.literal("failed")),
|
||||
error: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(args.requestId, {
|
||||
status: args.status,
|
||||
error: args.error,
|
||||
updatedAt: now,
|
||||
completedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
dispatchPackageRescanInternal,
|
||||
requestRescan as requestPackageRescan,
|
||||
} from "./packages";
|
||||
import {
|
||||
dispatchSkillRescanInternal,
|
||||
getRescanState as getSkillRescanState,
|
||||
requestRescan as requestSkillRescan,
|
||||
} from "./skills";
|
||||
import { requireUser } from "./lib/access";
|
||||
import {
|
||||
finalizeInProgressRescanRequestsForTarget,
|
||||
MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
|
||||
} from "./model/rescans/policy";
|
||||
|
||||
vi.mock("./lib/access", () => ({
|
||||
requireUser: vi.fn(),
|
||||
}));
|
||||
|
||||
type WrappedHandler<TArgs, TResult = unknown> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const requestSkillRescanHandler = (
|
||||
requestSkillRescan as unknown as WrappedHandler<{ skillId: string }>
|
||||
)._handler;
|
||||
const requestPackageRescanHandler = (
|
||||
requestPackageRescan as unknown as WrappedHandler<{ packageId: string }>
|
||||
)._handler;
|
||||
const getSkillRescanStateHandler = (
|
||||
getSkillRescanState as unknown as WrappedHandler<{ skillId: string }>
|
||||
)._handler;
|
||||
const dispatchSkillRescanHandler = (
|
||||
dispatchSkillRescanInternal as unknown as WrappedHandler<{
|
||||
requestId: string;
|
||||
skillId: string;
|
||||
versionId: string;
|
||||
}>
|
||||
)._handler;
|
||||
const dispatchPackageRescanHandler = (
|
||||
dispatchPackageRescanInternal as unknown as WrappedHandler<{
|
||||
requestId: string;
|
||||
releaseId: string;
|
||||
}>
|
||||
)._handler;
|
||||
|
||||
type RescanRequest = {
|
||||
_id: string;
|
||||
targetKind: "skill" | "plugin";
|
||||
skillId?: string;
|
||||
skillVersionId?: string;
|
||||
packageId?: string;
|
||||
packageReleaseId?: string;
|
||||
targetVersion: string;
|
||||
requestedByUserId: string;
|
||||
ownerUserId: string;
|
||||
ownerPublisherId?: string;
|
||||
status: "in_progress" | "completed" | "failed";
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
completedAt?: number;
|
||||
};
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return chainEq(constraints);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matches(doc: Record<string, unknown>, constraints: Record<string, unknown>) {
|
||||
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
|
||||
}
|
||||
|
||||
function createDb(options?: {
|
||||
requests?: RescanRequest[];
|
||||
userRole?: "admin" | "moderator" | "user";
|
||||
ownerPublisherId?: string;
|
||||
membershipRole?: "owner" | "admin" | "publisher";
|
||||
skillLatestVersionId?: string;
|
||||
packageLatestReleaseId?: string;
|
||||
skillSoftDeletedAt?: number;
|
||||
skillVersionSoftDeletedAt?: number;
|
||||
packageSoftDeletedAt?: number;
|
||||
packageReleaseSoftDeletedAt?: number;
|
||||
}) {
|
||||
const requests = [...(options?.requests ?? [])];
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
slug: "flagged-skill",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: options?.ownerPublisherId,
|
||||
latestVersionId: options?.skillLatestVersionId ?? "skillVersions:latest",
|
||||
softDeletedAt: options?.skillSoftDeletedAt,
|
||||
};
|
||||
const version = {
|
||||
_id: "skillVersions:latest",
|
||||
skillId: "skills:1",
|
||||
version: "1.2.3",
|
||||
softDeletedAt: options?.skillVersionSoftDeletedAt,
|
||||
};
|
||||
const pkg = {
|
||||
_id: "packages:1",
|
||||
name: "flagged-plugin",
|
||||
family: "code-plugin",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: options?.ownerPublisherId,
|
||||
latestReleaseId: options?.packageLatestReleaseId ?? "packageReleases:latest",
|
||||
softDeletedAt: options?.packageSoftDeletedAt,
|
||||
};
|
||||
const release = {
|
||||
_id: "packageReleases:latest",
|
||||
packageId: "packages:1",
|
||||
version: "2.0.0",
|
||||
softDeletedAt: options?.packageReleaseSoftDeletedAt,
|
||||
};
|
||||
const actor = {
|
||||
_id: "users:actor",
|
||||
role: options?.userRole ?? "user",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "skills:1") return skill;
|
||||
if (id === "skillVersions:latest") return version;
|
||||
if (id === "packages:1") return pkg;
|
||||
if (id === "packageReleases:latest") return release;
|
||||
if (id === "users:actor") return actor;
|
||||
return null;
|
||||
}),
|
||||
insert: vi.fn(async (table: string, doc: Omit<RescanRequest, "_id">) => {
|
||||
if (table !== "rescanRequests") throw new Error(`unexpected insert ${table}`);
|
||||
const inserted = {
|
||||
_id: `rescanRequests:${requests.length + 1}`,
|
||||
...doc,
|
||||
} as RescanRequest;
|
||||
requests.push(inserted);
|
||||
return inserted._id;
|
||||
}),
|
||||
patch: vi.fn(async (id: string, patch: Partial<RescanRequest>) => {
|
||||
const request = requests.find((candidate) => candidate._id === id);
|
||||
if (request) Object.assign(request, patch);
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
if (name !== "by_publisher_user") throw new Error(`unexpected index ${name}`);
|
||||
const constraints: Record<string, unknown> = {};
|
||||
build(chainEq(constraints));
|
||||
return {
|
||||
unique: async () =>
|
||||
options?.membershipRole
|
||||
? {
|
||||
publisherId: constraints.publisherId,
|
||||
userId: constraints.userId,
|
||||
role: options.membershipRole,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table !== "rescanRequests") throw new Error(`unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (_name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
const constraints: Record<string, unknown> = {};
|
||||
build(chainEq(constraints));
|
||||
const matched = requests
|
||||
.filter((request) => matches(request as unknown as Record<string, unknown>, constraints))
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return {
|
||||
order: () => ({
|
||||
take: async (limit: number) => matched.slice(0, limit),
|
||||
first: async () => matched[0] ?? null,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}),
|
||||
normalizeId: vi.fn((table: string, id: string) => (id.startsWith(`${table}:`) ? id : null)),
|
||||
};
|
||||
|
||||
return { db, requests };
|
||||
}
|
||||
|
||||
function createRequest(overrides?: Partial<RescanRequest>): RescanRequest {
|
||||
return {
|
||||
_id: "rescanRequests:existing",
|
||||
targetKind: "skill",
|
||||
skillId: "skills:1",
|
||||
skillVersionId: "skillVersions:latest",
|
||||
targetVersion: "1.2.3",
|
||||
requestedByUserId: "users:owner",
|
||||
ownerUserId: "users:owner",
|
||||
status: "completed",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(requireUser).mockReset();
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:owner",
|
||||
user: { _id: "users:owner", role: "user" },
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("rescan requests", () => {
|
||||
it("returns owner-visible state for the latest skill version", async () => {
|
||||
const { db } = createDb({
|
||||
requests: [
|
||||
createRequest({ _id: "rescanRequests:1", status: "completed", createdAt: 1 }),
|
||||
createRequest({ _id: "rescanRequests:2", status: "failed", createdAt: 2 }),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getSkillRescanStateHandler({ db } as never, {
|
||||
skillId: "skills:1",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
targetKind: "skill",
|
||||
targetVersion: "1.2.3",
|
||||
maxRequests: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE,
|
||||
requestCount: 2,
|
||||
remainingRequests: 1,
|
||||
canRequest: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a skill rescan request and schedules dispatch", async () => {
|
||||
const { db, requests } = createDb();
|
||||
const scheduler = { runAfter: vi.fn(async () => undefined) };
|
||||
|
||||
const result = await requestSkillRescanHandler({ db, scheduler } as never, {
|
||||
skillId: "skills:1",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
requestId: "rescanRequests:1",
|
||||
remainingRequests: 2,
|
||||
});
|
||||
expect(requests[0]).toMatchObject({
|
||||
targetKind: "skill",
|
||||
skillId: "skills:1",
|
||||
skillVersionId: "skillVersions:latest",
|
||||
status: "in_progress",
|
||||
targetVersion: "1.2.3",
|
||||
});
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
requestId: "rescanRequests:1",
|
||||
skillId: "skills:1",
|
||||
versionId: "skillVersions:latest",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a plugin rescan request against the latest release", async () => {
|
||||
const { db, requests } = createDb();
|
||||
const scheduler = { runAfter: vi.fn(async () => undefined) };
|
||||
|
||||
await requestPackageRescanHandler({ db, scheduler } as never, {
|
||||
packageId: "packages:1",
|
||||
});
|
||||
|
||||
expect(requests[0]).toMatchObject({
|
||||
targetKind: "plugin",
|
||||
packageId: "packages:1",
|
||||
packageReleaseId: "packageReleases:latest",
|
||||
status: "in_progress",
|
||||
targetVersion: "2.0.0",
|
||||
});
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
requestId: "rescanRequests:1",
|
||||
releaseId: "packageReleases:latest",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate in-progress requests for the same release", async () => {
|
||||
const { db } = createDb({
|
||||
requests: [createRequest({ status: "in_progress" })],
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
|
||||
skillId: "skills:1",
|
||||
}),
|
||||
).rejects.toThrow("already in progress");
|
||||
});
|
||||
|
||||
it("enforces the per-release rescan cap", async () => {
|
||||
const { db } = createDb({
|
||||
requests: Array.from({ length: MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE }, (_, index) =>
|
||||
createRequest({
|
||||
_id: `rescanRequests:${index}`,
|
||||
status: "completed",
|
||||
createdAt: index,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
|
||||
skillId: "skills:1",
|
||||
}),
|
||||
).rejects.toThrow("Rescan request limit reached");
|
||||
});
|
||||
|
||||
it("rejects non-owners", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:actor",
|
||||
user: { _id: "users:actor", role: "user" },
|
||||
} as never);
|
||||
const { db } = createDb();
|
||||
|
||||
await expect(
|
||||
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
|
||||
skillId: "skills:1",
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("lets org admins request owner rescans", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:actor",
|
||||
user: { _id: "users:actor", role: "user" },
|
||||
} as never);
|
||||
const { db } = createDb({
|
||||
ownerPublisherId: "publishers:org",
|
||||
membershipRole: "admin",
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
|
||||
skillId: "skills:1",
|
||||
}),
|
||||
).resolves.toMatchObject({ requestId: "rescanRequests:1" });
|
||||
});
|
||||
|
||||
it("rejects publisher-only org members", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:actor",
|
||||
user: { _id: "users:actor", role: "user" },
|
||||
} as never);
|
||||
const { db } = createDb({
|
||||
ownerPublisherId: "publishers:org",
|
||||
membershipRole: "publisher",
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
|
||||
skillId: "skills:1",
|
||||
}),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("lets admins request owner rescans", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const { db } = createDb();
|
||||
|
||||
await expect(
|
||||
requestSkillRescanHandler({ db, scheduler: { runAfter: vi.fn() } } as never, {
|
||||
skillId: "skills:1",
|
||||
}),
|
||||
).resolves.toMatchObject({ requestId: "rescanRequests:1" });
|
||||
});
|
||||
|
||||
it("rejects missing or soft-deleted skill targets", async () => {
|
||||
const softDeletedSkill = createDb({ skillSoftDeletedAt: 123 });
|
||||
await expect(
|
||||
requestSkillRescanHandler(
|
||||
{ db: softDeletedSkill.db, scheduler: { runAfter: vi.fn() } } as never,
|
||||
{ skillId: "skills:1" },
|
||||
),
|
||||
).rejects.toThrow("Skill not found");
|
||||
|
||||
const softDeletedVersion = createDb({ skillVersionSoftDeletedAt: 123 });
|
||||
await expect(
|
||||
requestSkillRescanHandler(
|
||||
{ db: softDeletedVersion.db, scheduler: { runAfter: vi.fn() } } as never,
|
||||
{ skillId: "skills:1" },
|
||||
),
|
||||
).rejects.toThrow("Latest skill version not found");
|
||||
});
|
||||
|
||||
it("rejects missing or soft-deleted plugin targets", async () => {
|
||||
const softDeletedPackage = createDb({ packageSoftDeletedAt: 123 });
|
||||
await expect(
|
||||
requestPackageRescanHandler(
|
||||
{ db: softDeletedPackage.db, scheduler: { runAfter: vi.fn() } } as never,
|
||||
{ packageId: "packages:1" },
|
||||
),
|
||||
).rejects.toThrow("Plugin not found");
|
||||
|
||||
const softDeletedRelease = createDb({ packageReleaseSoftDeletedAt: 123 });
|
||||
await expect(
|
||||
requestPackageRescanHandler(
|
||||
{ db: softDeletedRelease.db, scheduler: { runAfter: vi.fn() } } as never,
|
||||
{ packageId: "packages:1" },
|
||||
),
|
||||
).rejects.toThrow("Latest plugin release not found");
|
||||
});
|
||||
|
||||
it("dispatches skill rescans through each existing scanner without completing early", async () => {
|
||||
const runAction = vi.fn(async () => undefined);
|
||||
const runMutation = vi.fn(async () => undefined);
|
||||
|
||||
await dispatchSkillRescanHandler({ runAction, runMutation } as never, {
|
||||
requestId: "rescanRequests:1",
|
||||
skillId: "skills:1",
|
||||
versionId: "skillVersions:latest",
|
||||
});
|
||||
|
||||
expect(runAction).toHaveBeenCalledTimes(3);
|
||||
expect(runAction).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ skillId: "skills:1", versionId: "skillVersions:latest" }),
|
||||
);
|
||||
expect(runAction).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ versionId: "skillVersions:latest" }),
|
||||
);
|
||||
expect(runAction).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ versionId: "skillVersions:latest" }),
|
||||
);
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches plugin rescans through each existing scanner without completing early", async () => {
|
||||
const runAction = vi.fn(async () => undefined);
|
||||
const runMutation = vi.fn(async () => undefined);
|
||||
|
||||
await dispatchPackageRescanHandler({ runAction, runMutation } as never, {
|
||||
requestId: "rescanRequests:1",
|
||||
releaseId: "packageReleases:latest",
|
||||
});
|
||||
|
||||
expect(runAction).toHaveBeenCalledTimes(3);
|
||||
expect(runAction).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ releaseId: "packageReleases:latest" }),
|
||||
);
|
||||
expect(runAction).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ releaseId: "packageReleases:latest" }),
|
||||
);
|
||||
expect(runAction).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ releaseId: "packageReleases:latest" }),
|
||||
);
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("completes in-progress rescans when all scanner results are fresh", async () => {
|
||||
const { db, requests } = createDb({
|
||||
requests: [createRequest({ status: "in_progress", createdAt: 100 })],
|
||||
});
|
||||
|
||||
await finalizeInProgressRescanRequestsForTarget(
|
||||
{ db } as never,
|
||||
{ kind: "skill", artifactId: "skillVersions:latest" as never },
|
||||
{
|
||||
staticScan: { status: "clean", checkedAt: 101 },
|
||||
vtAnalysis: { status: "clean", checkedAt: 102 },
|
||||
llmAnalysis: { status: "benign", checkedAt: 103 },
|
||||
},
|
||||
);
|
||||
|
||||
expect(requests[0]).toMatchObject({ status: "completed" });
|
||||
expect(requests[0].completedAt).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
it("keeps in-progress rescans open while VT only has old results", async () => {
|
||||
const { db, requests } = createDb({
|
||||
requests: [createRequest({ status: "in_progress", createdAt: 100 })],
|
||||
});
|
||||
|
||||
await finalizeInProgressRescanRequestsForTarget(
|
||||
{ db } as never,
|
||||
{ kind: "skill", artifactId: "skillVersions:latest" as never },
|
||||
{
|
||||
staticScan: { status: "clean", checkedAt: 101 },
|
||||
vtAnalysis: { status: "clean", checkedAt: 99 },
|
||||
llmAnalysis: { status: "benign", checkedAt: 103 },
|
||||
},
|
||||
);
|
||||
|
||||
expect(requests[0]).toMatchObject({ status: "in_progress" });
|
||||
});
|
||||
});
|
||||
+53
-18
@@ -12,6 +12,23 @@ const manualModerationOverride = v.object({
|
||||
updatedAt: v.number(),
|
||||
});
|
||||
|
||||
const vtEngineStatsValidator = v.object({
|
||||
malicious: v.optional(v.number()),
|
||||
suspicious: v.optional(v.number()),
|
||||
undetected: v.optional(v.number()),
|
||||
harmless: v.optional(v.number()),
|
||||
});
|
||||
|
||||
const vtAnalysisValidator = v.object({
|
||||
status: v.string(),
|
||||
verdict: v.optional(v.string()),
|
||||
analysis: v.optional(v.string()),
|
||||
source: v.optional(v.string()),
|
||||
scanner: v.optional(v.string()),
|
||||
engineStats: v.optional(vtEngineStatsValidator),
|
||||
checkedAt: v.number(),
|
||||
});
|
||||
|
||||
const users = defineTable({
|
||||
name: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
@@ -429,15 +446,7 @@ const skillVersions = defineTable({
|
||||
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(),
|
||||
}),
|
||||
),
|
||||
vtAnalysis: v.optional(vtAnalysisValidator),
|
||||
llmAnalysis: v.optional(
|
||||
v.object({
|
||||
status: v.string(),
|
||||
@@ -538,6 +547,16 @@ const skillBadges = defineTable({
|
||||
.index("by_skill_kind", ["skillId", "kind"])
|
||||
.index("by_kind_at", ["kind", "at"]);
|
||||
|
||||
const packageBadges = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
kind: v.union(v.literal("highlighted")),
|
||||
byUserId: v.id("users"),
|
||||
at: v.number(),
|
||||
})
|
||||
.index("by_package", ["packageId"])
|
||||
.index("by_package_kind", ["packageId", "kind"])
|
||||
.index("by_kind_at", ["kind", "at"]);
|
||||
|
||||
const soulVersionFingerprints = defineTable({
|
||||
soulId: v.id("souls"),
|
||||
versionId: v.id("soulVersions"),
|
||||
@@ -704,15 +723,7 @@ const packageReleases = defineTable({
|
||||
capabilities: packageCapabilitiesValidator,
|
||||
verification: packageVerificationValidator,
|
||||
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(),
|
||||
}),
|
||||
),
|
||||
vtAnalysis: v.optional(vtAnalysisValidator),
|
||||
llmAnalysis: v.optional(
|
||||
v.object({
|
||||
status: v.string(),
|
||||
@@ -1199,6 +1210,28 @@ const vtScanLogs = defineTable({
|
||||
createdAt: v.number(),
|
||||
}).index("by_type_date", ["type", "createdAt"]);
|
||||
|
||||
const rescanRequests = defineTable({
|
||||
targetKind: v.union(v.literal("skill"), v.literal("plugin")),
|
||||
skillId: v.optional(v.id("skills")),
|
||||
skillVersionId: v.optional(v.id("skillVersions")),
|
||||
packageId: v.optional(v.id("packages")),
|
||||
packageReleaseId: v.optional(v.id("packageReleases")),
|
||||
targetVersion: v.string(),
|
||||
requestedByUserId: v.id("users"),
|
||||
ownerUserId: v.id("users"),
|
||||
ownerPublisherId: v.optional(v.id("publishers")),
|
||||
status: v.union(v.literal("in_progress"), v.literal("completed"), v.literal("failed")),
|
||||
error: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
completedAt: v.optional(v.number()),
|
||||
})
|
||||
.index("by_skill_version", ["targetKind", "skillVersionId", "createdAt"])
|
||||
.index("by_skill_version_status", ["targetKind", "skillVersionId", "status", "createdAt"])
|
||||
.index("by_package_release", ["targetKind", "packageReleaseId", "createdAt"])
|
||||
.index("by_package_release_status", ["targetKind", "packageReleaseId", "status", "createdAt"])
|
||||
.index("by_requester", ["requestedByUserId", "createdAt"]);
|
||||
|
||||
const apiTokens = defineTable({
|
||||
userId: v.id("users"),
|
||||
label: v.string(),
|
||||
@@ -1334,6 +1367,7 @@ export default defineSchema({
|
||||
packageReleases,
|
||||
packageTrustedPublishers,
|
||||
packagePublishTokens,
|
||||
packageBadges,
|
||||
packageSearchDigest,
|
||||
packageCapabilitySearchDigest,
|
||||
souls,
|
||||
@@ -1360,6 +1394,7 @@ export default defineSchema({
|
||||
soulStars,
|
||||
auditLogs,
|
||||
vtScanLogs,
|
||||
rescanRequests,
|
||||
apiTokens,
|
||||
rateLimits,
|
||||
downloadDedupes,
|
||||
|
||||
+157
-25
@@ -11,44 +11,62 @@ type WrappedHandler<TArgs, TResult = unknown> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
function makeSkill(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
_id: "skills:skill",
|
||||
_creationTime: 1,
|
||||
slug: "demo-skill",
|
||||
displayName: "Demo Skill",
|
||||
summary: "Demo skill",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
canonicalSkillId: undefined,
|
||||
forkOf: undefined,
|
||||
latestVersionId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
tags: {},
|
||||
capabilityTags: [],
|
||||
badges: undefined,
|
||||
statsDownloads: 7,
|
||||
statsStars: 3,
|
||||
statsInstallsCurrent: 0,
|
||||
statsInstallsAllTime: 0,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
stars: 0,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: [],
|
||||
moderationReason: undefined,
|
||||
moderationVerdict: "clean",
|
||||
isSuspicious: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const listHandler = (
|
||||
list as unknown as WrappedHandler<
|
||||
{ ownerPublisherId?: string; ownerUserId?: string; limit?: number },
|
||||
Array<{ slug: string }>
|
||||
Array<{ slug: string; stats: { downloads: number; stars: number } }>
|
||||
>
|
||||
)._handler;
|
||||
|
||||
describe("skills.list", () => {
|
||||
it("includes legacy personal skills when listing a personal publisher", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const legacySkill = {
|
||||
const legacySkill = makeSkill({
|
||||
_id: "skills:legacy",
|
||||
_creationTime: 1,
|
||||
slug: "legacy-skill",
|
||||
displayName: "Legacy Skill",
|
||||
summary: "Pre-backfill skill",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
canonicalSkillId: undefined,
|
||||
forkOf: undefined,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
badges: undefined,
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
stars: 0,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: [],
|
||||
moderationReason: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
@@ -121,4 +139,118 @@ describe("skills.list", () => {
|
||||
|
||||
expect(result).toEqual([expect.objectContaining({ slug: "legacy-skill" })]);
|
||||
});
|
||||
|
||||
it("includes non-public flagged skills for the owning user dashboard", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const blockedSkill = makeSkill({
|
||||
slug: "blocked-skill",
|
||||
moderationStatus: "hidden",
|
||||
moderationFlags: ["blocked.malware"],
|
||||
moderationReason: "scanner.vt.malicious",
|
||||
moderationVerdict: "malicious",
|
||||
isSuspicious: true,
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:owner") {
|
||||
return {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "owner",
|
||||
displayName: "Owner",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "skills") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([blockedSkill]),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "skillBadges") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await listHandler(
|
||||
ctx as never,
|
||||
{ ownerUserId: "users:owner", limit: 10 } as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
slug: "blocked-skill",
|
||||
moderationStatus: "hidden",
|
||||
moderationVerdict: "malicious",
|
||||
stats: expect.objectContaining({ downloads: 7, stars: 3 }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not expose non-public flagged skills to non-owner list callers", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue(null);
|
||||
const blockedSkill = makeSkill({
|
||||
slug: "blocked-skill",
|
||||
moderationStatus: "hidden",
|
||||
moderationFlags: ["blocked.malware"],
|
||||
moderationReason: "scanner.vt.malicious",
|
||||
moderationVerdict: "malicious",
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:owner") {
|
||||
return {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "owner",
|
||||
displayName: "Owner",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "skills") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([blockedSkill]),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "skillBadges") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await listHandler(
|
||||
ctx as never,
|
||||
{ ownerUserId: "users:owner", limit: 10 } as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,16 @@ function makeCtx(params: { skill: Record<string, unknown>; version?: Record<stri
|
||||
};
|
||||
}
|
||||
|
||||
if (table === "rescanRequests") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn(async () => []),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected query table: ${table}`);
|
||||
});
|
||||
const get = vi.fn(async (id: string) => {
|
||||
@@ -426,4 +436,66 @@ describe("skills manual overrides", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears legacy suspicious state when LLM corroborates clean VT Code Insight-only suspicious", async () => {
|
||||
const now = 1_700_000_400_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:9",
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "hidden",
|
||||
moderationReason: "scanner.vt.suspicious",
|
||||
moderationVerdict: "suspicious",
|
||||
moderationFlags: ["flagged.suspicious"],
|
||||
};
|
||||
const version = {
|
||||
_id: "skillVersions:9",
|
||||
skillId: "skills:1",
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "",
|
||||
engineVersion: "v2.1.1",
|
||||
checkedAt: now - 200,
|
||||
},
|
||||
vtAnalysis: {
|
||||
status: "suspicious",
|
||||
scanner: "code_insight",
|
||||
engineStats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 12,
|
||||
undetected: 54,
|
||||
},
|
||||
checkedAt: now - 100,
|
||||
},
|
||||
llmAnalysis: undefined,
|
||||
};
|
||||
|
||||
const { ctx, patch } = makeCtx({ skill, version });
|
||||
|
||||
await updateVersionLlmAnalysisInternalHandler(ctx, {
|
||||
versionId: "skillVersions:9",
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
checkedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
moderationStatus: "active",
|
||||
moderationReason: "scanner.aggregate.clean",
|
||||
moderationFlags: undefined,
|
||||
moderationVerdict: "clean",
|
||||
moderationReasonCodes: undefined,
|
||||
isSuspicious: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getActiveSkillBatchForStaticScanBackfillInternal,
|
||||
getPendingScanSkillsInternal,
|
||||
} from "./skills";
|
||||
import { MODERATION_ENGINE_VERSION } from "./lib/moderationReasonCodes";
|
||||
|
||||
type PendingScanResult = Array<{
|
||||
skillId: string;
|
||||
@@ -279,7 +280,7 @@ describe("skills.getActiveSkillBatchForStaticScanBackfillInternal", () => {
|
||||
"skillVersions:current-static",
|
||||
{
|
||||
_id: "skillVersions:current-static",
|
||||
staticScan: { engineVersion: "v2.4.1" },
|
||||
staticScan: { engineVersion: MODERATION_ENGINE_VERSION },
|
||||
},
|
||||
],
|
||||
[
|
||||
|
||||
@@ -1266,6 +1266,92 @@ describe("skills anti-spam guards", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("vt suspicious escalation clears legacy quarantine for uncorroborated Code Insight", async () => {
|
||||
const patch = vi.fn(async () => {});
|
||||
const version = {
|
||||
_id: "skillVersions:1",
|
||||
skillId: "skills:1",
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "",
|
||||
engineVersion: "v2.1.1",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
vtAnalysis: {
|
||||
status: "suspicious",
|
||||
scanner: "code_insight",
|
||||
engineStats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 12,
|
||||
undetected: 54,
|
||||
},
|
||||
},
|
||||
llmAnalysis: { status: "clean" },
|
||||
};
|
||||
const skill = {
|
||||
_id: "skills:1",
|
||||
slug: "doc-only",
|
||||
ownerUserId: "users:owner",
|
||||
latestVersionId: "skillVersions:1",
|
||||
moderationStatus: "hidden",
|
||||
moderationFlags: ["flagged.suspicious"],
|
||||
moderationReason: "scanner.vt.suspicious",
|
||||
};
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
role: "user",
|
||||
deletedAt: undefined,
|
||||
};
|
||||
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "skills:1") return skill;
|
||||
if (id === "users:owner") return owner;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
const globalStatsQuery = buildGlobalStatsQuery(table);
|
||||
if (globalStatsQuery) return globalStatsQuery;
|
||||
const digestQuery = buildDigestQuery(table);
|
||||
if (digestQuery) return digestQuery;
|
||||
if (table === "skillVersions") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => version,
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
insert: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
};
|
||||
|
||||
await escalateByVtHandler(
|
||||
{ db, scheduler: { runAfter: vi.fn() } } as never,
|
||||
{
|
||||
sha256hash: "h".repeat(64),
|
||||
status: "suspicious",
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
moderationReason: "scanner.vt.clean",
|
||||
moderationVerdict: "clean",
|
||||
moderationReasonCodes: undefined,
|
||||
isSuspicious: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores vt escalation for non-latest versions", async () => {
|
||||
const patch = vi.fn(async () => {});
|
||||
const version = {
|
||||
|
||||
+398
-133
@@ -15,7 +15,14 @@ import {
|
||||
mutation,
|
||||
query,
|
||||
} from "./functions";
|
||||
import { assertAdmin, assertModerator, requireUser, requireUserFromAction } from "./lib/access";
|
||||
import {
|
||||
assertAdmin,
|
||||
assertModerator,
|
||||
getOptionalActiveAuthUserId,
|
||||
getOptionalActiveAuthUserIdFromAction,
|
||||
requireUser,
|
||||
requireUserFromAction,
|
||||
} from "./lib/access";
|
||||
import { getSkillBadgeMap, getSkillBadgeMaps, isSkillHighlighted } from "./lib/badges";
|
||||
import { scheduleNextBatchIfNeeded } from "./lib/batching";
|
||||
import { generateChangelogPreview as buildChangelogPreview } from "./lib/changelog";
|
||||
@@ -56,6 +63,7 @@ import {
|
||||
toPublicUser,
|
||||
} from "./lib/public";
|
||||
import {
|
||||
assertCanManageOwnedResource,
|
||||
ensurePersonalPublisherForUser,
|
||||
getOwnerPublisher,
|
||||
requirePublisherRole,
|
||||
@@ -80,7 +88,6 @@ import {
|
||||
publishVersionForUser,
|
||||
queueHighlightedWebhook,
|
||||
} from "./lib/skillPublish";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
|
||||
import { computeIsSuspicious, isSkillSuspicious } from "./lib/skillSafety";
|
||||
import {
|
||||
@@ -89,7 +96,16 @@ import {
|
||||
extractDigestFields,
|
||||
upsertSkillSearchDigest,
|
||||
} from "./lib/skillSearchDigest";
|
||||
import { readCanonicalStat } from "./lib/skillStats";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
|
||||
import {
|
||||
assertCanRequestRescan,
|
||||
buildRescanState,
|
||||
errorMessage,
|
||||
finalizeInProgressRescanRequestsForTarget,
|
||||
} from "./model/rescans/policy";
|
||||
import { getLatestSkillRescanTarget, insertSkillRescanRequest } from "./model/skills/rescans";
|
||||
import schema from "./schema";
|
||||
|
||||
export { publishVersionForUser } from "./lib/skillPublish";
|
||||
@@ -128,8 +144,26 @@ const USER_MODERATION_REASON = "user.moderation";
|
||||
const SKILL_CATALOG_CURSOR_PREFIX = "skillcat:";
|
||||
const SKILL_CAPABILITY_TAG_SET = new Set<string>(SKILL_CAPABILITY_TAGS);
|
||||
|
||||
const vtEngineStatsValidator = v.object({
|
||||
malicious: v.optional(v.number()),
|
||||
suspicious: v.optional(v.number()),
|
||||
undetected: v.optional(v.number()),
|
||||
harmless: v.optional(v.number()),
|
||||
});
|
||||
|
||||
const vtAnalysisValidator = v.object({
|
||||
status: v.string(),
|
||||
verdict: v.optional(v.string()),
|
||||
analysis: v.optional(v.string()),
|
||||
source: v.optional(v.string()),
|
||||
scanner: v.optional(v.string()),
|
||||
engineStats: v.optional(vtEngineStatsValidator),
|
||||
checkedAt: v.number(),
|
||||
});
|
||||
|
||||
function buildStructuredModerationPatch(params: {
|
||||
staticScan?: Doc<"skillVersions">["staticScan"];
|
||||
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
|
||||
vtStatus?: string;
|
||||
llmStatus?: string;
|
||||
sourceVersionId?: Id<"skillVersions">;
|
||||
@@ -145,6 +179,7 @@ function buildStructuredModerationPatch(params: {
|
||||
> {
|
||||
const snapshot = buildModerationSnapshot({
|
||||
staticScan: params.staticScan,
|
||||
vtAnalysis: params.vtAnalysis,
|
||||
vtStatus: params.vtStatus,
|
||||
llmStatus: params.llmStatus,
|
||||
sourceVersionId: params.sourceVersionId,
|
||||
@@ -188,6 +223,9 @@ function resolveScannerModerationReason(params: {
|
||||
const vtStatus = normalizeAnalysisStatus(params.vtStatus);
|
||||
const llmStatus = normalizeAnalysisStatus(params.llmStatus);
|
||||
|
||||
if (params.verdict === "clean" && (vtStatus === "suspicious" || llmStatus === "suspicious")) {
|
||||
return "scanner.aggregate.clean";
|
||||
}
|
||||
if (vtStatus === "malicious") return "scanner.vt.malicious";
|
||||
if (llmStatus === "malicious") return "scanner.llm.malicious";
|
||||
if (vtStatus === "suspicious") return "scanner.vt.suspicious";
|
||||
@@ -210,6 +248,7 @@ function buildScannerModerationPatchFromVersion(params: {
|
||||
}): SkillModerationPatch {
|
||||
const structuredPatch = buildStructuredModerationPatch({
|
||||
staticScan: params.version.staticScan,
|
||||
vtAnalysis: params.version.vtAnalysis,
|
||||
vtStatus: params.version.vtAnalysis?.status,
|
||||
llmStatus: params.version.llmAnalysis?.status,
|
||||
sourceVersionId: params.version._id,
|
||||
@@ -484,20 +523,14 @@ async function syncSkillModerationFromLatestVersion(
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
}
|
||||
|
||||
function buildConflictingSkillUrl(
|
||||
skill: Doc<"skills">,
|
||||
owner: SkillOwnerRef,
|
||||
) {
|
||||
function buildConflictingSkillUrl(skill: Doc<"skills">, owner: SkillOwnerRef) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null;
|
||||
const ownerParam = owner.handle?.trim() || String(owner._id);
|
||||
if (!ownerParam) return null;
|
||||
return `/${encodeURIComponent(ownerParam)}/${encodeURIComponent(skill.slug)}`;
|
||||
}
|
||||
|
||||
function buildSlugTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: SkillOwnerRef,
|
||||
) {
|
||||
function buildSlugTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
return (
|
||||
"This slug is locked to a deleted or banned account. " +
|
||||
@@ -510,10 +543,7 @@ function buildSlugTakenErrorMessage(
|
||||
return `${base} Existing skill: ${url}`;
|
||||
}
|
||||
|
||||
function buildAliasTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: SkillOwnerRef,
|
||||
) {
|
||||
function buildAliasTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef) {
|
||||
const base = "Slug redirects to an existing skill. Choose a different slug.";
|
||||
const url = buildConflictingSkillUrl(skill, owner);
|
||||
if (!url) return base;
|
||||
@@ -1115,6 +1145,40 @@ type ManagementSkillEntry = {
|
||||
owner: Doc<"users"> | null;
|
||||
};
|
||||
|
||||
type DashboardSkillListItem = {
|
||||
_id: Id<"skills">;
|
||||
_creationTime: number;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
ownerUserId: Id<"users">;
|
||||
ownerPublisherId?: Id<"publishers">;
|
||||
canonicalSkillId?: Id<"skills">;
|
||||
forkOf?: Doc<"skills">["forkOf"];
|
||||
latestVersionId?: Id<"skillVersions">;
|
||||
tags: Doc<"skills">["tags"];
|
||||
capabilityTags?: string[];
|
||||
badges: Doc<"skills">["badges"];
|
||||
stats: Doc<"skills">["stats"];
|
||||
moderationStatus?: Doc<"skills">["moderationStatus"];
|
||||
moderationReason?: string;
|
||||
moderationVerdict?: Doc<"skills">["moderationVerdict"];
|
||||
moderationFlags?: string[];
|
||||
isSuspicious?: boolean;
|
||||
pendingReview?: true;
|
||||
qualityDecision?: NonNullable<Doc<"skills">["quality"]>["decision"];
|
||||
rescanState: Awaited<ReturnType<typeof buildRescanState>> | null;
|
||||
latestVersion: {
|
||||
version: string;
|
||||
createdAt: number;
|
||||
vtStatus: string | null;
|
||||
llmStatus: string | null;
|
||||
staticScanStatus: "clean" | "suspicious" | "malicious" | null;
|
||||
} | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type BadgeKind = Doc<"skillBadges">["kind"];
|
||||
|
||||
async function buildPublicSkillEntries(
|
||||
@@ -1345,6 +1409,66 @@ async function attachBadgesToSkills(ctx: QueryCtx, skills: Doc<"skills">[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
async function toDashboardSkillListItem(
|
||||
ctx: QueryCtx,
|
||||
skill: Doc<"skills"> & { badges?: Doc<"skills">["badges"] },
|
||||
): Promise<DashboardSkillListItem> {
|
||||
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null;
|
||||
const stats = {
|
||||
...skill.stats,
|
||||
downloads: readCanonicalStat(skill, "downloads"),
|
||||
stars: readCanonicalStat(skill, "stars"),
|
||||
installsCurrent: readCanonicalStat(skill, "installsCurrent"),
|
||||
installsAllTime: readCanonicalStat(skill, "installsAllTime"),
|
||||
};
|
||||
|
||||
return {
|
||||
_id: skill._id,
|
||||
_creationTime: skill._creationTime,
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
canonicalSkillId: skill.canonicalSkillId,
|
||||
forkOf: skill.forkOf,
|
||||
latestVersionId: skill.latestVersionId,
|
||||
tags: skill.tags,
|
||||
capabilityTags: skill.capabilityTags,
|
||||
badges: skill.badges,
|
||||
stats,
|
||||
moderationStatus: skill.moderationStatus,
|
||||
moderationReason: skill.moderationReason,
|
||||
moderationVerdict: skill.moderationVerdict,
|
||||
moderationFlags: skill.moderationFlags,
|
||||
isSuspicious: skill.isSuspicious,
|
||||
pendingReview:
|
||||
skill.moderationReason === "pending.scan" || skill.moderationReason === "pending.scan.stale"
|
||||
? true
|
||||
: undefined,
|
||||
qualityDecision: skill.quality?.decision,
|
||||
rescanState:
|
||||
latestVersion && !latestVersion.softDeletedAt
|
||||
? await buildRescanState(ctx, {
|
||||
kind: "skill",
|
||||
artifactId: latestVersion._id,
|
||||
})
|
||||
: null,
|
||||
latestVersion:
|
||||
latestVersion && !latestVersion.softDeletedAt
|
||||
? {
|
||||
version: latestVersion.version,
|
||||
createdAt: latestVersion.createdAt,
|
||||
vtStatus: latestVersion.vtAnalysis?.status ?? null,
|
||||
llmStatus: latestVersion.llmAnalysis?.status ?? null,
|
||||
staticScanStatus: latestVersion.staticScan?.status ?? null,
|
||||
}
|
||||
: null,
|
||||
createdAt: skill.createdAt,
|
||||
updatedAt: skill.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadHighlightedSkills(ctx: QueryCtx, limit: number) {
|
||||
const entries = await ctx.db
|
||||
.query("skillBadges")
|
||||
@@ -1419,7 +1543,7 @@ export const getBySlug = query({
|
||||
const skill = resolved.skill;
|
||||
if (!skill) return null;
|
||||
|
||||
const userId = await getAuthUserId(ctx);
|
||||
const userId = await getOptionalActiveAuthUserId(ctx);
|
||||
const ownerPublisher = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
@@ -2107,7 +2231,7 @@ export const list = query({
|
||||
}
|
||||
const ownerPublisherId = args.ownerPublisherId;
|
||||
if (ownerPublisherId) {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
const userId = await getOptionalActiveAuthUserId(ctx);
|
||||
const ownerPublisher = await ctx.db.get(ownerPublisherId);
|
||||
const membership =
|
||||
userId &&
|
||||
@@ -2144,36 +2268,9 @@ export const list = query({
|
||||
const withBadges = await attachBadgesToSkills(ctx, filtered);
|
||||
|
||||
if (isOwnDashboard) {
|
||||
return withBadges
|
||||
.map((skill) => {
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
if (publicSkill) return publicSkill;
|
||||
const isPending =
|
||||
skill.moderationStatus === "hidden" && skill.moderationReason === "pending.scan";
|
||||
if (isPending) {
|
||||
const { badges } = skill;
|
||||
return {
|
||||
_id: skill._id,
|
||||
_creationTime: skill._creationTime,
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
canonicalSkillId: skill.canonicalSkillId,
|
||||
forkOf: skill.forkOf,
|
||||
latestVersionId: skill.latestVersionId,
|
||||
tags: skill.tags,
|
||||
badges,
|
||||
stats: skill.stats,
|
||||
createdAt: skill.createdAt,
|
||||
updatedAt: skill.updatedAt,
|
||||
pendingReview: true as const,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((skill): skill is NonNullable<typeof skill> => Boolean(skill));
|
||||
return await Promise.all(
|
||||
withBadges.map(async (skill) => await toDashboardSkillListItem(ctx, skill)),
|
||||
);
|
||||
}
|
||||
|
||||
const visibleSkills = await filterSkillsByActiveOwner(ctx, withBadges);
|
||||
@@ -2183,7 +2280,7 @@ export const list = query({
|
||||
}
|
||||
const ownerUserId = args.ownerUserId;
|
||||
if (ownerUserId) {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
const userId = await getOptionalActiveAuthUserId(ctx);
|
||||
const isOwnDashboard = Boolean(userId && userId === ownerUserId);
|
||||
const entries = await ctx.db
|
||||
.query("skills")
|
||||
@@ -2194,38 +2291,9 @@ export const list = query({
|
||||
const withBadges = await attachBadgesToSkills(ctx, filtered);
|
||||
|
||||
if (isOwnDashboard) {
|
||||
// For owner's own dashboard, include pending skills
|
||||
return withBadges
|
||||
.map((skill) => {
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
if (publicSkill) return publicSkill;
|
||||
// Include pending skills for owner
|
||||
const isPending =
|
||||
skill.moderationStatus === "hidden" && skill.moderationReason === "pending.scan";
|
||||
if (isPending) {
|
||||
// Use computed badges from attachBadgesToSkills, not stored skill.badges
|
||||
const { badges } = skill;
|
||||
return {
|
||||
_id: skill._id,
|
||||
_creationTime: skill._creationTime,
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
canonicalSkillId: skill.canonicalSkillId,
|
||||
forkOf: skill.forkOf,
|
||||
latestVersionId: skill.latestVersionId,
|
||||
tags: skill.tags,
|
||||
badges,
|
||||
stats: skill.stats,
|
||||
createdAt: skill.createdAt,
|
||||
updatedAt: skill.updatedAt,
|
||||
pendingReview: true as const,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((skill): skill is NonNullable<typeof skill> => Boolean(skill));
|
||||
return await Promise.all(
|
||||
withBadges.map(async (skill) => await toDashboardSkillListItem(ctx, skill)),
|
||||
);
|
||||
}
|
||||
|
||||
const visibleSkills = await filterSkillsByActiveOwner(ctx, withBadges);
|
||||
@@ -2799,7 +2867,10 @@ export const listPublicPageV4 = query({
|
||||
let scanInclusive = isFirstPage;
|
||||
let hasMore = false;
|
||||
let nextCursor: string | null = null;
|
||||
let remainingRows = Math.max(numItems, Math.min(MAX_FILTERED_PUBLIC_LIST_SCAN_ROWS, numItems * 12));
|
||||
let remainingRows = Math.max(
|
||||
numItems,
|
||||
Math.min(MAX_FILTERED_PUBLIC_LIST_SCAN_ROWS, numItems * 12),
|
||||
);
|
||||
|
||||
for (let pageCount = 0; pageCount < MAX_FILTERED_PUBLIC_LIST_SCAN_PAGES; pageCount += 1) {
|
||||
if (remainingRows <= 0) break;
|
||||
@@ -2984,6 +3055,7 @@ function skillCatalogMatchesFilters(
|
||||
args: {
|
||||
channel?: "official" | "community" | "private";
|
||||
isOfficial?: boolean;
|
||||
highlightedOnly?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
},
|
||||
@@ -2994,6 +3066,7 @@ function skillCatalogMatchesFilters(
|
||||
const isOfficial = isSkillCatalogOfficial(digest);
|
||||
const channel = getSkillCatalogChannel(digest);
|
||||
if (typeof args.isOfficial === "boolean" && isOfficial !== args.isOfficial) return false;
|
||||
if (args.highlightedOnly && !isSkillHighlighted(digest)) return false;
|
||||
if (args.channel && channel !== args.channel) return false;
|
||||
if (args.capabilityTag && !(digest.capabilityTags ?? []).includes(args.capabilityTag))
|
||||
return false;
|
||||
@@ -3049,6 +3122,7 @@ export const listPackageCatalogPage = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
@@ -3140,6 +3214,7 @@ export const searchPackageCatalogPublic = query({
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
},
|
||||
@@ -3932,11 +4007,18 @@ export const updateSkillVersionStaticScanInternal = internalMutation({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const version = await ctx.db.get(args.versionId);
|
||||
if (!version || version.skillId !== args.skillId) return { ok: true as const, skipped: "missing" as const };
|
||||
if (!version || version.skillId !== args.skillId)
|
||||
return { ok: true as const, skipped: "missing" as const };
|
||||
|
||||
await ctx.db.patch(version._id, {
|
||||
staticScan: args.staticScan,
|
||||
});
|
||||
const updatedVersion = { ...version, staticScan: args.staticScan };
|
||||
await finalizeInProgressRescanRequestsForTarget(
|
||||
ctx,
|
||||
{ kind: "skill", artifactId: version._id },
|
||||
updatedVersion,
|
||||
);
|
||||
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) return { ok: true as const, skipped: "missing" as const };
|
||||
@@ -3946,7 +4028,6 @@ export const updateSkillVersionStaticScanInternal = internalMutation({
|
||||
|
||||
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null;
|
||||
const now = Date.now();
|
||||
const updatedVersion = { ...version, staticScan: args.staticScan };
|
||||
const basePatch = buildScannerModerationPatchFromVersion({
|
||||
owner,
|
||||
version: updatedVersion,
|
||||
@@ -3978,37 +4059,39 @@ export const updateSkillVersionStaticScanInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const scanSkillVersionStaticallyInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
export const scanSkillVersionStaticallyInternal: ReturnType<typeof internalAction> = internalAction(
|
||||
{
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const [skill, version] = await Promise.all([
|
||||
ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId: args.skillId }),
|
||||
ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId: args.versionId }),
|
||||
]);
|
||||
|
||||
if (!skill || !version) {
|
||||
return { ok: true as const, skipped: "missing" as const };
|
||||
}
|
||||
|
||||
const staticScan = await runStaticPublishScan(ctx, {
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary ?? undefined,
|
||||
frontmatter: version.parsed?.frontmatter ?? {},
|
||||
metadata: version.parsed?.metadata,
|
||||
files: version.files,
|
||||
});
|
||||
|
||||
return await ctx.runMutation(internal.skills.updateSkillVersionStaticScanInternal, {
|
||||
skillId: skill._id,
|
||||
versionId: version._id,
|
||||
staticScan,
|
||||
});
|
||||
},
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const [skill, version] = await Promise.all([
|
||||
ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId: args.skillId }),
|
||||
ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId: args.versionId }),
|
||||
]);
|
||||
|
||||
if (!skill || !version) {
|
||||
return { ok: true as const, skipped: "missing" as const };
|
||||
}
|
||||
|
||||
const staticScan = await runStaticPublishScan(ctx, {
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary ?? undefined,
|
||||
frontmatter: version.parsed?.frontmatter ?? {},
|
||||
metadata: version.parsed?.metadata,
|
||||
files: version.files,
|
||||
});
|
||||
|
||||
return await ctx.runMutation(internal.skills.updateSkillVersionStaticScanInternal, {
|
||||
skillId: skill._id,
|
||||
versionId: version._id,
|
||||
staticScan,
|
||||
});
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
export const backfillSkillStaticScansInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
@@ -4018,10 +4101,13 @@ export const backfillSkillStaticScansInternal: ReturnType<typeof internalAction>
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = Math.max(1, Math.min(args.batchSize ?? 25, 100));
|
||||
const batch = await ctx.runQuery(internal.skills.getActiveSkillBatchForStaticScanBackfillInternal, {
|
||||
cursor: args.cursor,
|
||||
batchSize,
|
||||
});
|
||||
const batch = await ctx.runQuery(
|
||||
internal.skills.getActiveSkillBatchForStaticScanBackfillInternal,
|
||||
{
|
||||
cursor: args.cursor,
|
||||
batchSize,
|
||||
},
|
||||
);
|
||||
|
||||
let rescanned = args.rescanned ?? 0;
|
||||
for (const skill of batch.skills) {
|
||||
@@ -4061,6 +4147,156 @@ export const backfillSkillStaticScans: ReturnType<typeof action> = action({
|
||||
},
|
||||
});
|
||||
|
||||
async function markSkillRescanRequest(
|
||||
ctx: { runMutation: (ref: never, args: never) => Promise<unknown> },
|
||||
requestId: Id<"rescanRequests">,
|
||||
status: "completed" | "failed",
|
||||
error?: string,
|
||||
) {
|
||||
await ctx.runMutation(
|
||||
internal.rescanRequests.markStatusInternal as never,
|
||||
{
|
||||
requestId,
|
||||
status,
|
||||
error,
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
|
||||
export const getRescanState = query({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
const target = await getLatestSkillRescanTarget(ctx, args.skillId);
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
actor: user,
|
||||
ownerUserId: target.skill.ownerUserId,
|
||||
ownerPublisherId: target.skill.ownerPublisherId,
|
||||
allowPlatformAdmin: true,
|
||||
});
|
||||
return {
|
||||
targetKind: "skill" as const,
|
||||
targetVersion: target.version.version,
|
||||
skillVersionId: target.version._id,
|
||||
...(await buildRescanState(ctx, {
|
||||
kind: "skill",
|
||||
artifactId: target.version._id,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const requestRescan = mutation({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
const target = await getLatestSkillRescanTarget(ctx, args.skillId);
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
actor: user,
|
||||
ownerUserId: target.skill.ownerUserId,
|
||||
ownerPublisherId: target.skill.ownerPublisherId,
|
||||
allowPlatformAdmin: true,
|
||||
});
|
||||
await assertCanRequestRescan(ctx, {
|
||||
kind: "skill",
|
||||
artifactId: target.version._id,
|
||||
});
|
||||
|
||||
const requestId = await insertSkillRescanRequest(ctx, user, target);
|
||||
await ctx.scheduler.runAfter(0, internal.skills.dispatchSkillRescanInternal, {
|
||||
requestId,
|
||||
skillId: target.skill._id,
|
||||
versionId: target.version._id,
|
||||
});
|
||||
|
||||
return {
|
||||
requestId,
|
||||
...(await buildRescanState(ctx, {
|
||||
kind: "skill",
|
||||
artifactId: target.version._id,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const requestRescanForApiTokenInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
slug: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
|
||||
const resolved = await resolveSkillBySlugOrAlias(ctx, args.slug.trim().toLowerCase());
|
||||
const skill = resolved.skill;
|
||||
if (!skill) throw new ConvexError("Skill not found");
|
||||
|
||||
const target = await getLatestSkillRescanTarget(ctx, skill._id);
|
||||
await assertCanManageOwnedResource(ctx, {
|
||||
actor,
|
||||
ownerUserId: target.skill.ownerUserId,
|
||||
ownerPublisherId: target.skill.ownerPublisherId,
|
||||
allowPlatformAdmin: true,
|
||||
});
|
||||
await assertCanRequestRescan(ctx, {
|
||||
kind: "skill",
|
||||
artifactId: target.version._id,
|
||||
});
|
||||
|
||||
const requestId = await insertSkillRescanRequest(ctx, actor, target);
|
||||
await ctx.scheduler.runAfter(0, internal.skills.dispatchSkillRescanInternal, {
|
||||
requestId,
|
||||
skillId: target.skill._id,
|
||||
versionId: target.version._id,
|
||||
});
|
||||
|
||||
const state = await buildRescanState(ctx, {
|
||||
kind: "skill",
|
||||
artifactId: target.version._id,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
targetKind: "skill" as const,
|
||||
name: target.skill.slug,
|
||||
version: target.version.version,
|
||||
status: state.inProgressRequest?.status ?? state.latestRequest?.status ?? "in_progress",
|
||||
remainingRequests: state.remainingRequests,
|
||||
maxRequests: state.maxRequests,
|
||||
pendingRequestId: requestId,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const dispatchSkillRescanInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
requestId: v.id("rescanRequests"),
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
await ctx.runAction(internal.skills.scanSkillVersionStaticallyInternal, {
|
||||
skillId: args.skillId,
|
||||
versionId: args.versionId,
|
||||
});
|
||||
await ctx.runAction(internal.vt.scanWithVirusTotal, {
|
||||
versionId: args.versionId,
|
||||
});
|
||||
await ctx.runAction(internal.llmEval.evaluateWithLlm, {
|
||||
versionId: args.versionId,
|
||||
});
|
||||
} catch (error) {
|
||||
await markSkillRescanRequest(ctx, args.requestId, "failed", errorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Emergency escalation by skillId for legacy rows without sha256hash.
|
||||
* Rebuilds the full moderation snapshot so legacy rows stay in sync with structured fields.
|
||||
@@ -4085,6 +4321,7 @@ export const escalateSkillByIdInternal = internalMutation({
|
||||
const llmStatus = reasonMatch?.[1] === "llm" ? reasonMatch[2] : version?.llmAnalysis?.status;
|
||||
const snapshot = buildModerationSnapshot({
|
||||
staticScan: version?.staticScan,
|
||||
vtAnalysis: version?.vtAnalysis,
|
||||
vtStatus,
|
||||
llmStatus,
|
||||
sourceVersionId: version?._id,
|
||||
@@ -4572,15 +4809,7 @@ export const updateVersionScanResultsInternal = internalMutation({
|
||||
args: {
|
||||
versionId: v.id("skillVersions"),
|
||||
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(),
|
||||
}),
|
||||
),
|
||||
vtAnalysis: v.optional(vtAnalysisValidator),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const version = await ctx.db.get(args.versionId);
|
||||
@@ -4596,6 +4825,11 @@ export const updateVersionScanResultsInternal = internalMutation({
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await ctx.db.patch(args.versionId, patch);
|
||||
await finalizeInProgressRescanRequestsForTarget(
|
||||
ctx,
|
||||
{ kind: "skill", artifactId: args.versionId },
|
||||
{ ...version, ...patch },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -4629,6 +4863,11 @@ export const updateVersionLlmAnalysisInternal = internalMutation({
|
||||
if (!version) return;
|
||||
const nextVersion = { ...version, llmAnalysis: args.llmAnalysis };
|
||||
await ctx.db.patch(args.versionId, { llmAnalysis: args.llmAnalysis });
|
||||
await finalizeInProgressRescanRequestsForTarget(
|
||||
ctx,
|
||||
{ kind: "skill", artifactId: version._id },
|
||||
nextVersion,
|
||||
);
|
||||
|
||||
const skill = await ctx.db.get(version.skillId);
|
||||
if (!skill || skill.latestVersionId !== version._id) return;
|
||||
@@ -4694,11 +4933,6 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
|
||||
const now = Date.now();
|
||||
const qualityLocked = skill.moderationReason === "quality.low" && !isMalicious;
|
||||
const nextModerationReason = qualityLocked
|
||||
? "quality.low"
|
||||
: bypassSuspicious
|
||||
? `scanner.${args.scanner}.clean`
|
||||
: `scanner.${args.scanner}.${args.status}`;
|
||||
const nextModerationNotes = qualityLocked
|
||||
? (skill.moderationNotes ??
|
||||
"Quality gate quarantine is still active. Manual moderation review required.")
|
||||
@@ -4706,6 +4940,7 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
const scanner = args.scanner.trim().toLowerCase();
|
||||
const snapshot = buildModerationSnapshot({
|
||||
staticScan: version.staticScan,
|
||||
vtAnalysis: version.vtAnalysis,
|
||||
vtStatus: scanner === "vt" ? args.status : version.vtAnalysis?.status,
|
||||
llmStatus: scanner === "llm" ? args.status : version.llmAnalysis?.status,
|
||||
sourceVersionId: version._id,
|
||||
@@ -4716,6 +4951,16 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
: snapshot.reasonCodes;
|
||||
const nextVerdict = verdictFromCodes(nextReasonCodes);
|
||||
const nextLegacyFlags = legacyFlagsFromVerdict(nextVerdict);
|
||||
if (nextVerdict === "clean" && !alreadyBlocked) {
|
||||
newFlags = undefined;
|
||||
}
|
||||
const nextModerationReason = qualityLocked
|
||||
? "quality.low"
|
||||
: bypassSuspicious
|
||||
? `scanner.${args.scanner}.clean`
|
||||
: nextVerdict === "clean"
|
||||
? "scanner.aggregate.clean"
|
||||
: `scanner.${args.scanner}.${args.status}`;
|
||||
const nextModerationStatus =
|
||||
nextVerdict === "malicious" || qualityLocked ? "hidden" : "active";
|
||||
|
||||
@@ -4801,9 +5046,9 @@ export const escalateByVtInternal = internalMutation({
|
||||
newFlags = ["flagged.suspicious"];
|
||||
}
|
||||
|
||||
const nextModerationFlags = newFlags.length ? newFlags : undefined;
|
||||
const snapshot = buildModerationSnapshot({
|
||||
staticScan: version.staticScan,
|
||||
vtAnalysis: version.vtAnalysis,
|
||||
vtStatus: args.status,
|
||||
llmStatus: version.llmAnalysis?.status,
|
||||
sourceVersionId: version._id,
|
||||
@@ -4813,6 +5058,13 @@ export const escalateByVtInternal = internalMutation({
|
||||
? snapshot.reasonCodes.filter((code) => !code.startsWith("suspicious."))
|
||||
: snapshot.reasonCodes;
|
||||
const nextVerdict = verdictFromCodes(nextReasonCodes);
|
||||
const nextLegacyFlags = legacyFlagsFromVerdict(nextVerdict);
|
||||
const nextModerationFlags =
|
||||
nextVerdict === "clean" && !alreadyBlocked
|
||||
? undefined
|
||||
: newFlags.length
|
||||
? newFlags
|
||||
: nextLegacyFlags;
|
||||
const now = Date.now();
|
||||
const basePatch: SkillModerationPatch = {
|
||||
moderationFlags: nextModerationFlags,
|
||||
@@ -4829,11 +5081,21 @@ export const escalateByVtInternal = internalMutation({
|
||||
basePatch.moderationReason = normalizeScannerSuspiciousReason(
|
||||
skill.moderationReason as string | undefined,
|
||||
);
|
||||
} else if (nextVerdict === "clean" && !alreadyBlocked) {
|
||||
const existingReason = skill.moderationReason as string | undefined;
|
||||
if (existingReason?.startsWith("scanner.") && existingReason.endsWith(".suspicious")) {
|
||||
basePatch.moderationReason = normalizeScannerSuspiciousReason(existingReason);
|
||||
}
|
||||
}
|
||||
|
||||
// Only hide for malicious — suspicious stays visible with a flag
|
||||
if (isMalicious) {
|
||||
basePatch.moderationStatus = "hidden";
|
||||
} else if (nextVerdict === "clean" && !alreadyBlocked) {
|
||||
basePatch.moderationStatus = "active";
|
||||
basePatch.hiddenAt = undefined;
|
||||
basePatch.hiddenBy = undefined;
|
||||
basePatch.lastReviewedAt = undefined;
|
||||
}
|
||||
|
||||
basePatch.isSuspicious = computeIsSuspicious({
|
||||
@@ -4975,7 +5237,7 @@ async function canReadSkillVersionFiles(ctx: ActionCtx, version: Doc<"skillVersi
|
||||
})) as Doc<"skills"> | null;
|
||||
if (!skill) return false;
|
||||
|
||||
const authUserId = await getAuthUserId(ctx);
|
||||
const authUserId = await getOptionalActiveAuthUserIdFromAction(ctx);
|
||||
if (authUserId) {
|
||||
if (authUserId === skill.ownerUserId && !skill.softDeletedAt && !version.softDeletedAt) {
|
||||
return true;
|
||||
@@ -5446,7 +5708,10 @@ export const changeOwner = mutation({
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ownerUserId: args.ownerUserId });
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, {
|
||||
...skill,
|
||||
ownerUserId: args.ownerUserId,
|
||||
});
|
||||
|
||||
const embeddings = await listSkillEmbeddingsForSkill(ctx, skill._id);
|
||||
for (const embedding of embeddings) {
|
||||
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
import { v } from "convex/values";
|
||||
import { mutation, query } from "./functions";
|
||||
import { requireUser } from "./lib/access";
|
||||
import { getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
||||
import { toPublicSoul } from "./lib/public";
|
||||
|
||||
export const isStarred = query({
|
||||
args: { soulId: v.id("souls") },
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
const userId = await getOptionalActiveAuthUserId(ctx);
|
||||
if (!userId) return false;
|
||||
const existing = await ctx.db
|
||||
.query("soulStars")
|
||||
.withIndex("by_soul_user", (q) => q.eq("soulId", args.soulId).eq("userId", userId))
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isStarred } from "./stars";
|
||||
import { isStarred as isSoulStarred } from "./soulStars";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
function unwrapHandler(wrapped: unknown) {
|
||||
const handler = (wrapped as { _handler?: unknown })._handler;
|
||||
if (typeof handler !== "function") {
|
||||
throw new Error("Expected Convex test wrapper to expose _handler");
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
||||
const isStarredHandler = unwrapHandler(isStarred) as (
|
||||
ctx: unknown,
|
||||
args: { skillId: string },
|
||||
) => Promise<boolean>;
|
||||
const isSoulStarredHandler = unwrapHandler(isSoulStarred) as (
|
||||
ctx: unknown,
|
||||
args: { soulId: string },
|
||||
) => Promise<boolean>;
|
||||
|
||||
function makeCtx(options: { user?: Record<string, unknown> | null; existingStar?: unknown }) {
|
||||
return {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:viewer") return options.user ?? { _id: "users:viewer" };
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(options.existingStar ?? null),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getAuthUserId).mockReset();
|
||||
});
|
||||
|
||||
describe("stars queries", () => {
|
||||
it("returns false instead of throwing when skill star auth is stale", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:viewer" as never);
|
||||
|
||||
await expect(
|
||||
isStarredHandler(makeCtx({ user: null }), { skillId: "skills:demo" }),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false instead of throwing when soul star auth is stale", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:viewer" as never);
|
||||
|
||||
await expect(
|
||||
isSoulStarredHandler(makeCtx({ user: null }), { soulId: "souls:demo" }),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("still reports existing stars for active users", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:viewer" as never);
|
||||
|
||||
await expect(
|
||||
isStarredHandler(makeCtx({ existingStar: { _id: "stars:demo" } }), {
|
||||
skillId: "skills:demo",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("still reports existing soul stars for active users", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:viewer" as never);
|
||||
|
||||
await expect(
|
||||
isSoulStarredHandler(makeCtx({ existingStar: { _id: "soulStars:demo" } }), {
|
||||
soulId: "souls:demo",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
+3
-2
@@ -1,13 +1,14 @@
|
||||
import { v } from "convex/values";
|
||||
import { internalMutation, mutation, query } from "./functions";
|
||||
import { requireUser } from "./lib/access";
|
||||
import { getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
||||
import { toPublicSkill } from "./lib/public";
|
||||
import { insertStatEvent } from "./skillStatEvents";
|
||||
|
||||
export const isStarred = query({
|
||||
args: { skillId: v.id("skills") },
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx);
|
||||
const userId = await getOptionalActiveAuthUserId(ctx);
|
||||
if (!userId) return false;
|
||||
const existing = await ctx.db
|
||||
.query("stars")
|
||||
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", userId))
|
||||
|
||||
+16
-7
@@ -3,15 +3,20 @@ import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
||||
import {
|
||||
assertAdmin,
|
||||
assertModerator,
|
||||
getOptionalActiveAuthUserId,
|
||||
requireUser,
|
||||
} from "./lib/access";
|
||||
import { syncGitHubProfile } from "./lib/githubAccount";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPublisherByHandle,
|
||||
getUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
getLatestActiveReservedHandle,
|
||||
isHandleReservedForAnotherUser,
|
||||
@@ -296,7 +301,9 @@ export async function ensureHandler(ctx: MutationCtx) {
|
||||
updates.updatedAt = Date.now();
|
||||
await ctx.db.patch(userId, updates);
|
||||
}
|
||||
const ensuredUser = hasUpdates ? ({ ...user, ...updates } as Doc<"users">) : ((await ctx.db.get(userId)) ?? user);
|
||||
const ensuredUser = hasUpdates
|
||||
? ({ ...user, ...updates } as Doc<"users">)
|
||||
: ((await ctx.db.get(userId)) ?? user);
|
||||
await ensurePersonalPublisherForUser(ctx, ensuredUser);
|
||||
return await ctx.db.get(userId);
|
||||
}
|
||||
@@ -387,12 +394,11 @@ export const list = query({
|
||||
});
|
||||
|
||||
export const listPublic = query({
|
||||
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? 40, 1, 100);
|
||||
const result = await queryUsersForPublicList(ctx, {
|
||||
limit,
|
||||
search: args.search,
|
||||
});
|
||||
return {
|
||||
items: result.items
|
||||
@@ -445,7 +451,9 @@ async function queryUsersForPublicList(
|
||||
: clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
|
||||
const scannedUsers = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_active_handle", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined))
|
||||
.withIndex("by_active_handle", (q) =>
|
||||
q.eq("deletedAt", undefined).eq("deactivatedAt", undefined),
|
||||
)
|
||||
.order("desc")
|
||||
.take(scanLimit);
|
||||
const activeUsers = scannedUsers.filter((user) => Boolean(user.handle));
|
||||
@@ -899,7 +907,8 @@ async function ensurePublisherHandleWithActor(
|
||||
|
||||
if (existing) {
|
||||
const nextDisplayName =
|
||||
args.displayName?.trim() && (!existing.displayName || existing.displayName === existing.handle)
|
||||
args.displayName?.trim() &&
|
||||
(!existing.displayName || existing.displayName === existing.handle)
|
||||
? displayName
|
||||
: existing.displayName;
|
||||
await ctx.db.patch(existing._id, {
|
||||
|
||||
@@ -92,6 +92,7 @@ describe("version file access actions", () => {
|
||||
it("allows owners to read hidden skill versions", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const ctx = makeActionCtx({
|
||||
actor: { _id: "users:owner", role: "user" },
|
||||
version: makeSkillVersion(),
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
@@ -133,6 +134,7 @@ describe("version file access actions", () => {
|
||||
it("allows owners to read hidden skill files", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const ctx = makeActionCtx({
|
||||
actor: { _id: "users:owner", role: "user" },
|
||||
version: makeSkillVersion(),
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
|
||||
@@ -92,6 +92,26 @@ describe("vt activation fallback", () => {
|
||||
});
|
||||
|
||||
describe("vt AV engine fallback verdicts", () => {
|
||||
it("strips unsupported VT stat keys before caching", () => {
|
||||
expect(
|
||||
__test.normalizeVtEngineStats({
|
||||
"confirmed-timeout": 0,
|
||||
failure: 2,
|
||||
harmless: 0,
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
timeout: 0,
|
||||
"type-unsupported": 10,
|
||||
undetected: 64,
|
||||
} as never),
|
||||
).toEqual({
|
||||
harmless: 0,
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
undetected: 64,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps engine verdicts in severity order", () => {
|
||||
expect(
|
||||
__test.statusFromAvStats({
|
||||
|
||||
@@ -166,6 +166,16 @@ type PackageReleaseScanDoc = Pick<
|
||||
>;
|
||||
type PackageScanDoc = Pick<Doc<"packages">, "family" | "isOfficial">;
|
||||
|
||||
function normalizeVtEngineStats(stats?: VTAnalysisStats | null) {
|
||||
if (!stats) return undefined;
|
||||
return {
|
||||
malicious: stats.malicious,
|
||||
suspicious: stats.suspicious,
|
||||
undetected: stats.undetected,
|
||||
harmless: stats.harmless,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPackageUndetectedFallbackAnalysis(
|
||||
release: PackageReleaseScanDoc,
|
||||
pkg: PackageScanDoc,
|
||||
@@ -203,11 +213,14 @@ function buildPackageScanAnalysisFromVtResult(
|
||||
);
|
||||
if (aiResult) {
|
||||
const verdict = normalizeVerdict(aiResult.verdict);
|
||||
const stats = vtResult.data.attributes.last_analysis_stats;
|
||||
return {
|
||||
status: verdictToStatus(verdict),
|
||||
verdict: aiResult.verdict,
|
||||
analysis: aiResult.analysis,
|
||||
source: aiResult.source,
|
||||
scanner: "code_insight",
|
||||
engineStats: normalizeVtEngineStats(stats),
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -511,6 +524,7 @@ export const scanWithVirusTotal = internalAction({
|
||||
);
|
||||
|
||||
if (aiResult) {
|
||||
const stats = existingFile.data.attributes.last_analysis_stats;
|
||||
// File exists and has AI analysis - use the verdict
|
||||
const verdict = normalizeVerdict(aiResult.verdict);
|
||||
const status = verdictToStatus(verdict);
|
||||
@@ -526,6 +540,8 @@ export const scanWithVirusTotal = internalAction({
|
||||
verdict: aiResult.verdict,
|
||||
analysis: aiResult.analysis,
|
||||
source: aiResult.source,
|
||||
scanner: "code_insight",
|
||||
engineStats: normalizeVtEngineStats(stats),
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
@@ -766,6 +782,11 @@ export const pollPackageReleaseScanResults = internalAction({
|
||||
attempt: attempt + 1,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
|
||||
releaseId: args.releaseId,
|
||||
vtAnalysis: { status: "stale", checkedAt: Date.now() },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -790,6 +811,11 @@ export const pollPackageReleaseScanResults = internalAction({
|
||||
attempt: attempt + 1,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
|
||||
releaseId: args.releaseId,
|
||||
vtAnalysis: { status: "stale", checkedAt: Date.now() },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[vt:package] Error polling ${release.sha256hash}:`, error);
|
||||
@@ -803,6 +829,11 @@ export const pollPackageReleaseScanResults = internalAction({
|
||||
attempt: attempt + 1,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
|
||||
releaseId: args.releaseId,
|
||||
vtAnalysis: { status: "error", checkedAt: Date.now() },
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -914,6 +945,7 @@ export const pollPendingScans = internalAction({
|
||||
vtAnalysis: {
|
||||
status,
|
||||
source,
|
||||
engineStats: normalizeVtEngineStats(stats),
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
@@ -952,6 +984,7 @@ export const pollPendingScans = internalAction({
|
||||
// We have a verdict - update the skill
|
||||
const verdict = normalizeVerdict(aiResult.verdict);
|
||||
const status = verdictToStatus(verdict);
|
||||
const stats = vtResult.data.attributes.last_analysis_stats;
|
||||
|
||||
console.log(
|
||||
`[vt:pollPendingScans] Hash ${sha256hash} verdict: ${verdict} -> status: ${status}`,
|
||||
@@ -965,6 +998,8 @@ export const pollPendingScans = internalAction({
|
||||
verdict: aiResult.verdict,
|
||||
analysis: aiResult.analysis,
|
||||
source: aiResult.source,
|
||||
scanner: "code_insight",
|
||||
engineStats: normalizeVtEngineStats(stats),
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
@@ -1046,6 +1081,7 @@ async function requestRescan(apiKey: string, sha256hash: string): Promise<boolea
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
normalizeVtEngineStats,
|
||||
statusFromAvStats,
|
||||
shouldActivateWhenVtUnavailable,
|
||||
};
|
||||
@@ -1249,6 +1285,7 @@ export const rescanActiveSkills = internalAction({
|
||||
vtAnalysis: {
|
||||
status,
|
||||
source,
|
||||
engineStats: normalizeVtEngineStats(stats),
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
@@ -1278,6 +1315,7 @@ export const rescanActiveSkills = internalAction({
|
||||
|
||||
const verdict = normalizeVerdict(aiResult.verdict);
|
||||
const status = verdictToStatus(verdict);
|
||||
const stats = vtResult.data.attributes.last_analysis_stats;
|
||||
|
||||
await ctx.runMutation(internal.skills.updateVersionScanResultsInternal, {
|
||||
versionId,
|
||||
@@ -1286,6 +1324,8 @@ export const rescanActiveSkills = internalAction({
|
||||
verdict: aiResult.verdict,
|
||||
analysis: aiResult.analysis,
|
||||
source: aiResult.source,
|
||||
scanner: "code_insight",
|
||||
engineStats: normalizeVtEngineStats(stats),
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
@@ -1576,6 +1616,7 @@ export const backfillActiveSkillsVTCache = internalAction({
|
||||
// Update the version with VT analysis
|
||||
const verdict = normalizeVerdict(aiResult.verdict);
|
||||
const status = verdictToStatus(verdict);
|
||||
const stats = vtResult.data.attributes.last_analysis_stats;
|
||||
|
||||
await ctx.runMutation(internal.skills.updateVersionScanResultsInternal, {
|
||||
versionId,
|
||||
@@ -1585,6 +1626,8 @@ export const backfillActiveSkillsVTCache = internalAction({
|
||||
verdict: aiResult.verdict,
|
||||
analysis: aiResult.analysis,
|
||||
source: aiResult.source,
|
||||
scanner: "code_insight",
|
||||
engineStats: normalizeVtEngineStats(stats),
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,6 +33,14 @@ function mustGetToken() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAdminToken() {
|
||||
return process.env.CLAWHUB_E2E_ADMIN_TOKEN?.trim() || null;
|
||||
}
|
||||
|
||||
function getUserToken() {
|
||||
return process.env.CLAWHUB_E2E_USER_TOKEN?.trim() || null;
|
||||
}
|
||||
|
||||
function getRegistry() {
|
||||
return (
|
||||
process.env.CLAWHUB_REGISTRY?.trim() ||
|
||||
@@ -73,7 +81,19 @@ function allowLiveMutations() {
|
||||
return value === "1" || value?.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function shouldSeedRoleHelpTokens() {
|
||||
const value = process.env.CLAWHUB_E2E_SEED_CLI_ROLE_HELP?.trim();
|
||||
return value === "1" || value?.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
const itIfLiveMutations = allowLiveMutations() ? it : it.skip;
|
||||
const itIfAdminAndUserTokens =
|
||||
getAdminToken() && getUserToken() || shouldSeedRoleHelpTokens() ? it : it.skip;
|
||||
|
||||
type RoleHelpTokens = {
|
||||
adminToken: string;
|
||||
userToken: string;
|
||||
};
|
||||
|
||||
async function makeTempConfig(registry: string, token: string | null) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "clawhub-e2e-"));
|
||||
@@ -86,6 +106,67 @@ async function makeTempConfig(registry: string, token: string | null) {
|
||||
return { dir, path };
|
||||
}
|
||||
|
||||
async function resolveRoleHelpTokens(registry: string): Promise<RoleHelpTokens> {
|
||||
const adminToken = getAdminToken();
|
||||
const userToken = getUserToken();
|
||||
if (adminToken && userToken) return { adminToken, userToken };
|
||||
|
||||
if (!shouldSeedRoleHelpTokens()) {
|
||||
throw new Error(
|
||||
"Missing CLAWHUB_E2E_ADMIN_TOKEN/CLAWHUB_E2E_USER_TOKEN or CLAWHUB_E2E_SEED_CLI_ROLE_HELP=1",
|
||||
);
|
||||
}
|
||||
if (!isLocalRegistry(registry)) {
|
||||
throw new Error("CLAWHUB_E2E_SEED_CLI_ROLE_HELP=1 only works against local registries");
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
"bunx",
|
||||
["convex", "run", "--no-push", "devSeed:seedCliRoleHelpFixtures"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to seed role help fixtures:\n${result.stderr || result.stdout}`);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(extractLastJsonObject(result.stdout)) as {
|
||||
admin?: { token?: unknown };
|
||||
user?: { token?: unknown };
|
||||
};
|
||||
if (typeof parsed.admin?.token !== "string" || typeof parsed.user?.token !== "string") {
|
||||
throw new Error("Role help fixture seed did not return admin and user tokens");
|
||||
}
|
||||
return { adminToken: parsed.admin.token, userToken: parsed.user.token };
|
||||
}
|
||||
|
||||
function isLocalRegistry(registry: string) {
|
||||
try {
|
||||
const hostname = new URL(registry).hostname;
|
||||
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractLastJsonObject(output: string) {
|
||||
const trimmed = output.trim();
|
||||
for (let index = 0; index < trimmed.length; index += 1) {
|
||||
if (trimmed[index] !== "{") continue;
|
||||
const candidate = trimmed.slice(index);
|
||||
try {
|
||||
JSON.parse(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Keep scanning for the actual JSON payload if Convex printed status lines first.
|
||||
}
|
||||
}
|
||||
throw new Error(`No JSON object in convex run output:\n${output}`);
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(new Error("Timeout")), REQUEST_TIMEOUT_MS);
|
||||
@@ -197,6 +278,63 @@ describe("clawhub e2e", () => {
|
||||
}
|
||||
});
|
||||
|
||||
itIfAdminAndUserTokens("shows staff CLI commands only in admin help", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const { adminToken, userToken } = await resolveRoleHelpTokens(registry);
|
||||
|
||||
async function expectRole(token: string, expectedRole: "admin" | "user") {
|
||||
const whoamiUrl = new URL(ApiRoutes.whoami, registry);
|
||||
const response = await fetchWithTimeout(whoamiUrl.toString(), {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
const whoami = parseArk(
|
||||
ApiV1WhoamiResponseSchema,
|
||||
(await response.json()) as unknown,
|
||||
"Whoami",
|
||||
);
|
||||
expect(whoami.user.role).toBe(expectedRole);
|
||||
}
|
||||
|
||||
await expectRole(adminToken, "admin");
|
||||
await expectRole(userToken, "user");
|
||||
|
||||
const adminCfg = await makeTempConfig(registry, adminToken);
|
||||
const userCfg = await makeTempConfig(registry, userToken);
|
||||
try {
|
||||
const baseEnv = { ...process.env, CLAWHUB_DISABLE_TELEMETRY: "1" };
|
||||
const adminResult = spawnSync(
|
||||
"bun",
|
||||
["clawhub", "--registry", registry, "--site", site, "--help"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...baseEnv, CLAWHUB_CONFIG_PATH: adminCfg.path },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
const userResult = spawnSync(
|
||||
"bun",
|
||||
["clawhub", "--registry", registry, "--site", site, "--help"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...baseEnv, CLAWHUB_CONFIG_PATH: userCfg.path },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
expect(adminResult.status).toBe(0);
|
||||
expect(adminResult.stdout).toContain("ban-user");
|
||||
expect(adminResult.stdout).toContain("set-role");
|
||||
expect(userResult.status).toBe(0);
|
||||
expect(userResult.stdout).not.toContain("ban-user");
|
||||
expect(userResult.stdout).not.toContain("set-role");
|
||||
} finally {
|
||||
await rm(adminCfg.dir, { recursive: true, force: true });
|
||||
await rm(userCfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sync dry-run finds skills from an explicit root", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
|
||||
|
||||
const navLabels = ["Skills", "Plugins", "Search"];
|
||||
const navLabels = ["Skills", "Plugins"];
|
||||
|
||||
test("skills loads without error", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
@@ -13,7 +13,7 @@ test("skills loads without error", async ({ page }) => {
|
||||
test("souls loads without error", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
await page.goto("/souls", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator("h1", { hasText: "Souls" })).toBeVisible();
|
||||
await expect(page.locator("h1", { hasText: "SOUL.md discovery is on deck" })).toBeVisible();
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
@@ -35,11 +35,6 @@ test("header menu routes render", async ({ page }) => {
|
||||
await expect(page).toHaveURL(/\/plugins(\?|$)/);
|
||||
await expect(page.locator("h1", { hasText: "Plugins" })).toBeVisible();
|
||||
}
|
||||
|
||||
if (label === "Search") {
|
||||
await expect(page).toHaveURL(/\/skills(\?|$)/);
|
||||
await expect(page.locator("h1", { hasText: "Skills" })).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dev": "bun --bun vite dev --port 3000",
|
||||
"docs:list": "bun scripts/docs-list.ts",
|
||||
"format": "oxfmt --write",
|
||||
"format:check": "oxfmt --check",
|
||||
"install:local-hooks": "bun scripts/install-git-hooks.mjs",
|
||||
"lint": "bun run lint:oxlint",
|
||||
"lint:fix": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex ./packages/clawhub/src ./packages/schema/src --fix && bun run format",
|
||||
@@ -39,10 +40,15 @@
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/manrope": "^5.2.8",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@@ -64,6 +70,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"convex": "^1.34.1",
|
||||
"convex-helpers": "^0.1.114",
|
||||
"fflate": "^0.8.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawhub",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
|
||||
"homepage": "https://clawhub.ai",
|
||||
"bugs": {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { shouldShowAdminCommandsInHelp } from "./cli/adminHelp.js";
|
||||
import { getCliBuildLabel, getCliVersion } from "./cli/buildInfo.js";
|
||||
import { resolveClawdbotDefaultWorkspace } from "./cli/clawdbotConfig.js";
|
||||
import { cmdLoginFlow, cmdLogout, cmdWhoami } from "./cli/commands/auth.js";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
cmdSetPackageTrustedPublisher,
|
||||
} from "./cli/commands/packages.js";
|
||||
import { cmdPublish } from "./cli/commands/publish.js";
|
||||
import { cmdRescanPackage, cmdRescanSkill } from "./cli/commands/rescan.js";
|
||||
import {
|
||||
cmdExplore,
|
||||
cmdInstall,
|
||||
@@ -47,6 +49,9 @@ import type { GlobalOpts } from "./cli/types.js";
|
||||
import { fail } from "./cli/ui.js";
|
||||
import { readGlobalConfig } from "./config.js";
|
||||
|
||||
const showAdminCommandsInHelp = await shouldShowAdminCommandsInHelp();
|
||||
const adminCommandOptions = showAdminCommandsInHelp ? undefined : { hidden: true };
|
||||
|
||||
const program = new Command()
|
||||
.name("clawhub")
|
||||
.description(
|
||||
@@ -451,6 +456,17 @@ trustedPublisherCmd
|
||||
await cmdDeletePackageTrustedPublisher(opts, name, options);
|
||||
});
|
||||
|
||||
packageCmd
|
||||
.command("rescan")
|
||||
.description("Request a security rescan for the latest published package release")
|
||||
.argument("<name>", "Package name")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (name, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdRescanPackage(opts, name, options, isInputAllowed());
|
||||
});
|
||||
|
||||
skill
|
||||
.command("rename")
|
||||
.description("Rename a published skill and keep the old slug as a redirect")
|
||||
@@ -473,8 +489,19 @@ skill
|
||||
await cmdMergeSkill(opts, sourceSlug, targetSlug, options, isInputAllowed());
|
||||
});
|
||||
|
||||
skill
|
||||
.command("rescan")
|
||||
.description("Request a security rescan for the latest published skill version")
|
||||
.argument("<slug>", "Skill slug")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (slug, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdRescanSkill(opts, slug, options, isInputAllowed());
|
||||
});
|
||||
|
||||
program
|
||||
.command("ban-user")
|
||||
.command("ban-user", adminCommandOptions)
|
||||
.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")
|
||||
@@ -487,7 +514,7 @@ program
|
||||
});
|
||||
|
||||
program
|
||||
.command("set-role")
|
||||
.command("set-role", adminCommandOptions)
|
||||
.description("Change a user role (admin only)")
|
||||
.argument("<handleOrId>", "User handle (default) or user id")
|
||||
.argument("<role>", "user | moderator | admin")
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockReadGlobalConfig = vi.fn(
|
||||
async () => null as { registry?: string; token?: string } | null,
|
||||
);
|
||||
vi.mock("../config.js", () => ({
|
||||
readGlobalConfig: () => mockReadGlobalConfig(),
|
||||
}));
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
vi.mock("../http.js", () => ({
|
||||
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
|
||||
}));
|
||||
|
||||
const { isHelpRequest, shouldShowAdminCommandsInHelp } = await import("./adminHelp");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("admin help gating", () => {
|
||||
it("treats root help-like invocations as help requests", () => {
|
||||
expect(isHelpRequest(["node", "clawhub", "--help"])).toBe(true);
|
||||
expect(isHelpRequest(["node", "clawhub", "help"])).toBe(true);
|
||||
expect(isHelpRequest(["node", "clawhub"])).toBe(true);
|
||||
expect(isHelpRequest(["node", "clawhub", "search", "weather"])).toBe(false);
|
||||
});
|
||||
|
||||
it("does not hide commands for normal command execution", async () => {
|
||||
await expect(
|
||||
shouldShowAdminCommandsInHelp({
|
||||
argv: ["node", "clawhub", "ban-user", "demo"],
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(mockReadGlobalConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides admin commands from help when logged out", async () => {
|
||||
mockReadGlobalConfig.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
shouldShowAdminCommandsInHelp({
|
||||
argv: ["node", "clawhub", "--help"],
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(mockApiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows admin commands in help for stored admin tokens", async () => {
|
||||
mockReadGlobalConfig.mockResolvedValueOnce({
|
||||
registry: "https://registry.example",
|
||||
token: "clh_admin",
|
||||
});
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
user: { handle: "p", role: "admin" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
shouldShowAdminCommandsInHelp({
|
||||
argv: ["node", "clawhub", "--help"],
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
"https://registry.example",
|
||||
expect.objectContaining({ path: "/api/v1/whoami", token: "clh_admin" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides admin commands in help for non-admin tokens", async () => {
|
||||
mockReadGlobalConfig.mockResolvedValueOnce({
|
||||
registry: "https://registry.example",
|
||||
token: "clh_user",
|
||||
});
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
user: { handle: "p", role: "moderator" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
shouldShowAdminCommandsInHelp({
|
||||
argv: ["node", "clawhub", "--help"],
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readGlobalConfig } from "../config.js";
|
||||
import { apiRequest } from "../http.js";
|
||||
import { ApiRoutes, ApiV1WhoamiResponseSchema } from "../schema/index.js";
|
||||
import { DEFAULT_REGISTRY } from "./registry.js";
|
||||
|
||||
type AdminHelpDeps = {
|
||||
argv?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
export function isHelpRequest(argv: string[] = process.argv) {
|
||||
const args = argv.slice(2);
|
||||
return args.length === 0 || args[0] === "help" || args.includes("--help") || args.includes("-h");
|
||||
}
|
||||
|
||||
export async function shouldShowAdminCommandsInHelp(deps: AdminHelpDeps = {}) {
|
||||
if (!isHelpRequest(deps.argv)) return true;
|
||||
|
||||
const env = deps.env ?? process.env;
|
||||
const cfg = await readGlobalConfig();
|
||||
const token = cfg?.token?.trim();
|
||||
if (!token) return false;
|
||||
|
||||
const registry =
|
||||
readFlagValue(deps.argv ?? process.argv, "--registry")?.trim() ||
|
||||
env.CLAWHUB_REGISTRY?.trim() ||
|
||||
env.CLAWDHUB_REGISTRY?.trim() ||
|
||||
cfg?.registry?.trim() ||
|
||||
DEFAULT_REGISTRY;
|
||||
|
||||
try {
|
||||
const whoami = await apiRequest(
|
||||
registry,
|
||||
{ method: "GET", path: ApiRoutes.whoami, token },
|
||||
ApiV1WhoamiResponseSchema,
|
||||
);
|
||||
return whoami.user.role === "admin";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readFlagValue(argv: string[], flag: string) {
|
||||
const args = argv.slice(2);
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === flag) return args[index + 1];
|
||||
if (arg?.startsWith(`${flag}=`)) return arg.slice(flag.length + 1);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdRescanPackage, cmdRescanSkill } = await import("./rescan");
|
||||
|
||||
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
const response = {
|
||||
ok: true,
|
||||
targetKind: "skill",
|
||||
name: "demo",
|
||||
version: "1.2.3",
|
||||
status: "in_progress",
|
||||
remainingRequests: 2,
|
||||
maxRequests: 3,
|
||||
pendingRequestId: "rescanRequests:1",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLog.mockClear();
|
||||
});
|
||||
|
||||
describe("rescan commands", () => {
|
||||
it("requires --yes when input is disabled", async () => {
|
||||
await expect(cmdRescanSkill(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdRescanPackage(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts skill rescans to the skill rescan endpoint", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce(response);
|
||||
|
||||
await cmdRescanSkill(makeGlobalOpts(), "Demo", { yes: true }, false);
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/rescan" }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
|
||||
"OK. Requested skill rescan for demo@1.2.3 (2/3 remaining)",
|
||||
);
|
||||
});
|
||||
|
||||
it("posts package rescans to the package rescan endpoint", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
...response,
|
||||
targetKind: "package",
|
||||
name: "@scope/demo",
|
||||
});
|
||||
|
||||
await cmdRescanPackage(makeGlobalOpts(), "@scope/demo", { yes: true }, false);
|
||||
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/packages/%40scope%2Fdemo/rescan",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("prints JSON output", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce(response);
|
||||
|
||||
await cmdRescanSkill(makeGlobalOpts(), "demo", { yes: true, json: true }, false);
|
||||
|
||||
expect(uiMocks.spinner.stop).toHaveBeenCalled();
|
||||
expect(mockLog).toHaveBeenCalledWith(JSON.stringify(response, null, 2));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { apiRequest } from "../../http.js";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1RescanResponseSchema,
|
||||
parseArk,
|
||||
type ApiV1RescanResponse,
|
||||
} from "../../schema/index.js";
|
||||
import { requireAuthToken } from "../authToken.js";
|
||||
import { getRegistry } from "../registry.js";
|
||||
import type { GlobalOpts } from "../types.js";
|
||||
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from "../ui.js";
|
||||
|
||||
type RescanTargetKind = "skill" | "package";
|
||||
|
||||
type RescanOptions = {
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export async function cmdRescanSkill(
|
||||
opts: GlobalOpts,
|
||||
slugArg: string,
|
||||
options: RescanOptions,
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const slug = slugArg.trim().toLowerCase();
|
||||
if (!slug) fail("Slug required");
|
||||
return requestRescan(opts, "skill", slug, options, inputAllowed);
|
||||
}
|
||||
|
||||
export async function cmdRescanPackage(
|
||||
opts: GlobalOpts,
|
||||
nameArg: string,
|
||||
options: RescanOptions,
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const name = nameArg.trim();
|
||||
if (!name) fail("Package name required");
|
||||
return requestRescan(opts, "package", name, options, inputAllowed);
|
||||
}
|
||||
|
||||
async function requestRescan(
|
||||
opts: GlobalOpts,
|
||||
targetKind: RescanTargetKind,
|
||||
name: string,
|
||||
options: RescanOptions,
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false;
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail("Pass --yes (no input)");
|
||||
const ok = await promptConfirm(`Request latest ${targetKind} rescan for ${name}?`);
|
||||
if (!ok) return undefined;
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = createSpinner(`Requesting ${targetKind} rescan for ${name}`);
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: buildRescanPath(targetKind, name),
|
||||
token,
|
||||
},
|
||||
ApiV1RescanResponseSchema,
|
||||
);
|
||||
const parsed = parseArk(ApiV1RescanResponseSchema, result, "Rescan response");
|
||||
if (options.json) {
|
||||
spinner.stop();
|
||||
console.log(JSON.stringify(parsed, null, 2));
|
||||
} else {
|
||||
spinner.succeed(formatRescanSuccess(parsed));
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRescanPath(targetKind: RescanTargetKind, name: string) {
|
||||
const root = targetKind === "skill" ? ApiRoutes.skills : ApiRoutes.packages;
|
||||
return `${root}/${encodeURIComponent(name)}/rescan`;
|
||||
}
|
||||
|
||||
function formatRescanSuccess(result: ApiV1RescanResponse) {
|
||||
const label = result.targetKind === "skill" ? "skill" : "package";
|
||||
return `OK. Requested ${label} rescan for ${result.name}@${result.version} (${result.remainingRequests}/${result.maxRequests} remaining)`;
|
||||
}
|
||||
@@ -134,6 +134,7 @@ export const ApiV1WhoamiResponseSchema = type({
|
||||
handle: "string|null",
|
||||
displayName: "string|null?",
|
||||
image: "string|null?",
|
||||
role: '"admin"|"moderator"|"user"|null?',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -273,6 +274,18 @@ export const ApiV1DeleteResponseSchema = type({
|
||||
ok: "true",
|
||||
});
|
||||
|
||||
export const ApiV1RescanResponseSchema = type({
|
||||
ok: "true",
|
||||
targetKind: '"skill"|"package"',
|
||||
name: "string",
|
||||
version: "string",
|
||||
status: '"in_progress"|"completed"|"failed"',
|
||||
remainingRequests: "number",
|
||||
maxRequests: "number",
|
||||
pendingRequestId: "string?",
|
||||
});
|
||||
export type ApiV1RescanResponse = (typeof ApiV1RescanResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillRenameResponseSchema = type({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
|
||||
Vendored
+1
@@ -134,6 +134,7 @@ export declare const ApiV1WhoamiResponseSchema: import("arktype/internal/variant
|
||||
handle: string | null;
|
||||
displayName?: string | null | undefined;
|
||||
image?: string | null | undefined;
|
||||
role?: "user" | "admin" | "moderator" | null | undefined;
|
||||
};
|
||||
}, {}>;
|
||||
export declare const ApiV1UserSearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
|
||||
Vendored
+1
@@ -110,6 +110,7 @@ export const ApiV1WhoamiResponseSchema = type({
|
||||
handle: "string|null",
|
||||
displayName: "string|null?",
|
||||
image: "string|null?",
|
||||
role: '"admin"|"moderator"|"user"|null?',
|
||||
},
|
||||
});
|
||||
export const ApiV1UserSearchResponseSchema = type({
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -135,6 +135,7 @@ export const ApiV1WhoamiResponseSchema = type({
|
||||
handle: "string|null",
|
||||
displayName: "string|null?",
|
||||
image: "string|null?",
|
||||
role: '"admin"|"moderator"|"user"|null?',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -282,6 +283,18 @@ export const ApiV1DeleteResponseSchema = type({
|
||||
ok: "true",
|
||||
});
|
||||
|
||||
export const ApiV1RescanResponseSchema = type({
|
||||
ok: "true",
|
||||
targetKind: '"skill"|"package"',
|
||||
name: "string",
|
||||
version: "string",
|
||||
status: '"in_progress"|"completed"|"failed"',
|
||||
remainingRequests: "number",
|
||||
maxRequests: "number",
|
||||
pendingRequestId: "string?",
|
||||
});
|
||||
export type ApiV1RescanResponse = (typeof ApiV1RescanResponseSchema)[inferred];
|
||||
|
||||
export const ApiV1SkillRenameResponseSchema = type({
|
||||
ok: "true",
|
||||
slug: "string",
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const RESCAN_GUIDANCE_LABEL = "r: rescan-guidance";
|
||||
export const RESCAN_GUIDANCE_COMMENT_MARKER = "<!-- clawhub-rescan-guidance -->";
|
||||
export const SUPPRESS_LABEL = "skip-rescan-guidance";
|
||||
|
||||
const DEFAULT_REPO = "openclaw/clawhub";
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const APPLY_CONFIRM_ENV = "CLAWHUB_RESCAN_GUIDANCE_APPLY";
|
||||
|
||||
const explicitIntentRules = [
|
||||
{
|
||||
id: "explicit-rescan",
|
||||
pattern:
|
||||
/\b(?:re[-\s]?scan|rerun(?:ning)?\s+(?:the\s+)?(?:security\s+)?scan|re-run\s+(?:the\s+)?(?:security\s+)?scan|run\s+(?:the\s+)?(?:security\s+)?scan\s+again|scan\s+again)\b/i,
|
||||
},
|
||||
{
|
||||
id: "re-evaluation",
|
||||
pattern: /\b(?:re[-\s]?evaluat(?:e|ion)|reassess|re-assess|re[-\s]?review)\b/i,
|
||||
},
|
||||
{
|
||||
id: "reclassification",
|
||||
pattern:
|
||||
/\b(?:re[-\s]?classif(?:y|ication)|remove\s+(?:the\s+)?suspicious\s+flag|clear\s+(?:the\s+)?suspicious\s+flag|mark\s+(?:it\s+)?(?:as\s+)?(?:clean|benign))\b/i,
|
||||
},
|
||||
{
|
||||
id: "review-after-fix",
|
||||
pattern:
|
||||
/\b(?:security\s+flag\s+review|scan\s+flag\s+review|(?:request(?:ing)?|please)\s+(?:a\s+)?(?:manual\s+)?(?:review|security\s+review)\b[\s\S]{0,120}\b(?:after|fix(?:ed|es|ing)?|updated?|metadata|current\s+version|latest\s+version|new\s+version)|review\s+request\b[\s\S]{0,120}\b(?:after|fix(?:ed|es|ing)?|updated?|metadata|current\s+version|latest\s+version|new\s+version))\b/i,
|
||||
},
|
||||
{
|
||||
id: "fixed-and-still-flagged",
|
||||
pattern:
|
||||
/\b(?:(?:after|despite)\s+(?:fixing|fixes|metadata\s+fixes|clarifying|removing)|fix(?:ed|es)?\s+.*\b(?:still|yet)\s+.*\b(?:flagged|suspicious))\b/i,
|
||||
},
|
||||
];
|
||||
|
||||
const moderationContextRules = [
|
||||
{
|
||||
id: "clawhub-asset",
|
||||
pattern: /\b(?:skill|plugin|package|publisher|published|version|clawhub)\b/i,
|
||||
},
|
||||
{
|
||||
id: "moderation-signal",
|
||||
pattern:
|
||||
/\b(?:suspicious|flagged\s+(?:as\s+)?suspicious|security\s+scan|scanner|virustotal|vt\b|openclaw\s+verdict|moderation|malicious|benign|clean)\b/i,
|
||||
},
|
||||
];
|
||||
|
||||
const negativeContextRules = [
|
||||
{
|
||||
id: "auth-login",
|
||||
pattern: /\b(?:login|log\s+in|sign[-\s]?in|oauth|unauthorized|token|callback)\b/i,
|
||||
},
|
||||
{
|
||||
id: "install-rate-limit",
|
||||
pattern: /\b(?:install(?:ing)?|rate\s+limit|429|download|npx)\b/i,
|
||||
},
|
||||
{
|
||||
id: "search-indexing",
|
||||
pattern: /\b(?:search|indexed|indexing|explore|catalog|disappeared|hidden)\b/i,
|
||||
},
|
||||
];
|
||||
|
||||
export const rescanGuidanceComment = [
|
||||
RESCAN_GUIDANCE_COMMENT_MARKER,
|
||||
'Thanks for the report. Please use the "Rescan" button on the skill/plugin page while signed in as the owner.',
|
||||
"",
|
||||
"You can also request a fresh scan from the CLI:",
|
||||
"- Skill: `clawhub skill rescan <slug>`",
|
||||
"- Plugin/package: `clawhub package rescan <name>`",
|
||||
"",
|
||||
"If the content or metadata changed, publish the fixed version first, then request the rescan for the latest release. I'm closing this issue after posting this guidance. If you're still having trouble after rescanning, please reopen this issue with the ClawHub URL, version, and latest scan result.",
|
||||
].join("\n");
|
||||
|
||||
function normalizeLabel(label) {
|
||||
if (typeof label === "string") return label.trim().toLowerCase();
|
||||
if (label && typeof label.name === "string") return label.name.trim().toLowerCase();
|
||||
return "";
|
||||
}
|
||||
|
||||
function issueLabels(issue) {
|
||||
return Array.isArray(issue.labels) ? issue.labels.map(normalizeLabel).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function issueState(issue) {
|
||||
return String(issue.state ?? "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function issueText(issue) {
|
||||
return `${issue.title ?? ""}\n${issue.body ?? ""}`.trim();
|
||||
}
|
||||
|
||||
function matchingRuleIds(rules, text) {
|
||||
return rules.filter((rule) => rule.pattern.test(text)).map((rule) => rule.id);
|
||||
}
|
||||
|
||||
function commentHash(body) {
|
||||
return createHash("sha256").update(body).digest("hex");
|
||||
}
|
||||
|
||||
export function classifyRescanRequest(issue) {
|
||||
const labels = issueLabels(issue);
|
||||
const state = issueState(issue);
|
||||
const text = issueText(issue);
|
||||
|
||||
if (state && state !== "OPEN") {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because issue state is ${state.toLowerCase()}.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (issue.pull_request || issue.isPullRequest) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: "Skipped because this is a pull request.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (labels.includes(SUPPRESS_LABEL)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because ${SUPPRESS_LABEL} is present.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (labels.includes(RESCAN_GUIDANCE_LABEL)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because ${RESCAN_GUIDANCE_LABEL} is already present.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (/\b(?:false\s+duplicate|duplicate\s+flag|not\s+a\s+duplicate|duplicate\s+of)\b/i.test(text)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason:
|
||||
"Skipped because this looks like a duplicate-classification appeal, not a rescan request.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const explicitMatches = matchingRuleIds(explicitIntentRules, text);
|
||||
if (explicitMatches.length === 0) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: "No explicit rescan, re-evaluation, review, or reclassification request found.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const contextMatches = matchingRuleIds(moderationContextRules, text);
|
||||
if (contextMatches.length < moderationContextRules.length) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: explicitMatches,
|
||||
reason: "Explicit request found, but it lacks ClawHub asset and moderation/scan context.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const negativeMatches = matchingRuleIds(negativeContextRules, text);
|
||||
const hasStrongModerationLanguage =
|
||||
/\b(?:suspicious|flagged|virustotal|vt\b|malicious|benign|clean|security\s+scan|scanner|moderation)\b/i.test(
|
||||
text,
|
||||
);
|
||||
if (negativeMatches.length > 0 && !hasStrongModerationLanguage) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [...explicitMatches, ...contextMatches],
|
||||
reason: `Skipped because it looks like ${negativeMatches.join(", ")} support rather than a moderation rescan request.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const matchedRules = [...explicitMatches, ...contextMatches];
|
||||
return {
|
||||
matched: true,
|
||||
matchedRules,
|
||||
reason: `Explicit rescan guidance match: ${matchedRules.join(", ")}.`,
|
||||
actions: planRescanGuidanceActions(),
|
||||
};
|
||||
}
|
||||
|
||||
export function planRescanGuidanceActions() {
|
||||
return [
|
||||
{
|
||||
type: "add_label",
|
||||
label: RESCAN_GUIDANCE_LABEL,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function planCommentForLabeledIssue(issue) {
|
||||
const labels = issueLabels(issue);
|
||||
const state = issueState(issue);
|
||||
|
||||
if (state && state !== "OPEN") {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because issue state is ${state.toLowerCase()}.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (issue.pull_request || issue.isPullRequest) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: "Skipped because this is a pull request.",
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!labels.includes(RESCAN_GUIDANCE_LABEL)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedRules: [],
|
||||
reason: `Skipped because ${RESCAN_GUIDANCE_LABEL} is not present.`,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
matched: true,
|
||||
matchedRules: ["rescan-guidance-label"],
|
||||
reason: `Matched because ${RESCAN_GUIDANCE_LABEL} is present.`,
|
||||
actions: [
|
||||
{
|
||||
type: "comment",
|
||||
body: rescanGuidanceComment,
|
||||
bodySha256: commentHash(rescanGuidanceComment),
|
||||
},
|
||||
{
|
||||
type: "close",
|
||||
stateReason: "not_planned",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
repo: DEFAULT_REPO,
|
||||
limit: DEFAULT_LIMIT,
|
||||
issues: [],
|
||||
dryRun: true,
|
||||
json: true,
|
||||
commentForLabeledIssue: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--repo") {
|
||||
args.repo = requireValue(argv, (index += 1), "--repo");
|
||||
} else if (arg === "--limit") {
|
||||
args.limit = Number.parseInt(requireValue(argv, (index += 1), "--limit"), 10);
|
||||
} else if (arg === "--issue" || arg === "--item") {
|
||||
args.issues.push(Number.parseInt(requireValue(argv, (index += 1), arg), 10));
|
||||
} else if (arg === "--dry-run") {
|
||||
args.dryRun = true;
|
||||
} else if (arg === "--apply") {
|
||||
args.dryRun = false;
|
||||
} else if (arg === "--comment-for-labeled-issue") {
|
||||
args.commentForLabeledIssue = true;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
args.help = true;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isInteger(args.limit) || args.limit < 1) {
|
||||
throw new Error("--limit must be a positive integer.");
|
||||
}
|
||||
if (args.issues.some((issue) => !Number.isInteger(issue) || issue < 1)) {
|
||||
throw new Error("--issue values must be positive integers.");
|
||||
}
|
||||
if (args.commentForLabeledIssue && args.issues.length === 0) {
|
||||
throw new Error("--comment-for-labeled-issue requires --issue.");
|
||||
}
|
||||
if (!args.dryRun && process.env[APPLY_CONFIRM_ENV] !== "1") {
|
||||
throw new Error(`--apply requires ${APPLY_CONFIRM_ENV}=1.`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function requireValue(argv, index, flag) {
|
||||
const value = argv[index];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`${flag} requires a value.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ghJson(args) {
|
||||
const stdout = execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
function gh(args, input) {
|
||||
execFileSync("gh", args, {
|
||||
encoding: "utf8",
|
||||
input,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
stdio: input === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function ghOk(args) {
|
||||
try {
|
||||
gh(args);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function labelApiName(label) {
|
||||
return encodeURIComponent(label);
|
||||
}
|
||||
|
||||
function ensureGuidanceLabel(repo) {
|
||||
if (ghOk(["api", `repos/${repo}/labels/${labelApiName(RESCAN_GUIDANCE_LABEL)}`])) return;
|
||||
const created = ghOk([
|
||||
"label",
|
||||
"create",
|
||||
RESCAN_GUIDANCE_LABEL,
|
||||
"--repo",
|
||||
repo,
|
||||
"--color",
|
||||
"bfdadc",
|
||||
"--description",
|
||||
"Rescan guidance has been posted for this ClawHub item",
|
||||
]);
|
||||
if (!created && !ghOk(["api", `repos/${repo}/labels/${labelApiName(RESCAN_GUIDANCE_LABEL)}`])) {
|
||||
throw new Error(`Could not create or find label: ${RESCAN_GUIDANCE_LABEL}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGhIssue(issue) {
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title ?? "",
|
||||
body: issue.body ?? "",
|
||||
state: issue.state ?? "",
|
||||
url: issue.url ?? issue.html_url ?? "",
|
||||
labels: issue.labels ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchIssues(options) {
|
||||
if (options.issues.length > 0) {
|
||||
return options.issues.map((issueNumber) =>
|
||||
normalizeGhIssue(
|
||||
ghJson([
|
||||
"issue",
|
||||
"view",
|
||||
String(issueNumber),
|
||||
"--repo",
|
||||
options.repo,
|
||||
"--json",
|
||||
"number,title,body,state,url,labels",
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ghJson([
|
||||
"issue",
|
||||
"list",
|
||||
"--repo",
|
||||
options.repo,
|
||||
"--state",
|
||||
"open",
|
||||
"--limit",
|
||||
String(options.limit),
|
||||
"--json",
|
||||
"number,title,body,state,url,labels",
|
||||
]).map(normalizeGhIssue);
|
||||
}
|
||||
|
||||
export function planIssue(issue) {
|
||||
const classification = classifyRescanRequest(issue);
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
matched: classification.matched,
|
||||
matchedRules: classification.matchedRules,
|
||||
reason: classification.reason,
|
||||
actions: classification.actions,
|
||||
};
|
||||
}
|
||||
|
||||
function writeCommentPayload(plan) {
|
||||
const commentAction = plan.actions.find((action) => action.type === "comment");
|
||||
if (!commentAction) return null;
|
||||
return JSON.stringify({ body: commentAction.body });
|
||||
}
|
||||
|
||||
function hasExistingGuidanceComment(repo, number) {
|
||||
const comments = ghJson([
|
||||
"api",
|
||||
`repos/${repo}/issues/${number}/comments?per_page=100`,
|
||||
"--jq",
|
||||
`[.[] | {body}]`,
|
||||
]);
|
||||
return comments.some((comment) =>
|
||||
String(comment.body ?? "").includes(RESCAN_GUIDANCE_COMMENT_MARKER),
|
||||
);
|
||||
}
|
||||
|
||||
function applyPlan(plan, options) {
|
||||
if (!plan.matched) return { number: plan.number, applied: false, reason: plan.reason };
|
||||
if (plan.actions.some((action) => action.type === "add_label")) {
|
||||
ensureGuidanceLabel(options.repo);
|
||||
}
|
||||
const existingGuidanceComment = hasExistingGuidanceComment(options.repo, plan.number);
|
||||
const appliedActions = [];
|
||||
for (const action of plan.actions) {
|
||||
if (action.type === "add_label") {
|
||||
gh([
|
||||
"api",
|
||||
`repos/${options.repo}/issues/${plan.number}/labels`,
|
||||
"--method",
|
||||
"POST",
|
||||
"--field",
|
||||
`labels[]=${action.label}`,
|
||||
]);
|
||||
appliedActions.push(action.type);
|
||||
} else if (action.type === "comment") {
|
||||
if (existingGuidanceComment) continue;
|
||||
gh(
|
||||
[
|
||||
"api",
|
||||
`repos/${options.repo}/issues/${plan.number}/comments`,
|
||||
"--method",
|
||||
"POST",
|
||||
"--input",
|
||||
"-",
|
||||
],
|
||||
writeCommentPayload(plan),
|
||||
);
|
||||
appliedActions.push(action.type);
|
||||
} else if (action.type === "close") {
|
||||
gh(
|
||||
["api", `repos/${options.repo}/issues/${plan.number}`, "--method", "PATCH", "--input", "-"],
|
||||
JSON.stringify({ state: "closed", state_reason: action.stateReason ?? "not_planned" }),
|
||||
);
|
||||
appliedActions.push(action.type);
|
||||
}
|
||||
}
|
||||
return {
|
||||
number: plan.number,
|
||||
applied: appliedActions.length > 0,
|
||||
actions: appliedActions,
|
||||
skippedComment: existingGuidanceComment,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSummary(plans, options) {
|
||||
const matches = plans.filter((plan) => plan.matched);
|
||||
const lines = [
|
||||
`ClawHub rescan auto-response ${options.dryRun ? "dry run" : "apply run"} for ${options.repo}`,
|
||||
`Scanned ${plans.length} issue(s); matched ${matches.length}.`,
|
||||
];
|
||||
for (const plan of matches) {
|
||||
lines.push(`- #${plan.number}: ${plan.title}`);
|
||||
lines.push(` ${plan.url}`);
|
||||
lines.push(` rules: ${plan.matchedRules.join(", ")}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function helpText() {
|
||||
return [
|
||||
"Usage: bun scripts/github/clawhub-rescan-auto-response.mjs [options]",
|
||||
"",
|
||||
"Options:",
|
||||
" --repo <owner/repo> Repository to inspect. Default: openclaw/clawhub",
|
||||
" --limit <n> Number of open issues to scan. Default: 100",
|
||||
" --issue <n> Inspect one issue number. Repeatable.",
|
||||
" --dry-run Preview only. Default.",
|
||||
" --comment-for-labeled-issue",
|
||||
` Post guidance only when ${RESCAN_GUIDANCE_LABEL} is already present.`,
|
||||
` --apply Add the label and guidance comment. Requires ${APPLY_CONFIRM_ENV}=1.`,
|
||||
" --help Show this help.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function runCli(argv = process.argv.slice(2)) {
|
||||
const options = parseArgs(argv);
|
||||
if (options.help) {
|
||||
console.log(helpText());
|
||||
return;
|
||||
}
|
||||
const issues = await fetchIssues(options);
|
||||
const plans = options.commentForLabeledIssue
|
||||
? issues.map((issue) => {
|
||||
const classification = planCommentForLabeledIssue(issue);
|
||||
return {
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
matched: classification.matched,
|
||||
matchedRules: classification.matchedRules,
|
||||
reason: classification.reason,
|
||||
actions: classification.actions,
|
||||
};
|
||||
})
|
||||
: issues.map(planIssue);
|
||||
const applyResults = options.dryRun ? [] : plans.map((plan) => applyPlan(plan, options));
|
||||
console.error(renderSummary(plans, options));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
repo: options.repo,
|
||||
dryRun: options.dryRun,
|
||||
scanned: plans.length,
|
||||
matched: plans.filter((plan) => plan.matched).length,
|
||||
applied: applyResults.filter((result) => result.applied).length,
|
||||
applyResults,
|
||||
plans,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] === currentFile) {
|
||||
runCli().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
RESCAN_GUIDANCE_COMMENT_MARKER,
|
||||
RESCAN_GUIDANCE_LABEL,
|
||||
SUPPRESS_LABEL,
|
||||
classifyRescanRequest,
|
||||
planCommentForLabeledIssue,
|
||||
planIssue,
|
||||
rescanGuidanceComment,
|
||||
} from "./clawhub-rescan-auto-response.mjs";
|
||||
|
||||
const issue = (overrides) => ({
|
||||
number: 1,
|
||||
title: "placeholder",
|
||||
body: "",
|
||||
state: "OPEN",
|
||||
url: "https://github.com/openclaw/clawhub/issues/1",
|
||||
labels: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("clawhub rescan auto-response classifier", () => {
|
||||
it.each([
|
||||
[
|
||||
1553,
|
||||
"Re-scan jarviyin/clawpk v5.0.0 - remove suspicious flag",
|
||||
"Please re-run the security scan on v5.0.0 and remove the suspicious flag. The package no longer contains any patterns that should trigger it.",
|
||||
],
|
||||
[
|
||||
1834,
|
||||
"feishu-team-manager: Request re-scan after fixing flagged issues (v2.4.3)",
|
||||
"The skill fixed credentials and Unicode control characters. Please re-run the security scan on v2.4.3.",
|
||||
],
|
||||
[
|
||||
1808,
|
||||
"Re-evaluation request: topview-skill (official Topview AI client) - medium-suspicious verdict triggered by emoji ZWJ false positive",
|
||||
"Please re-scan at the current commit and reclassify as Benign. The suspicious scan findings have been fixed.",
|
||||
],
|
||||
[
|
||||
1671,
|
||||
'Request for Security Re-evaluation: "book-companion" skill marked as suspicious',
|
||||
"I have proactively audited the skill and implemented compliance measures. Please review the updated documentation and clear the suspicious flag.",
|
||||
],
|
||||
])("matches explicit rescan/re-evaluation request #%s", (number, title, body) => {
|
||||
const result = classifyRescanRequest(issue({ number, title, body }));
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.matchedRules.length).toBeGreaterThanOrEqual(3);
|
||||
expect(result.actions).toEqual([{ type: "add_label", label: RESCAN_GUIDANCE_LABEL }]);
|
||||
expect(rescanGuidanceComment).toContain(RESCAN_GUIDANCE_COMMENT_MARKER);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
589,
|
||||
"Rate limit exceeded when installing clawhub",
|
||||
"npx clawhub@latest install sonoscli returns Rate limit exceeded. Is this not getting fixed?",
|
||||
],
|
||||
[
|
||||
100,
|
||||
"CLI: Auth fails due to redirect from clawhub.ai to www.clawhub.ai",
|
||||
"The clawhub CLI fails to authenticate because a redirect loses the Authorization header.",
|
||||
],
|
||||
[
|
||||
758,
|
||||
"False positive: create-project skill flagged as suspicious by VirusTotal",
|
||||
"The create-project skill has been flagged as suspicious. This appears to be the same class of false positive as other issues.",
|
||||
],
|
||||
[
|
||||
256,
|
||||
"False positive: clawarr-suite flagged as suspicious",
|
||||
"Please review and unflag. All patterns are standard for a media server management tool.",
|
||||
],
|
||||
[
|
||||
1514,
|
||||
"False duplicate flag: claude-to-free is not a duplicate of model-migration",
|
||||
"This skill is not a duplicate. Please remove the duplicate flag.",
|
||||
],
|
||||
])("does not match non-rescan issue #%s", (number, title, body) => {
|
||||
const result = classifyRescanRequest(issue({ number, title, body }));
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.actions).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips closed issues", () => {
|
||||
const result = classifyRescanRequest(
|
||||
issue({
|
||||
state: "CLOSED",
|
||||
title: "Re-scan example skill",
|
||||
body: "Please re-run the security scan and remove the suspicious flag.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.reason).toContain("closed");
|
||||
});
|
||||
|
||||
it("skips pull requests", () => {
|
||||
const result = classifyRescanRequest(
|
||||
issue({
|
||||
isPullRequest: true,
|
||||
title: "Re-scan example skill",
|
||||
body: "Please re-run the security scan and remove the suspicious flag.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.reason).toContain("pull request");
|
||||
});
|
||||
|
||||
it("skips suppressed and already-handled issues", () => {
|
||||
expect(
|
||||
classifyRescanRequest(
|
||||
issue({
|
||||
labels: [{ name: SUPPRESS_LABEL }],
|
||||
title: "Re-scan example skill",
|
||||
body: "Please re-run the security scan and remove the suspicious flag.",
|
||||
}),
|
||||
).reason,
|
||||
).toContain(SUPPRESS_LABEL);
|
||||
|
||||
expect(
|
||||
classifyRescanRequest(
|
||||
issue({
|
||||
labels: [{ name: RESCAN_GUIDANCE_LABEL }],
|
||||
title: "Re-scan example skill",
|
||||
body: "Please re-run the security scan and remove the suspicious flag.",
|
||||
}),
|
||||
).reason,
|
||||
).toContain(RESCAN_GUIDANCE_LABEL);
|
||||
});
|
||||
|
||||
it("plans dry-run rows for matched issues", () => {
|
||||
const plan = planIssue(
|
||||
issue({
|
||||
number: 1834,
|
||||
title: "feishu-team-manager: Request re-scan after fixing flagged issues (v2.4.3)",
|
||||
body: "This skill has fixed flagged metadata issues. Please re-run the security scan on v2.4.3.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
number: 1834,
|
||||
matched: true,
|
||||
actions: [{ type: "add_label", label: RESCAN_GUIDANCE_LABEL }],
|
||||
});
|
||||
});
|
||||
|
||||
it("plans comments only for issues already labeled for guidance", () => {
|
||||
const plan = planCommentForLabeledIssue(
|
||||
issue({
|
||||
labels: [{ name: RESCAN_GUIDANCE_LABEL }],
|
||||
title: "False positive: example skill flagged as suspicious",
|
||||
body: "Please re-run the security scan.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
matched: true,
|
||||
matchedRules: ["rescan-guidance-label"],
|
||||
actions: [
|
||||
{
|
||||
type: "comment",
|
||||
body: rescanGuidanceComment,
|
||||
bodySha256: expect.any(String),
|
||||
},
|
||||
{
|
||||
type: "close",
|
||||
stateReason: "not_planned",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(rescanGuidanceComment).toContain("reopen this issue");
|
||||
});
|
||||
|
||||
it("does not plan comments without the guidance label", () => {
|
||||
const plan = planCommentForLabeledIssue(
|
||||
issue({
|
||||
title: "False positive: example skill flagged as suspicious",
|
||||
body: "Please re-run the security scan.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
matched: false,
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
+287
-128
@@ -1,193 +1,352 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import Header from "../components/Header";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type HeaderAuthStatus = {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
me: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const siteModeMock = vi.fn(() => "souls");
|
||||
const navigateMock = vi.fn();
|
||||
const { useUnifiedSearchMock } = vi.hoisted(() => ({
|
||||
useUnifiedSearchMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const defaultUnifiedSearchResult = {
|
||||
results: [],
|
||||
skillResults: [
|
||||
{
|
||||
type: "skill",
|
||||
ownerHandle: "local",
|
||||
score: 10,
|
||||
skill: {
|
||||
_id: "skills:weather",
|
||||
slug: "weather",
|
||||
displayName: "Weather Skill",
|
||||
ownerUserId: "users:local",
|
||||
stats: { downloads: 1, stars: 2 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
pluginResults: [
|
||||
{
|
||||
type: "plugin",
|
||||
plugin: {
|
||||
name: "weather-plugin",
|
||||
displayName: "Weather Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
summary: "Plugin weather tools.",
|
||||
ownerHandle: "local",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
latestVersion: "1.0.0",
|
||||
capabilityTags: [],
|
||||
executesCode: true,
|
||||
verificationTier: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
skillCount: 1,
|
||||
pluginCount: 1,
|
||||
isSearching: false,
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: (props: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
hash?: string;
|
||||
to?: string;
|
||||
}) => (
|
||||
<a
|
||||
href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`}
|
||||
className={props.className}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/" }),
|
||||
useNavigate: () => navigateMock,
|
||||
Link: (props: { children: ReactNode; className?: string; hash?: string; to?: string }) => (
|
||||
<a href={`${props.to ?? "/"}${props.hash ? `#${props.hash}` : ""}`} className={props.className}>
|
||||
{props.children}
|
||||
</a>
|
||||
),
|
||||
useLocation: () => ({ pathname: "/" }),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("@convex-dev/auth/react", () => ({
|
||||
useAuthActions: () => ({
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}),
|
||||
useAuthActions: () => ({
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const authStatusMock = vi.fn(() => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
const authStatusMock = vi.fn<() => HeaderAuthStatus>(() => ({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => authStatusMock(),
|
||||
useAuthStatus: () => authStatusMock(),
|
||||
}));
|
||||
|
||||
const setThemeMock = vi.fn();
|
||||
const setModeMock = vi.fn();
|
||||
|
||||
vi.mock("../lib/theme", () => ({
|
||||
applyTheme: vi.fn(),
|
||||
THEME_OPTIONS: [
|
||||
{ value: "claw", label: "Claw", description: "" },
|
||||
{ value: "hub", label: "Hub", description: "" },
|
||||
],
|
||||
useThemeMode: () => ({
|
||||
theme: "hub",
|
||||
mode: "system",
|
||||
setTheme: setThemeMock,
|
||||
setMode: setModeMock,
|
||||
}),
|
||||
applyTheme: vi.fn(),
|
||||
THEME_OPTIONS: [
|
||||
{ value: "claw", label: "Claw", description: "" },
|
||||
{ value: "hub", label: "Hub", description: "" },
|
||||
],
|
||||
useThemeMode: () => ({
|
||||
theme: "hub",
|
||||
mode: "system",
|
||||
setTheme: setThemeMock,
|
||||
setMode: setModeMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/theme-transition", () => ({
|
||||
startThemeTransition: ({
|
||||
setTheme,
|
||||
nextTheme,
|
||||
}: {
|
||||
setTheme: (value: string) => void;
|
||||
nextTheme: string;
|
||||
}) => setTheme(nextTheme),
|
||||
startThemeTransition: ({
|
||||
setTheme,
|
||||
nextTheme,
|
||||
}: {
|
||||
setTheme: (value: string) => void;
|
||||
nextTheme: string;
|
||||
}) => setTheme(nextTheme),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthError", () => ({
|
||||
setAuthError: vi.fn(),
|
||||
useAuthError: () => ({
|
||||
error: null,
|
||||
clear: vi.fn(),
|
||||
}),
|
||||
setAuthError: vi.fn(),
|
||||
useAuthError: () => ({
|
||||
error: null,
|
||||
clear: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/roles", () => ({
|
||||
isModerator: () => false,
|
||||
isModerator: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/site", () => ({
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
getSiteMode: () => siteModeMock(),
|
||||
getSiteName: () => "OnlyCrabs",
|
||||
getClawHubSiteUrl: () => "https://clawhub.ai",
|
||||
getSiteMode: () => siteModeMock(),
|
||||
getSiteName: () => "OnlyCrabs",
|
||||
}));
|
||||
|
||||
vi.mock("../lib/gravatar", () => ({
|
||||
gravatarUrl: vi.fn(),
|
||||
gravatarUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useUnifiedSearch", () => ({
|
||||
useUnifiedSearch: () => useUnifiedSearchMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/toggle-group", () => ({
|
||||
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
ToggleGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ToggleGroupItem: ({ children }: { children: ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import Header from "../components/Header";
|
||||
|
||||
describe("Header", () => {
|
||||
it("hides Packages navigation in soul mode on mobile and desktop", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
beforeEach(() => {
|
||||
authStatusMock.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
});
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
useUnifiedSearchMock.mockReturnValue(defaultUnifiedSearchResult);
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
it("hides Packages navigation in soul mode on mobile and desktop", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
render(<Header />);
|
||||
|
||||
it("renders direct desktop theme family controls and plain Skills tab", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
setThemeMock.mockClear();
|
||||
setModeMock.mockClear();
|
||||
expect(screen.queryByText("Packages")).toBeNull();
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
it("renders simplified desktop nav and theme toggle", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
setThemeMock.mockClear();
|
||||
setModeMock.mockClear();
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Cycle theme mode/i }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(1);
|
||||
expect(
|
||||
screen.getByPlaceholderText("Search skills, plugins, users"),
|
||||
).toBeTruthy();
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Cycle theme mode/i }));
|
||||
expect(setModeMock).toHaveBeenCalledWith("light");
|
||||
expect(screen.getByRole("button", { name: /Toggle theme\. Current: system/i })).toBeTruthy();
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Plugins")).toHaveLength(1);
|
||||
expect(screen.queryByText("Users")).toBeNull();
|
||||
expect(screen.queryByText("Dashboard")).toBeNull();
|
||||
expect(screen.queryByText("Manage")).toBeNull();
|
||||
expect(screen.getByPlaceholderText("Search skills and plugins")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Toggle theme\. Current: system/i }));
|
||||
expect(setModeMock).toHaveBeenCalledWith("dark");
|
||||
|
||||
expect(screen.getAllByText("Home")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Users")).toHaveLength(2);
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
it("shows Home above Skills in the mobile menu", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
expect(screen.getAllByText("Home")).toHaveLength(1);
|
||||
expect(screen.getAllByText("Skills")).toHaveLength(2);
|
||||
expect(screen.getAllByText("Plugins")).toHaveLength(2);
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
it("shows grouped skills and plugins typeahead without users", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
navigateMock.mockReset();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
render(<Header />);
|
||||
|
||||
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "weather" } });
|
||||
|
||||
const labels = Array.from(
|
||||
document.querySelectorAll(".mobile-nav-section .mobile-nav-link"),
|
||||
)
|
||||
.map((element) => element.textContent?.trim())
|
||||
.filter((label): label is string => Boolean(label));
|
||||
const typeahead = screen.getByRole("listbox");
|
||||
expect(within(typeahead).getByText("Skills")).toBeTruthy();
|
||||
expect(screen.getByText("Weather Skill")).toBeTruthy();
|
||||
expect(within(typeahead).getByText("Plugins")).toBeTruthy();
|
||||
expect(screen.getByText("Weather Plugin")).toBeTruthy();
|
||||
expect(within(typeahead).queryByText("Users")).toBeNull();
|
||||
expect(within(typeahead).queryByText('See user results for "weather"')).toBeNull();
|
||||
|
||||
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
|
||||
});
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
it("routes soul-mode header searches to the souls browse page", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
navigateMock.mockReset();
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/search",
|
||||
search: { q: "weather", type: "skills" },
|
||||
});
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
it("falls back to typed skill search when a typeahead skill has no owner handle", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
navigateMock.mockReset();
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
...defaultUnifiedSearchResult,
|
||||
skillResults: [
|
||||
{
|
||||
...defaultUnifiedSearchResult.skillResults[0],
|
||||
ownerHandle: null,
|
||||
skill: {
|
||||
...defaultUnifiedSearchResult.skillResults[0].skill,
|
||||
ownerUserId: "users:opaque-id",
|
||||
ownerPublisherId: "publishers:opaque-id",
|
||||
},
|
||||
},
|
||||
],
|
||||
pluginResults: [],
|
||||
pluginCount: 0,
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
|
||||
target: { value: "angler" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
|
||||
render(<Header />);
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/souls",
|
||||
search: {
|
||||
q: "angler",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "weather" } });
|
||||
fireEvent.click(screen.getByRole("option", { name: /Weather Skill/i }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/search",
|
||||
search: { q: "weather", type: "skills" },
|
||||
});
|
||||
expect(navigateMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/publishers%3Aopaque-id/weather",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a single no-results state without section footers", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
results: [],
|
||||
skillResults: [],
|
||||
pluginResults: [],
|
||||
skillCount: 0,
|
||||
pluginCount: 0,
|
||||
isSearching: false,
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins");
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: "zzzz" } });
|
||||
|
||||
const typeahead = screen.getByRole("listbox");
|
||||
expect(within(typeahead).getByText('No skills or plugins found for "zzzz"')).toBeTruthy();
|
||||
expect(within(typeahead).queryByText("Skills")).toBeNull();
|
||||
expect(within(typeahead).queryByText("Plugins")).toBeNull();
|
||||
expect(within(typeahead).queryByText('See skill results for "zzzz"')).toBeNull();
|
||||
expect(within(typeahead).queryByText('See plugin results for "zzzz"')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows Home above Skills in the mobile menu", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
|
||||
expect(document.querySelector(".mobile-nav-brand-mark-image")).toBeTruthy();
|
||||
|
||||
const labels = Array.from(document.querySelectorAll(".mobile-nav-section .mobile-nav-link"))
|
||||
.map((element) => element.textContent?.trim())
|
||||
.filter((label): label is string => Boolean(label));
|
||||
|
||||
expect(labels.slice(0, 2)).toEqual(["Home", "Skills"]);
|
||||
});
|
||||
|
||||
it("keeps Stars out of signed-in header navigation", () => {
|
||||
siteModeMock.mockReturnValue("skills");
|
||||
authStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: {
|
||||
displayName: "Patrick",
|
||||
email: "patrick@example.com",
|
||||
handle: "patrick",
|
||||
image: null,
|
||||
name: "Patrick",
|
||||
},
|
||||
});
|
||||
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.queryByText("Stars")).toBeNull();
|
||||
expect(screen.getAllByText("Dashboard").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Settings")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("routes soul-mode header searches to the souls browse page", () => {
|
||||
siteModeMock.mockReturnValue("souls");
|
||||
navigateMock.mockReset();
|
||||
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search souls..."), {
|
||||
target: { value: "angler" },
|
||||
});
|
||||
fireEvent.submit(screen.getByRole("search", { name: "Site search" }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/souls",
|
||||
search: {
|
||||
q: "angler",
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,17 +15,17 @@ const isRateLimitedPackageApiErrorMock = vi.fn(
|
||||
(error: unknown) =>
|
||||
typeof error === "object" && error !== null && (error as { status?: number }).status === 429,
|
||||
);
|
||||
const useQueryMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
|
||||
type PluginDetailLoaderData = {
|
||||
detail: PackageDetailResponse;
|
||||
version: PackageVersionDetail | null;
|
||||
readme: string | null;
|
||||
rateLimited:
|
||||
| {
|
||||
scope: "detail" | "metadata";
|
||||
retryAfterSeconds: number | null;
|
||||
}
|
||||
| null;
|
||||
rateLimited: {
|
||||
scope: "detail" | "metadata";
|
||||
retryAfterSeconds: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
let paramsMock = { name: "demo-plugin" };
|
||||
@@ -59,6 +59,14 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
useParams: () => paramsMock,
|
||||
useLoaderData: () => loaderDataMock,
|
||||
}),
|
||||
useRouterState: ({
|
||||
select,
|
||||
}: {
|
||||
select?: (state: { location: { pathname: string } }) => string;
|
||||
}) =>
|
||||
select
|
||||
? select({ location: { pathname: `/plugins/${paramsMock.name}` } })
|
||||
: `/plugins/${paramsMock.name}`,
|
||||
Link: ({
|
||||
children,
|
||||
to,
|
||||
@@ -73,6 +81,15 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("convex/react", () => ({
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
useMutation: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useAuthStatus", () => ({
|
||||
useAuthStatus: () => useAuthStatusMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/packageApi", () => ({
|
||||
fetchPackageDetail: vi.fn(),
|
||||
fetchPackageReadme: vi.fn(),
|
||||
@@ -86,7 +103,13 @@ vi.mock("../lib/packageApi", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../components/MarkdownPreview", () => ({
|
||||
MarkdownPreview: ({ children }: { children: string; className?: string; highlight?: boolean }) => <div>{children}</div>,
|
||||
MarkdownPreview: ({
|
||||
children,
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
highlight?: boolean;
|
||||
}) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
@@ -128,6 +151,14 @@ describe("plugin detail route", () => {
|
||||
rateLimited: null,
|
||||
};
|
||||
isRateLimitedPackageApiErrorMock.mockClear();
|
||||
useQueryMock.mockReset();
|
||||
useQueryMock.mockReturnValue(undefined);
|
||||
useAuthStatusMock.mockReset();
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
me: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("hides download actions when the plugin has no latest release", async () => {
|
||||
@@ -188,9 +219,66 @@ describe("plugin detail route", () => {
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByText("Security Scan")).toBeTruthy();
|
||||
expect(screen.getByText("Security Scans")).toBeTruthy();
|
||||
expect(screen.getAllByText("VirusTotal").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("OpenClaw").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("ClawScan").length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("link", { name: /VirusTotal.*Benign/i }).getAttribute("href")).toBe(
|
||||
"/plugins/demo-plugin/security/virustotal",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Static analysis.*Benign/i }).getAttribute("href"),
|
||||
).toBe("/plugins/demo-plugin/security/static-analysis");
|
||||
});
|
||||
|
||||
it("shows owner-only plugin rescan state in the security summary", async () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: "users:1" },
|
||||
});
|
||||
useQueryMock.mockReturnValue({
|
||||
maxRequests: 3,
|
||||
requestCount: 1,
|
||||
remainingRequests: 2,
|
||||
canRequest: true,
|
||||
inProgressRequest: null,
|
||||
latestRequest: null,
|
||||
});
|
||||
loaderDataMock = {
|
||||
detail: loaderDataMock.detail,
|
||||
version: {
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "Initial release",
|
||||
distTags: ["latest"],
|
||||
files: [],
|
||||
compatibility: null,
|
||||
capabilities: null,
|
||||
verification: null,
|
||||
sha256hash: "a".repeat(64),
|
||||
vtAnalysis: null,
|
||||
llmAnalysis: null,
|
||||
staticScan: null,
|
||||
},
|
||||
},
|
||||
readme: null,
|
||||
rateLimited: null,
|
||||
};
|
||||
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Rescan" })).toBeTruthy();
|
||||
expect(screen.queryByText("Owner rescan")).toBeNull();
|
||||
expect(screen.queryByText("2/3 rescans left")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a retryable empty state when the detail lookup is rate limited", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchPluginCatalogMock = vi.fn();
|
||||
const fetchFeaturedPluginsMock = vi.fn();
|
||||
const isRateLimitedPackageApiErrorMock = vi.fn(
|
||||
(error: unknown) =>
|
||||
typeof error === "object" && error !== null && (error as { status?: number }).status === 429,
|
||||
@@ -57,6 +58,10 @@ vi.mock("../lib/packageApi", () => ({
|
||||
isRateLimitedPackageApiError: (error: unknown) => isRateLimitedPackageApiErrorMock(error),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/featuredCatalog", () => ({
|
||||
fetchFeaturedPlugins: (...args: unknown[]) => fetchFeaturedPluginsMock(...args),
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/plugins/index")).Route as unknown as {
|
||||
__config: {
|
||||
@@ -70,6 +75,7 @@ async function loadRoute() {
|
||||
describe("plugins route", () => {
|
||||
beforeEach(() => {
|
||||
fetchPluginCatalogMock.mockReset();
|
||||
fetchFeaturedPluginsMock.mockReset();
|
||||
isRateLimitedPackageApiErrorMock.mockClear();
|
||||
navigateMock.mockReset();
|
||||
searchMock = {};
|
||||
@@ -92,6 +98,7 @@ describe("plugins route", () => {
|
||||
family: undefined,
|
||||
q: "demo",
|
||||
cursor: undefined,
|
||||
featured: undefined,
|
||||
verified: undefined,
|
||||
executesCode: undefined,
|
||||
});
|
||||
@@ -211,12 +218,29 @@ describe("plugins route", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("selects featured from the sort group", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Featured" }));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
};
|
||||
expect(lastCall.search({ family: "code-plugin", cursor: "cursor:current" })).toEqual({
|
||||
family: undefined,
|
||||
cursor: undefined,
|
||||
featured: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a retryable empty state when the catalog is rate limited", async () => {
|
||||
fetchPluginCatalogMock.mockRejectedValue({ status: 429, retryAfterSeconds: 22 });
|
||||
const route = await loadRoute();
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
const loader = route.__config.loader as (args: { deps: Record<string, unknown> }) => Promise<{
|
||||
items: Array<{ name: string }>;
|
||||
nextCursor: string | null;
|
||||
rateLimited: boolean;
|
||||
@@ -237,9 +261,7 @@ describe("plugins route", () => {
|
||||
it("flags API errors for filtered catalog requests", async () => {
|
||||
fetchPluginCatalogMock.mockRejectedValue(new Error("boom"));
|
||||
const route = await loadRoute();
|
||||
const loader = route.__config.loader as (args: {
|
||||
deps: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
const loader = route.__config.loader as (args: { deps: Record<string, unknown> }) => Promise<{
|
||||
items: Array<{ name: string }>;
|
||||
nextCursor: string | null;
|
||||
rateLimited: boolean;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL ?? "https://example.convex.cloud";
|
||||
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convex: {},
|
||||
convexHttp: { query: vi.fn() },
|
||||
@@ -51,10 +50,10 @@ describe("search route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the users type filter", () => {
|
||||
it("ignores the users type filter", () => {
|
||||
expect(runValidateSearch({ q: "vincent", type: "users" })).toEqual({
|
||||
q: "vincent",
|
||||
type: "users",
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,24 +5,23 @@ import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" | "users" } = {};
|
||||
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" } = {};
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute:
|
||||
() =>
|
||||
(config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useUnifiedSearch", () => ({
|
||||
useUnifiedSearch: () => ({
|
||||
results: [],
|
||||
skillResults: [],
|
||||
pluginResults: [],
|
||||
skillCount: 0,
|
||||
pluginCount: 0,
|
||||
userCount: 0,
|
||||
isSearching: false,
|
||||
}),
|
||||
}));
|
||||
@@ -35,10 +34,6 @@ vi.mock("../components/SkillListItem", () => ({
|
||||
SkillListItem: ({ skill }: { skill: { slug: string } }) => <div>{skill.slug}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/UserListItem", () => ({
|
||||
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/card", () => ({
|
||||
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
@@ -64,7 +59,7 @@ describe("search route", () => {
|
||||
const Component = route.__config.component as ComponentType;
|
||||
const rendered = render(<Component />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement;
|
||||
const input = screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement;
|
||||
expect(input.value).toBe("first");
|
||||
|
||||
fireEvent.change(input, { target: { value: "draft" } });
|
||||
@@ -74,7 +69,16 @@ describe("search route", () => {
|
||||
rendered.rerender(<Component />);
|
||||
|
||||
expect(
|
||||
(screen.getByPlaceholderText("Search skills, plugins, users...") as HTMLInputElement).value,
|
||||
(screen.getByPlaceholderText("Search skills and plugins...") as HTMLInputElement).value,
|
||||
).toBe("second");
|
||||
});
|
||||
|
||||
it("does not render a public users search tab", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /users/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("SkillDetailPage", () => {
|
||||
});
|
||||
|
||||
render(<SkillDetailPage slug="weather" />);
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
|
||||
expect(screen.getByRole("status", { name: /Loading skill details/i })).toBeTruthy();
|
||||
expect(screen.queryByText(/Skill not found/i)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -222,18 +222,105 @@ describe("SkillDetailPage", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const installHeading = await screen.findByRole("heading", { name: "Install with OpenClaw" });
|
||||
const scanDisclaimer = screen.getByText(
|
||||
/Like a lobster shell, security has layers — review code before you run it\./i,
|
||||
await screen.findByRole("heading", { name: "Install" });
|
||||
const securityHeading = screen.getByRole("heading", { name: "Security Scans" });
|
||||
|
||||
expect(screen.getAllByRole("heading", { name: "Install" }).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
|
||||
expect(screen.queryByText("npx clawhub@latest install weather")).toBeNull();
|
||||
expect(screen.queryByRole("tab", { name: "ClawHub" })).toBeNull();
|
||||
expect(screen.getByRole("tab", { name: "CLI" }).getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getByRole("tab", { name: "Prompt" })).toBeTruthy();
|
||||
expect(screen.queryByText(/After install, inspect the skill metadata/i)).toBeNull();
|
||||
expect(securityHeading).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: /VirusTotal.*Pending/i })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: /ClawScan.*Pending/i })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: /Static analysis.*Pending/i })).toBeTruthy();
|
||||
expect(screen.queryByText(/Like a lobster shell, security has layers/i)).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Rescan" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an owner rescan action in the security summary for owned skills", async () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
me: { _id: ownerId, role: "user" },
|
||||
});
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "skillId" in args && !("limit" in args)) {
|
||||
return {
|
||||
maxRequests: 3,
|
||||
requestCount: 1,
|
||||
remainingRequests: 2,
|
||||
canRequest: true,
|
||||
inProgressRequest: null,
|
||||
latestRequest: null,
|
||||
};
|
||||
}
|
||||
if (args && typeof args === "object" && "limit" in args) return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
render(
|
||||
<SkillDetailPage
|
||||
slug="weather"
|
||||
initialData={{
|
||||
result: {
|
||||
skill: {
|
||||
_id: skillId,
|
||||
_creationTime: 0,
|
||||
slug: "weather",
|
||||
displayName: "Weather",
|
||||
summary: "Get current weather.",
|
||||
ownerUserId: ownerId,
|
||||
ownerPublisherId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
stars: 12,
|
||||
downloads: 34,
|
||||
installsCurrent: 5,
|
||||
installsAllTime: 8,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerPublisherId,
|
||||
_creationTime: 0,
|
||||
kind: "user",
|
||||
handle: "steipete",
|
||||
displayName: "Peter",
|
||||
linkedUserId: ownerId,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
_creationTime: 0,
|
||||
skillId,
|
||||
version: "1.0.0",
|
||||
fingerprint: "abc",
|
||||
changelog: "Initial release",
|
||||
parsed: { license: "MIT-0", frontmatter: {} },
|
||||
files: [],
|
||||
sha256hash: "abc123",
|
||||
createdBy: ownerId,
|
||||
createdAt: 0,
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
},
|
||||
readme: "# Weather",
|
||||
readmeError: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "CLI Commands" })).toBeTruthy();
|
||||
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
|
||||
expect(screen.getByText("npx clawhub@latest install weather")).toBeTruthy();
|
||||
expect(screen.getByText(/After install, inspect the skill metadata/i)).toBeTruthy();
|
||||
expect(
|
||||
installHeading.compareDocumentPosition(scanDisclaimer) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByRole("button", { name: "Rescan" })).toBeTruthy();
|
||||
expect(screen.queryByText("Owner rescan")).toBeNull();
|
||||
expect(screen.queryByText("2/3 rescans left")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not refetch readme when SSR data already matches the latest version", async () => {
|
||||
@@ -349,7 +436,7 @@ describe("SkillDetailPage", () => {
|
||||
});
|
||||
|
||||
render(<SkillDetailPage slug="weather" redirectToCanonical />);
|
||||
expect(screen.getByText(/Loading skill/i)).toBeTruthy();
|
||||
expect(screen.getByRole("status", { name: /Loading skill details/i })).toBeTruthy();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateMock).toHaveBeenCalled();
|
||||
@@ -506,7 +593,7 @@ describe("SkillDetailPage", () => {
|
||||
expect(screen.getByText(/Report skill/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows owner tools for the skill owner", async () => {
|
||||
it("links owner tools from the detail page and renders them on settings", async () => {
|
||||
useAuthStatusMock.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
@@ -514,7 +601,11 @@ describe("SkillDetailPage", () => {
|
||||
});
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
if (args && typeof args === "object" && "ownerUserId" in args) {
|
||||
if (
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
("ownerUserId" in args || "ownerPublisherId" in args)
|
||||
) {
|
||||
return [
|
||||
{ _id: "skills:1", slug: "weather", displayName: "Weather" },
|
||||
{ _id: "skills:2", slug: "weather-pro", displayName: "Weather Pro" },
|
||||
@@ -547,7 +638,14 @@ describe("SkillDetailPage", () => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
render(<SkillDetailPage slug="weather" />);
|
||||
const { unmount } = render(<SkillDetailPage slug="weather" />);
|
||||
|
||||
const settingsLink = await screen.findByRole("link", { name: /settings/i });
|
||||
expect(settingsLink.getAttribute("href")).toBe("/steipete/weather/settings");
|
||||
expect(screen.queryByText(/Owner tools/i)).toBeNull();
|
||||
unmount();
|
||||
|
||||
render(<SkillDetailPage slug="weather" mode="settings" />);
|
||||
|
||||
expect(await screen.findByText(/Owner tools/i)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Rename and redirect/i })).toBeTruthy();
|
||||
|
||||
@@ -40,7 +40,7 @@ function runBeforeLoad(search: Record<string, unknown>) {
|
||||
|
||||
describe("skills route default sort", () => {
|
||||
it("redirects browse view to downloads when sort is missing", () => {
|
||||
expect(runBeforeLoad({ nonSuspicious: true })).toEqual({
|
||||
expect(runBeforeLoad({})).toEqual({
|
||||
redirect: {
|
||||
to: "/skills",
|
||||
search: {
|
||||
@@ -48,7 +48,8 @@ describe("skills route default sort", () => {
|
||||
sort: "downloads",
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
nonSuspicious: true,
|
||||
featured: undefined,
|
||||
nonSuspicious: undefined,
|
||||
tag: undefined,
|
||||
view: undefined,
|
||||
focus: undefined,
|
||||
@@ -61,4 +62,10 @@ describe("skills route default sort", () => {
|
||||
it("does not redirect when query is present", () => {
|
||||
expect(runBeforeLoad({ q: "notion" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not redirect when filters are present", () => {
|
||||
expect(runBeforeLoad({ nonSuspicious: true })).toBeUndefined();
|
||||
expect(runBeforeLoad({ featured: true })).toBeUndefined();
|
||||
expect(runBeforeLoad({ highlighted: true })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const queryMock = vi.fn();
|
||||
|
||||
vi.mock("../convex/client", () => ({
|
||||
convexHttp: { query: (...args: unknown[]) => queryMock(...args) },
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
__config: config,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../components/UserListItem", () => ({
|
||||
UserListItem: ({ user }: { user: { _id: string } }) => <div>{user._id}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../components/ui/card", () => ({
|
||||
Card: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
async function loadRoute() {
|
||||
return (await import("../routes/users/index")).Route as unknown as {
|
||||
__config: {
|
||||
component?: ComponentType;
|
||||
validateSearch?: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe("users route", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
queryMock.mockReset();
|
||||
queryMock.mockResolvedValue({ items: [], total: 0 });
|
||||
});
|
||||
|
||||
it("does not expose public user search", async () => {
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
await waitFor(() => expect(queryMock).toHaveBeenCalled());
|
||||
expect(queryMock.mock.calls[0]?.[1]).toEqual({ limit: 48 });
|
||||
expect(screen.queryByPlaceholderText(/search users/i)).toBeNull();
|
||||
expect(route.__config.validateSearch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type DetailPageShellProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type DetailHeroProps = {
|
||||
main: ReactNode;
|
||||
sidebar?: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
topClassName?: string;
|
||||
mainClassName?: string;
|
||||
sidebarClassName?: string;
|
||||
};
|
||||
|
||||
type DetailBodyProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
mainClassName?: string;
|
||||
};
|
||||
|
||||
export function DetailPageShell({ children, className }: DetailPageShellProps) {
|
||||
return <div className={cn("skill-detail-stack", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function DetailHero({
|
||||
main,
|
||||
sidebar,
|
||||
children,
|
||||
className,
|
||||
topClassName,
|
||||
mainClassName,
|
||||
sidebarClassName,
|
||||
}: DetailHeroProps) {
|
||||
return (
|
||||
<div className={cn("skill-hero", className)}>
|
||||
<div className={cn("skill-hero-top", topClassName)}>
|
||||
<div className="skill-hero-layout">
|
||||
<div className={cn("skill-hero-main", mainClassName)}>
|
||||
{main}
|
||||
{children ? <div className="skill-hero-main-extra">{children}</div> : null}
|
||||
</div>
|
||||
{sidebar ? (
|
||||
<aside className={cn("skill-hero-sidebar", sidebarClassName)}>{sidebar}</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailBody({ children, className, mainClassName }: DetailBodyProps) {
|
||||
return (
|
||||
<div className={cn("detail-layout", className)}>
|
||||
<div className={cn("detail-main", mainClassName)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DetailSecuritySummary } from "./DetailSecuritySummary";
|
||||
|
||||
describe("DetailSecuritySummary", () => {
|
||||
it("shows a disabled spinner button while a rescan is in progress", () => {
|
||||
render(
|
||||
<DetailSecuritySummary
|
||||
scannerBasePath="/steipete/weather/security"
|
||||
rescanState={{
|
||||
maxRequests: 3,
|
||||
requestCount: 1,
|
||||
remainingRequests: 2,
|
||||
canRequest: false,
|
||||
inProgressRequest: {
|
||||
_id: "rescanRequests:1",
|
||||
targetKind: "skill",
|
||||
targetVersion: "1.0.0",
|
||||
status: "in_progress",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRequest: null,
|
||||
}}
|
||||
onRequestRescan={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Scanning" });
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(button.getAttribute("title")).toBe("A rescan is already in progress.");
|
||||
expect(button.querySelector(".animate-spin")?.className).toContain(
|
||||
"[animation-duration:2.4s]",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
getScanStatusInfo,
|
||||
type LlmAnalysis,
|
||||
type StaticFinding,
|
||||
type VtAnalysis,
|
||||
} from "./SkillSecurityScanResults";
|
||||
import { Badge, type BadgeProps } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
|
||||
type RescanRequest = {
|
||||
_id: string;
|
||||
targetKind: "skill" | "plugin";
|
||||
targetVersion: string;
|
||||
status: "in_progress" | "completed" | "failed";
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
completedAt?: number;
|
||||
};
|
||||
|
||||
export type DetailRescanState = {
|
||||
maxRequests: number;
|
||||
requestCount: number;
|
||||
remainingRequests: number;
|
||||
canRequest: boolean;
|
||||
inProgressRequest: RescanRequest | null;
|
||||
latestRequest: RescanRequest | null;
|
||||
};
|
||||
|
||||
type DetailSecuritySummaryProps = {
|
||||
scannerBasePath: string;
|
||||
sha256hash?: string | null;
|
||||
vtAnalysis?: VtAnalysis | null;
|
||||
llmAnalysis?: LlmAnalysis | null;
|
||||
staticScan?: {
|
||||
status: string;
|
||||
reasonCodes: string[];
|
||||
findings: StaticFinding[];
|
||||
summary: string;
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
} | null;
|
||||
rescanState?: DetailRescanState | null;
|
||||
onRequestRescan?: (() => Promise<void>) | null;
|
||||
};
|
||||
|
||||
function statusFromStaticScan(staticScan: DetailSecuritySummaryProps["staticScan"]) {
|
||||
if (staticScan?.status) return staticScan.status;
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function badgeVariantForScanStatus(status: string): BadgeProps["variant"] {
|
||||
const normalized = status.toLowerCase();
|
||||
if (normalized === "clean" || normalized === "benign") return "success";
|
||||
if (normalized === "suspicious") return "warning";
|
||||
if (normalized === "malicious" || normalized === "error") return "destructive";
|
||||
if (normalized === "pending" || normalized === "queued" || normalized === "loading") {
|
||||
return "pending";
|
||||
}
|
||||
return "compact";
|
||||
}
|
||||
|
||||
function ScannerRow({ href, label, status }: { href: string; label: string; status: string }) {
|
||||
const info = getScanStatusInfo(status);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="flex min-w-0 items-center justify-between gap-3 rounded-[var(--radius-sm)] px-1 py-2 text-sm !no-underline hover:bg-[color:var(--surface-muted)] hover:!no-underline"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 font-semibold text-[color:var(--ink)]">
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={badgeVariantForScanStatus(status)}>{info.label}</Badge>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function rescanDisabledReason(state: DetailRescanState | null | undefined) {
|
||||
if (!state) return null;
|
||||
if (state.inProgressRequest) return "A rescan is already in progress.";
|
||||
if (state.remainingRequests <= 0) {
|
||||
return `Rescan limit reached (${state.requestCount}/${state.maxRequests}).`;
|
||||
}
|
||||
if (!state.canRequest) return "This release is not eligible for another rescan.";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function DetailSecuritySummary({
|
||||
scannerBasePath,
|
||||
vtAnalysis,
|
||||
llmAnalysis,
|
||||
staticScan,
|
||||
rescanState,
|
||||
onRequestRescan,
|
||||
}: DetailSecuritySummaryProps) {
|
||||
const [isRequestingRescan, setIsRequestingRescan] = useState(false);
|
||||
const vtStatus = vtAnalysis?.verdict ?? vtAnalysis?.status ?? "pending";
|
||||
const llmStatus = llmAnalysis?.verdict ?? llmAnalysis?.status ?? "pending";
|
||||
const staticStatus = statusFromStaticScan(staticScan);
|
||||
const rescanButtonDisabledReason = rescanDisabledReason(rescanState);
|
||||
const isScanInProgress = Boolean(rescanState?.inProgressRequest);
|
||||
const rescanButtonLabel = isScanInProgress
|
||||
? "Scanning"
|
||||
: isRequestingRescan
|
||||
? "Requesting..."
|
||||
: "Rescan";
|
||||
|
||||
async function handleRequestRescan() {
|
||||
if (!onRequestRescan || rescanButtonDisabledReason || isRequestingRescan) return;
|
||||
setIsRequestingRescan(true);
|
||||
try {
|
||||
await onRequestRescan();
|
||||
} finally {
|
||||
setIsRequestingRescan(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
|
||||
Security Scans
|
||||
{rescanState && onRequestRescan ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-center sm:ml-auto sm:w-auto"
|
||||
loading={isRequestingRescan || isScanInProgress}
|
||||
disabled={Boolean(rescanButtonDisabledReason)}
|
||||
title={rescanButtonDisabledReason ?? "Request a fresh scan"}
|
||||
onClick={() => void handleRequestRescan()}
|
||||
>
|
||||
{rescanButtonLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ScannerRow href={`${scannerBasePath}/virustotal`} label="VirusTotal" status={vtStatus} />
|
||||
<ScannerRow href={`${scannerBasePath}/openclaw`} label="ClawScan" status={llmStatus} />
|
||||
<ScannerRow
|
||||
href={`${scannerBasePath}/static-analysis`}
|
||||
label="Static analysis"
|
||||
status={staticStatus}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+406
-160
@@ -1,21 +1,20 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { Ghost, Github, Menu, Monitor, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
|
||||
import { type ComponentType, useMemo, useRef, useState } from "react";
|
||||
import { ArrowRight, Ghost, Menu, Moon, Plug, Search, Sun, Wrench } from "lucide-react";
|
||||
import { type ComponentType, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getUserFacingAuthError } from "../lib/authErrorMessage";
|
||||
import { gravatarUrl } from "../lib/gravatar";
|
||||
import {
|
||||
filterNavItems,
|
||||
type NavIconName,
|
||||
PRIMARY_NAV_ITEMS,
|
||||
SECONDARY_NAV_ITEMS,
|
||||
} from "../lib/nav-items";
|
||||
import { filterNavItems, type NavIconName, PRIMARY_NAV_ITEMS } from "../lib/nav-items";
|
||||
import { isModerator } from "../lib/roles";
|
||||
import { getClawHubSiteUrl, getSiteMode, getSiteName } from "../lib/site";
|
||||
import { applyTheme, useThemeMode } from "../lib/theme";
|
||||
import { startThemeTransition } from "../lib/theme-transition";
|
||||
import { setAuthError, useAuthError } from "../lib/useAuthError";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import {
|
||||
useUnifiedSearch,
|
||||
type UnifiedPluginResult,
|
||||
type UnifiedSkillResult,
|
||||
} from "../lib/useUnifiedSearch";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "./ui/sheet";
|
||||
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
|
||||
|
||||
const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?: string }>> = {
|
||||
wrench: Wrench,
|
||||
@@ -40,13 +38,28 @@ const NAV_ICONS: Record<NavIconName, ComponentType<{ size?: number; className?:
|
||||
ghost: Ghost,
|
||||
};
|
||||
|
||||
const THEME_MODE_SEQUENCE: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
|
||||
type TypeaheadItem =
|
||||
| {
|
||||
kind: "skill";
|
||||
key: string;
|
||||
result: UnifiedSkillResult;
|
||||
}
|
||||
| {
|
||||
kind: "plugin";
|
||||
key: string;
|
||||
result: UnifiedPluginResult;
|
||||
}
|
||||
| {
|
||||
kind: "footer";
|
||||
key: string;
|
||||
section: "skills" | "plugins";
|
||||
label: string;
|
||||
};
|
||||
|
||||
export default function Header() {
|
||||
const { isAuthenticated, isLoading, me } = useAuthStatus();
|
||||
const { signIn, signOut } = useAuthActions();
|
||||
const { theme, mode, setMode } = useThemeMode();
|
||||
const toggleRef = useRef<HTMLDivElement | null>(null);
|
||||
const siteMode = getSiteMode();
|
||||
const siteName = useMemo(() => getSiteName(siteMode), [siteMode]);
|
||||
const isSoulMode = siteMode === "souls";
|
||||
@@ -64,32 +77,75 @@ export default function Header() {
|
||||
[hasResolvedUser, isSoulMode, isStaff],
|
||||
);
|
||||
const primaryItems = useMemo(() => filterNavItems(PRIMARY_NAV_ITEMS, navCtx), [navCtx]);
|
||||
const secondaryItems = useMemo(() => filterNavItems(SECONDARY_NAV_ITEMS, navCtx), [navCtx]);
|
||||
const { error: authError, clear: clearAuthError } = useAuthError();
|
||||
const signInRedirectTo = getCurrentRelativeUrl();
|
||||
|
||||
const [navSearchQuery, setNavSearchQuery] = useState("");
|
||||
const [typeaheadOpen, setTypeaheadOpen] = useState(false);
|
||||
const [typeaheadActiveIndex, setTypeaheadActiveIndex] = useState(0);
|
||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const searchWrapRef = useRef<HTMLDivElement | null>(null);
|
||||
const ThemeModeIcon = getThemeModeIcon(mode);
|
||||
const nextThemeMode = getNextThemeMode(mode);
|
||||
const trimmedNavSearchQuery = navSearchQuery.trim();
|
||||
const showTypeahead = !isSoulMode && typeaheadOpen && trimmedNavSearchQuery.length > 0;
|
||||
const {
|
||||
skillResults,
|
||||
skillCount,
|
||||
pluginResults,
|
||||
pluginCount,
|
||||
isSearching: typeaheadSearching,
|
||||
} = useUnifiedSearch(navSearchQuery, "all", {
|
||||
debounceMs: 180,
|
||||
enabled: showTypeahead,
|
||||
limits: { skills: 4, plugins: 4 },
|
||||
});
|
||||
const typeaheadItems = useMemo<TypeaheadItem[]>(() => {
|
||||
if (!showTypeahead) return [];
|
||||
const items: TypeaheadItem[] = [];
|
||||
for (const result of skillResults) {
|
||||
items.push({ kind: "skill", key: `skill-${result.skill._id}`, result });
|
||||
}
|
||||
if (skillCount > 0) {
|
||||
items.push({
|
||||
kind: "footer",
|
||||
key: "footer-skills",
|
||||
section: "skills",
|
||||
label: `See skill results for "${trimmedNavSearchQuery}"`,
|
||||
});
|
||||
}
|
||||
for (const result of pluginResults) {
|
||||
items.push({ kind: "plugin", key: `plugin-${result.plugin.name}`, result });
|
||||
}
|
||||
if (pluginCount > 0) {
|
||||
items.push({
|
||||
kind: "footer",
|
||||
key: "footer-plugins",
|
||||
section: "plugins",
|
||||
label: `See plugin results for "${trimmedNavSearchQuery}"`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [pluginCount, pluginResults, showTypeahead, skillCount, skillResults, trimmedNavSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setTypeaheadActiveIndex(0);
|
||||
}, [trimmedNavSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!typeaheadOpen) return () => {};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (searchWrapRef.current?.contains(event.target as Node)) return;
|
||||
setTypeaheadOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [typeaheadOpen]);
|
||||
|
||||
const setThemeMode = (next: "system" | "light" | "dark") => {
|
||||
startThemeTransition({
|
||||
nextTheme: next,
|
||||
currentTheme: mode,
|
||||
setTheme: (value) => {
|
||||
const nextMode = value as "system" | "light" | "dark";
|
||||
applyTheme(nextMode, theme);
|
||||
setMode(nextMode);
|
||||
},
|
||||
context: { element: toggleRef.current },
|
||||
});
|
||||
};
|
||||
|
||||
const cycleThemeMode = () => {
|
||||
const currentIndex = Math.max(0, THEME_MODE_SEQUENCE.indexOf(mode));
|
||||
const nextMode = THEME_MODE_SEQUENCE[(currentIndex + 1) % THEME_MODE_SEQUENCE.length] ?? "system";
|
||||
setThemeMode(nextMode);
|
||||
applyTheme(next, theme);
|
||||
setMode(next);
|
||||
};
|
||||
|
||||
const handleNavSearch = (e: React.FormEvent) => {
|
||||
@@ -109,9 +165,72 @@ export default function Header() {
|
||||
: { q, type: undefined },
|
||||
});
|
||||
setNavSearchQuery("");
|
||||
setTypeaheadOpen(false);
|
||||
setMobileSearchOpen(false);
|
||||
};
|
||||
|
||||
const navigateToTypeaheadItem = (item: TypeaheadItem) => {
|
||||
if (item.kind === "skill") {
|
||||
const resultOwnerHandle = item.result.ownerHandle?.trim();
|
||||
if (!resultOwnerHandle) {
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: trimmedNavSearchQuery, type: "skills" },
|
||||
});
|
||||
setNavSearchQuery("");
|
||||
setTypeaheadOpen(false);
|
||||
setMobileSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
void navigate({
|
||||
to: `/${encodeURIComponent(resultOwnerHandle)}/${encodeURIComponent(item.result.skill.slug)}`,
|
||||
});
|
||||
} else if (item.kind === "plugin") {
|
||||
void navigate({
|
||||
to: "/plugins/$name",
|
||||
params: { name: item.result.plugin.name },
|
||||
});
|
||||
} else {
|
||||
void navigate({
|
||||
to: "/search",
|
||||
search: { q: trimmedNavSearchQuery, type: item.section },
|
||||
});
|
||||
}
|
||||
setNavSearchQuery("");
|
||||
setTypeaheadOpen(false);
|
||||
setMobileSearchOpen(false);
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (isSoulMode) return;
|
||||
if (event.key === "Escape") {
|
||||
setTypeaheadOpen(false);
|
||||
return;
|
||||
}
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter") return;
|
||||
if (!showTypeahead || typeaheadItems.length === 0) {
|
||||
if (event.key === "ArrowDown" && trimmedNavSearchQuery) {
|
||||
setTypeaheadOpen(true);
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setTypeaheadActiveIndex((index) => (index + 1) % typeaheadItems.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setTypeaheadActiveIndex(
|
||||
(index) => (index - 1 + typeaheadItems.length) % typeaheadItems.length,
|
||||
);
|
||||
} else if (event.key === "Enter") {
|
||||
const activeItem = typeaheadItems[typeaheadActiveIndex];
|
||||
if (!activeItem) return;
|
||||
event.preventDefault();
|
||||
navigateToTypeaheadItem(activeItem);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="navbar">
|
||||
<div className="navbar-inner">
|
||||
@@ -166,48 +285,19 @@ export default function Header() {
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
{secondaryItems.map((item) => (
|
||||
<SheetClose key={item.to + item.label} asChild>
|
||||
<Link to={item.to} search={item.search ?? {}} className="mobile-nav-link">
|
||||
{item.label === "Management" ? "Manage" : item.label}
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
</div>
|
||||
<div className="mobile-nav-section">
|
||||
<div className="mobile-nav-section-title">Theme mode</div>
|
||||
<div className="mobile-nav-section-title">Theme</div>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("system");
|
||||
setThemeMode(nextThemeMode);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
System
|
||||
</button>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("light");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
className="mobile-nav-link"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setThemeMode("dark");
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
Dark
|
||||
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
|
||||
{mode === "system" ? "System theme" : `${mode} theme`}
|
||||
</button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
@@ -225,17 +315,68 @@ export default function Header() {
|
||||
<span className="brand-name brand-name-responsive">{siteName}</span>
|
||||
</Link>
|
||||
|
||||
<form className="navbar-search" onSubmit={handleNavSearch} role="search" aria-label="Site search">
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="search"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => setNavSearchQuery(e.target.value)}
|
||||
aria-label="Search"
|
||||
/>
|
||||
</form>
|
||||
<div className="navbar-search-wrap" ref={searchWrapRef}>
|
||||
<form
|
||||
className="navbar-search"
|
||||
onSubmit={handleNavSearch}
|
||||
role="search"
|
||||
aria-label="Site search"
|
||||
>
|
||||
<Search size={16} className="navbar-search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="search"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => {
|
||||
setNavSearchQuery(e.target.value);
|
||||
setTypeaheadOpen(true);
|
||||
}}
|
||||
onFocus={() => setTypeaheadOpen(true)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
aria-label="Search"
|
||||
aria-expanded={showTypeahead}
|
||||
aria-controls="navbar-search-typeahead"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</form>
|
||||
{showTypeahead ? (
|
||||
<SearchTypeahead
|
||||
activeIndex={typeaheadActiveIndex}
|
||||
items={typeaheadItems}
|
||||
loading={typeaheadSearching}
|
||||
onHoverItem={setTypeaheadActiveIndex}
|
||||
onSelectItem={navigateToTypeaheadItem}
|
||||
query={trimmedNavSearchQuery}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="navbar-top-links" aria-label="Primary">
|
||||
{isSoulMode ? (
|
||||
<a href={clawHubUrl} className="navbar-tab">
|
||||
ClawHub
|
||||
</a>
|
||||
) : null}
|
||||
{primaryItems.map((item) => {
|
||||
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
|
||||
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
|
||||
location.pathname.startsWith(prefix),
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={item.to + item.label}
|
||||
to={item.to}
|
||||
className="navbar-tab"
|
||||
search={item.search ?? {}}
|
||||
data-status={isActiveByPrefix ? "active" : undefined}
|
||||
>
|
||||
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="nav-actions">
|
||||
<button
|
||||
@@ -246,41 +387,16 @@ export default function Header() {
|
||||
>
|
||||
<Search size={18} aria-hidden="true" />
|
||||
</button>
|
||||
<div className="theme-toggle" ref={toggleRef}>
|
||||
<div className="theme-cycle-group" aria-label="Theme controls">
|
||||
<button
|
||||
type="button"
|
||||
className="theme-cycle-button theme-cycle-button-mode"
|
||||
onClick={cycleThemeMode}
|
||||
aria-label={`Cycle theme mode. Current: ${mode}`}
|
||||
title={`Theme mode: ${mode}`}
|
||||
>
|
||||
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
className="theme-mode-toggle"
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setThemeMode(value as "system" | "light" | "dark");
|
||||
}}
|
||||
aria-label="Theme mode"
|
||||
<div className="theme-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className="theme-cycle-button"
|
||||
onClick={() => setThemeMode(nextThemeMode)}
|
||||
aria-label={`Toggle theme. Current: ${mode}`}
|
||||
title={`Theme: ${mode}`}
|
||||
>
|
||||
<ToggleGroupItem value="system" aria-label="System theme">
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">System</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="light" aria-label="Light theme">
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Light</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Dark theme">
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Dark</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ThemeModeIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{isAuthenticated && me ? (
|
||||
<DropdownMenu>
|
||||
@@ -332,13 +448,13 @@ export default function Header() {
|
||||
"github",
|
||||
signInRedirectTo ? { redirectTo: signInRedirectTo } : undefined,
|
||||
).catch((error) => {
|
||||
setAuthError(getUserFacingAuthError(error, "Sign in failed. Please try again."));
|
||||
setAuthError(
|
||||
getUserFacingAuthError(error, "Sign in failed. Please try again."),
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Github size={16} aria-hidden="true" />
|
||||
<span className="sign-in-label">Sign in</span>
|
||||
<span className="sign-in-provider">with GitHub</span>
|
||||
Sign In
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -352,65 +468,179 @@ export default function Header() {
|
||||
<input
|
||||
className="navbar-search-input"
|
||||
type="text"
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills, plugins, users"}
|
||||
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
|
||||
value={navSearchQuery}
|
||||
onChange={(e) => setNavSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{/* Row 2: Content type tabs */}
|
||||
<nav className="navbar-tabs" aria-label="Content types">
|
||||
<div className="navbar-tabs-primary">
|
||||
{isSoulMode ? (
|
||||
<a href={clawHubUrl} className="navbar-tab">
|
||||
ClawHub
|
||||
</a>
|
||||
) : null}
|
||||
{primaryItems.map((item) => {
|
||||
const Icon = item.icon ? NAV_ICONS[item.icon] : null;
|
||||
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
|
||||
location.pathname.startsWith(prefix)
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={item.to + item.label}
|
||||
to={item.to}
|
||||
className="navbar-tab"
|
||||
search={item.search ?? {}}
|
||||
data-status={isActiveByPrefix ? "active" : undefined}
|
||||
>
|
||||
{Icon ? <Icon size={14} className="opacity-50" aria-hidden="true" /> : null}
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="navbar-tabs-secondary">
|
||||
{secondaryItems.map((item) => {
|
||||
const isActiveByPrefix = item.activePathPrefixes?.some((prefix) =>
|
||||
location.pathname.startsWith(prefix)
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={item.to + item.label}
|
||||
to={item.to}
|
||||
search={item.search ?? {}}
|
||||
className="navbar-tab navbar-tab-secondary"
|
||||
data-status={isActiveByPrefix ? "active" : undefined}
|
||||
>
|
||||
{item.label === "Management" ? "Manage" : item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchTypeahead({
|
||||
activeIndex,
|
||||
items,
|
||||
loading,
|
||||
onHoverItem,
|
||||
onSelectItem,
|
||||
query,
|
||||
}: {
|
||||
activeIndex: number;
|
||||
items: TypeaheadItem[];
|
||||
loading: boolean;
|
||||
onHoverItem: (index: number) => void;
|
||||
onSelectItem: (item: TypeaheadItem) => void;
|
||||
query: string;
|
||||
}) {
|
||||
const skillItems = items.filter((item) => item.kind === "skill");
|
||||
const pluginItems = items.filter((item) => item.kind === "plugin");
|
||||
const footerItems = items.filter((item) => item.kind === "footer");
|
||||
const skillsFooter = footerItems.find(
|
||||
(item) => item.kind === "footer" && item.section === "skills",
|
||||
);
|
||||
const pluginsFooter = footerItems.find(
|
||||
(item) => item.kind === "footer" && item.section === "plugins",
|
||||
);
|
||||
const hasMatches = skillItems.length > 0 || pluginItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className="navbar-search-typeahead" id="navbar-search-typeahead" role="listbox">
|
||||
<TypeaheadSection
|
||||
activeIndex={activeIndex}
|
||||
items={items}
|
||||
label="Skills"
|
||||
sectionItems={skillItems}
|
||||
footer={skillsFooter}
|
||||
onHoverItem={onHoverItem}
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
<TypeaheadSection
|
||||
activeIndex={activeIndex}
|
||||
items={items}
|
||||
label="Plugins"
|
||||
sectionItems={pluginItems}
|
||||
footer={pluginsFooter}
|
||||
onHoverItem={onHoverItem}
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
{loading && !hasMatches ? (
|
||||
<div className="navbar-search-typeahead-status">Searching...</div>
|
||||
) : null}
|
||||
{!loading && !hasMatches ? (
|
||||
<div className="navbar-search-typeahead-status">
|
||||
No skills or plugins found for "{query}"
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeaheadSection({
|
||||
activeIndex,
|
||||
footer,
|
||||
items,
|
||||
label,
|
||||
onHoverItem,
|
||||
onSelectItem,
|
||||
sectionItems,
|
||||
}: {
|
||||
activeIndex: number;
|
||||
footer: TypeaheadItem | undefined;
|
||||
items: TypeaheadItem[];
|
||||
label: string;
|
||||
onHoverItem: (index: number) => void;
|
||||
onSelectItem: (item: TypeaheadItem) => void;
|
||||
sectionItems: TypeaheadItem[];
|
||||
}) {
|
||||
if (sectionItems.length === 0 && !footer) return null;
|
||||
return (
|
||||
<div className="navbar-search-typeahead-section">
|
||||
<div className="navbar-search-typeahead-heading">{label}</div>
|
||||
{sectionItems.map((item) => (
|
||||
<TypeaheadRow
|
||||
key={item.key}
|
||||
active={items[activeIndex]?.key === item.key}
|
||||
item={item}
|
||||
index={items.findIndex((candidate) => candidate.key === item.key)}
|
||||
onHoverItem={onHoverItem}
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
))}
|
||||
{footer ? (
|
||||
<TypeaheadRow
|
||||
active={items[activeIndex]?.key === footer.key}
|
||||
item={footer}
|
||||
index={items.findIndex((candidate) => candidate.key === footer.key)}
|
||||
onHoverItem={onHoverItem}
|
||||
onSelectItem={onSelectItem}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeaheadRow({
|
||||
active,
|
||||
index,
|
||||
item,
|
||||
onHoverItem,
|
||||
onSelectItem,
|
||||
}: {
|
||||
active: boolean;
|
||||
index: number;
|
||||
item: TypeaheadItem;
|
||||
onHoverItem: (index: number) => void;
|
||||
onSelectItem: (item: TypeaheadItem) => void;
|
||||
}) {
|
||||
const body = getTypeaheadRowBody(item);
|
||||
return (
|
||||
<button
|
||||
className={`navbar-search-typeahead-row${active ? " is-active" : ""}${item.kind === "footer" ? " is-footer" : ""}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onMouseEnter={() => onHoverItem(index)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => onSelectItem(item)}
|
||||
>
|
||||
{body.icon ? <span className="navbar-search-typeahead-icon">{body.icon}</span> : null}
|
||||
<span className="navbar-search-typeahead-copy">
|
||||
<span className="navbar-search-typeahead-title">{body.title}</span>
|
||||
{body.meta ? <span className="navbar-search-typeahead-meta">{body.meta}</span> : null}
|
||||
</span>
|
||||
{item.kind === "footer" ? <ArrowRight size={14} aria-hidden="true" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function getTypeaheadRowBody(item: TypeaheadItem) {
|
||||
if (item.kind === "skill") {
|
||||
const owner = item.result.ownerHandle ? `@${item.result.ownerHandle}` : "Skill";
|
||||
return {
|
||||
icon: "S",
|
||||
title: item.result.skill.displayName,
|
||||
meta: `${owner} / ${item.result.skill.slug}`,
|
||||
};
|
||||
}
|
||||
if (item.kind === "plugin") {
|
||||
return {
|
||||
icon: "P",
|
||||
title: item.result.plugin.displayName,
|
||||
meta: item.result.plugin.ownerHandle
|
||||
? `@${item.result.plugin.ownerHandle} / ${item.result.plugin.name}`
|
||||
: item.result.plugin.name,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: null,
|
||||
title: item.label,
|
||||
meta: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentRelativeUrl() {
|
||||
if (typeof window === "undefined") return "/";
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
@@ -424,6 +654,22 @@ function getThemeModeIcon(mode: "system" | "light" | "dark") {
|
||||
return Moon;
|
||||
case "system":
|
||||
default:
|
||||
return Monitor;
|
||||
return Sun;
|
||||
}
|
||||
}
|
||||
|
||||
function getResolvedThemeMode(): "light" | "dark" {
|
||||
if (typeof document !== "undefined") {
|
||||
const resolved = document.documentElement.dataset.themeResolved;
|
||||
if (resolved === "light" || resolved === "dark") return resolved;
|
||||
}
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
return "light";
|
||||
}
|
||||
|
||||
function getNextThemeMode(mode: "system" | "light" | "dark"): "light" | "dark" {
|
||||
const resolved = mode === "system" ? getResolvedThemeMode() : mode;
|
||||
return resolved === "dark" ? "light" : "dark";
|
||||
}
|
||||
|
||||
@@ -37,11 +37,13 @@ export function InstallCopyButton({
|
||||
label = "Copy",
|
||||
ariaLabel,
|
||||
className,
|
||||
showLabel = true,
|
||||
}: {
|
||||
text: string;
|
||||
label?: string;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
}) {
|
||||
const [copyState, setCopyState] = useState<CopyState>("idle");
|
||||
const resetTimeoutRef = useRef<number | null>(null);
|
||||
@@ -94,7 +96,7 @@ export function InstallCopyButton({
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
<span aria-live="polite">{buttonLabel}</span>
|
||||
{showLabel ? <span aria-live="polite">{buttonLabel}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { ArrowLeft, Clock, ExternalLink, Fingerprint } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import {
|
||||
getScanStatusInfo,
|
||||
type LlmAnalysis,
|
||||
type StaticFinding,
|
||||
type VtAnalysis,
|
||||
} from "./SkillSecurityScanResults";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
|
||||
export type ScannerSlug = "virustotal" | "openclaw" | "static-analysis";
|
||||
|
||||
type OwnerRef = {
|
||||
_id?: string;
|
||||
handle?: string | null;
|
||||
};
|
||||
|
||||
type EntityRef = {
|
||||
kind: "skill" | "plugin";
|
||||
title: string;
|
||||
name: string;
|
||||
version?: string | null;
|
||||
owner?: OwnerRef | null;
|
||||
ownerUserId?: Id<"users"> | null;
|
||||
ownerPublisherId?: Id<"publishers"> | null;
|
||||
detailPath: string;
|
||||
};
|
||||
|
||||
type SecurityScannerPageProps = {
|
||||
scanner: ScannerSlug;
|
||||
entity: EntityRef;
|
||||
sha256hash?: string | null;
|
||||
vtAnalysis?: VtAnalysis | null;
|
||||
llmAnalysis?: LlmAnalysis | null;
|
||||
staticScan?: {
|
||||
status: string;
|
||||
reasonCodes: string[];
|
||||
findings: StaticFinding[];
|
||||
summary: string;
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
} | null;
|
||||
source?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const SCANNER_LABELS: Record<ScannerSlug, string> = {
|
||||
virustotal: "VirusTotal",
|
||||
openclaw: "ClawScan",
|
||||
"static-analysis": "Static analysis",
|
||||
};
|
||||
|
||||
const SCANNER_SUMMARIES: Record<ScannerSlug, string> = {
|
||||
virustotal: "External malware reputation and Code Insight signals for this exact artifact hash.",
|
||||
openclaw: "ClawHub's context-aware review of the artifact, metadata, and declared behavior.",
|
||||
"static-analysis": "Deterministic local checks for risky code patterns and metadata mismatches.",
|
||||
};
|
||||
|
||||
function formatTime(value?: number | null) {
|
||||
if (!value) return "Not checked yet";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string | null {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (Array.isArray(value)) return value.length ? value.map(formatValue).filter(Boolean).join(", ") : null;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
if (children === null || children === undefined || children === "") return null;
|
||||
return (
|
||||
<div className="grid gap-1.5 border-b border-[color:var(--line)] pb-3 last:border-b-0 last:pb-0 sm:grid-cols-[180px_1fr] sm:gap-4">
|
||||
<dt className="text-sm font-semibold text-[color:var(--ink-soft)]">{label}</dt>
|
||||
<dd className="min-w-0 break-words text-sm text-[color:var(--ink)]">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getScannerStatus(props: SecurityScannerPageProps) {
|
||||
if (props.scanner === "virustotal") return props.vtAnalysis?.verdict ?? props.vtAnalysis?.status ?? "pending";
|
||||
if (props.scanner === "openclaw") return props.llmAnalysis?.verdict ?? props.llmAnalysis?.status ?? "pending";
|
||||
return props.staticScan?.status ?? "pending";
|
||||
}
|
||||
|
||||
function getCheckedAt(props: SecurityScannerPageProps) {
|
||||
if (props.scanner === "virustotal") return props.vtAnalysis?.checkedAt ?? null;
|
||||
if (props.scanner === "openclaw") return props.llmAnalysis?.checkedAt ?? null;
|
||||
return props.staticScan?.checkedAt ?? null;
|
||||
}
|
||||
|
||||
export function SecurityScannerPage(props: SecurityScannerPageProps) {
|
||||
const label = SCANNER_LABELS[props.scanner];
|
||||
const status = getScannerStatus(props);
|
||||
const statusInfo = getScanStatusInfo(status);
|
||||
const checkedAt = getCheckedAt(props);
|
||||
const vtUrl = props.sha256hash ? `https://www.virustotal.com/gui/file/${props.sha256hash}` : null;
|
||||
const sourceRepo = formatValue(props.source?.repository ?? props.source?.repo ?? props.source?.url);
|
||||
const sourceCommit = formatValue(props.source?.commit ?? props.source?.sha);
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button asChild variant="ghost" size="sm" className="w-fit">
|
||||
<a href={props.entity.detailPath}>
|
||||
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Back to {props.entity.kind}
|
||||
</a>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Badge>{props.entity.kind === "skill" ? "Skill" : "Plugin"}</Badge>
|
||||
{props.entity.version ? <Badge variant="compact">v{props.entity.version}</Badge> : null}
|
||||
</div>
|
||||
<h1 className="m-0 break-words font-display text-3xl font-bold text-[color:var(--ink)]">
|
||||
{label} security
|
||||
</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-[color:var(--ink-soft)]">
|
||||
{props.entity.title} · {SCANNER_SUMMARIES[props.scanner]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="security-scanner-layout">
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Scanner verdict</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`scan-result-status ${statusInfo.className}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs text-[color:var(--ink-soft)]">
|
||||
<Clock className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{formatTime(checkedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-2 flex flex-col gap-3">
|
||||
{props.scanner === "virustotal" ? (
|
||||
<>
|
||||
<DetailRow label="Hash">
|
||||
{props.sha256hash ? (
|
||||
<span className="inline-flex max-w-full items-center gap-2 break-all font-mono text-xs">
|
||||
<Fingerprint className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
{props.sha256hash}
|
||||
</span>
|
||||
) : (
|
||||
"No artifact hash recorded."
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Source">{props.vtAnalysis?.source ?? "File reputation"}</DetailRow>
|
||||
<DetailRow label="Verdict">{props.vtAnalysis?.verdict ?? props.vtAnalysis?.status ?? "Pending"}</DetailRow>
|
||||
<DetailRow label="Code Insight">{props.vtAnalysis?.analysis ?? null}</DetailRow>
|
||||
<DetailRow label="External report">
|
||||
{vtUrl ? (
|
||||
<a
|
||||
href={vtUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 break-all text-[color:var(--accent)] hover:underline"
|
||||
>
|
||||
View on VirusTotal
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
) : (
|
||||
"Unavailable until an artifact hash is recorded."
|
||||
)}
|
||||
</DetailRow>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{props.scanner === "openclaw" ? (
|
||||
<>
|
||||
<DetailRow label="Verdict">{props.llmAnalysis?.verdict ?? props.llmAnalysis?.status ?? "Pending"}</DetailRow>
|
||||
<DetailRow label="Confidence">{props.llmAnalysis?.confidence ?? "Not reported"}</DetailRow>
|
||||
<DetailRow label="Model">{props.llmAnalysis?.model ?? "Not reported"}</DetailRow>
|
||||
<DetailRow label="Summary">{props.llmAnalysis?.summary ?? "No ClawScan analysis has been recorded yet."}</DetailRow>
|
||||
<DetailRow label="Guidance">{props.llmAnalysis?.guidance ?? null}</DetailRow>
|
||||
<DetailRow label="Findings">
|
||||
{props.llmAnalysis?.findings ? (
|
||||
<pre className="m-0 whitespace-pre-wrap break-words font-mono text-xs">
|
||||
{props.llmAnalysis.findings}
|
||||
</pre>
|
||||
) : null}
|
||||
</DetailRow>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{props.scanner === "static-analysis" ? (
|
||||
<>
|
||||
<DetailRow label="Summary">
|
||||
{props.staticScan?.summary ?? "No static analysis result has been recorded yet."}
|
||||
</DetailRow>
|
||||
<DetailRow label="Reason codes">
|
||||
{props.staticScan?.reasonCodes?.length ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{props.staticScan.reasonCodes.map((code) => (
|
||||
<Badge key={code} variant="compact">
|
||||
{code}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
"None"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Engine">{props.staticScan?.engineVersion ?? "Not reported"}</DetailRow>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<DetailRow label="Source repository">{sourceRepo}</DetailRow>
|
||||
<DetailRow label="Source commit">
|
||||
{sourceCommit ? <span className="font-mono text-xs">{sourceCommit}</span> : null}
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{props.scanner === "openclaw" && props.llmAnalysis?.dimensions?.length ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Review Dimensions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3">
|
||||
{props.llmAnalysis.dimensions.map((dimension) => (
|
||||
<DetailRow key={dimension.name} label={dimension.label}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge variant="compact" className="w-fit">
|
||||
{dimension.rating}
|
||||
</Badge>
|
||||
<span>{dimension.detail}</span>
|
||||
</div>
|
||||
</DetailRow>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{props.scanner === "static-analysis" && props.staticScan?.findings?.length ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Evidence</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3">
|
||||
{props.staticScan.findings.map((finding, index) => (
|
||||
<div
|
||||
key={`${finding.code}-${finding.file}-${finding.line}-${index}`}
|
||||
className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)] p-3"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="compact">{finding.severity}</Badge>
|
||||
<span className="break-all font-mono text-xs text-[color:var(--ink-soft)]">
|
||||
{finding.file}:{finding.line}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-[color:var(--ink)]">
|
||||
{finding.message}
|
||||
</div>
|
||||
<pre className="mt-2 whitespace-pre-wrap break-words rounded-[var(--radius-sm)] bg-[color:var(--surface)] p-2 font-mono text-xs text-[color:var(--ink-soft)]">
|
||||
{finding.evidence || finding.code}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<aside className="flex min-w-0 flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Artifact</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="flex flex-col gap-3">
|
||||
<DetailRow label="Package">{props.entity.name}</DetailRow>
|
||||
<DetailRow label="Version">{props.entity.version ?? "Latest"}</DetailRow>
|
||||
<DetailRow label="Hash">
|
||||
{props.sha256hash ? <span className="break-all font-mono text-xs">{props.sha256hash}</span> : "Not recorded"}
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -42,8 +42,8 @@ describe("SignInButton", () => {
|
||||
it("starts GitHub sign-in with the current relative URL by default", async () => {
|
||||
signInMock.mockResolvedValue({ signingIn: true });
|
||||
|
||||
render(<SignInButton>Sign in with GitHub</SignInButton>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
|
||||
render(<SignInButton />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInMock).toHaveBeenCalledWith("github", {
|
||||
@@ -57,8 +57,8 @@ describe("SignInButton", () => {
|
||||
it("surfaces a generic error when sign-in resolves without redirecting", async () => {
|
||||
signInMock.mockResolvedValue({ signingIn: false });
|
||||
|
||||
render(<SignInButton>Sign in with GitHub</SignInButton>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
|
||||
render(<SignInButton />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setAuthErrorMock).toHaveBeenCalledWith("Sign in failed. Please try again.");
|
||||
@@ -70,8 +70,8 @@ describe("SignInButton", () => {
|
||||
signInMock.mockRejectedValue(failure);
|
||||
getUserFacingAuthErrorMock.mockReturnValue("GitHub auth unavailable");
|
||||
|
||||
render(<SignInButton>Sign in with GitHub</SignInButton>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }));
|
||||
render(<SignInButton />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign In" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getUserFacingAuthErrorMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -12,14 +12,16 @@ type SignInButtonProps = Omit<ButtonProps, "onClick" | "type"> & {
|
||||
|
||||
export function SignInButton({
|
||||
redirectTo,
|
||||
children = "Sign in with GitHub",
|
||||
children = "Sign In",
|
||||
...props
|
||||
}: SignInButtonProps) {
|
||||
const { signIn } = useAuthActions();
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
clearAuthError();
|
||||
const next = redirectTo ?? getCurrentRelativeUrl();
|
||||
@@ -35,7 +37,6 @@ export function SignInButton({
|
||||
);
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { getUserFacingConvexError } from "../lib/convexError";
|
||||
import { canManageSkill, isModerator } from "../lib/roles";
|
||||
import type { SkillBySlugResult, SkillPageInitialData } from "../lib/skillPage";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import { Card } from "./ui/card";
|
||||
import { ClientOnly } from "./ClientOnly";
|
||||
import { DetailBody, DetailPageShell } from "./DetailPageShell";
|
||||
import { DetailSecuritySummary } from "./DetailSecuritySummary";
|
||||
import { SkillDetailSkeleton } from "./skeletons/SkillDetailSkeleton";
|
||||
import { SkillCommentsPanel } from "./SkillCommentsPanel";
|
||||
import { SkillDetailTabs, type DetailTab } from "./SkillDetailTabs";
|
||||
import { SkillMetadataSidebar } from "./SkillMetadataSidebar";
|
||||
import {
|
||||
buildSkillHref,
|
||||
formatConfigSnippet,
|
||||
@@ -22,16 +26,22 @@ import {
|
||||
import { SkillHeader } from "./SkillHeader";
|
||||
import { SkillOwnershipPanel } from "./SkillOwnershipPanel";
|
||||
import { SkillReportDialog } from "./SkillReportDialog";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
|
||||
type SkillDetailPageProps = {
|
||||
slug: string;
|
||||
canonicalOwner?: string;
|
||||
redirectToCanonical?: boolean;
|
||||
initialData?: SkillPageInitialData | null;
|
||||
mode?: "detail" | "settings";
|
||||
};
|
||||
|
||||
type SkillFile = Doc<"skillVersions">["files"][number];
|
||||
|
||||
const SHOW_SKILL_COMMENTS = false;
|
||||
|
||||
function formatReportError(error: unknown) {
|
||||
if (error && typeof error === "object" && "data" in error) {
|
||||
const data = (error as { data?: unknown }).data;
|
||||
@@ -65,6 +75,7 @@ export function SkillDetailPage({
|
||||
canonicalOwner,
|
||||
redirectToCanonical,
|
||||
initialData,
|
||||
mode = "detail",
|
||||
}: SkillDetailPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, me } = useAuthStatus();
|
||||
@@ -83,6 +94,7 @@ export function SkillDetailPage({
|
||||
const reportSkill = useMutation(api.skills.report);
|
||||
const updateTags = useMutation(api.skills.updateTags);
|
||||
const deleteTags = useMutation(api.skills.deleteTags);
|
||||
const requestRescan = useMutation(api.skills.requestRescan);
|
||||
const getReadme = useAction(api.skills.getReadme);
|
||||
const myPublishers = useQuery(api.publishers.listMine) as
|
||||
| Array<{ publisher: { _id: Id<"publishers"> }; role: string }>
|
||||
@@ -145,6 +157,11 @@ export function SkillDetailPage({
|
||||
: { ownerUserId: skill.ownerUserId, limit: 100 }
|
||||
: "skip",
|
||||
) as Array<{ _id: Id<"skills">; slug: string; displayName: string }> | undefined;
|
||||
const canViewOwnerRescanState = isOwner || me?.role === "admin";
|
||||
const rescanState = useQuery(
|
||||
api.skills.getRescanState,
|
||||
canViewOwnerRescanState && skill ? { skillId: skill._id } : "skip",
|
||||
) as ComponentProps<typeof DetailSecuritySummary>["rescanState"] | undefined;
|
||||
|
||||
const ownerHandle = owner?.handle ?? null;
|
||||
const ownerParam = ownerHandle?.trim().toLowerCase() || (owner?._id ? String(owner._id) : null);
|
||||
@@ -237,7 +254,10 @@ export function SkillDetailPage({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (latestVersion && !(loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null))) {
|
||||
if (
|
||||
latestVersion &&
|
||||
!(loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null))
|
||||
) {
|
||||
setReadme(null);
|
||||
setReadmeError(null);
|
||||
setLoadedReadmeVersionId(latestVersion._id);
|
||||
@@ -325,19 +345,36 @@ export function SkillDetailPage({
|
||||
}
|
||||
};
|
||||
|
||||
const submitRescanRequest = async () => {
|
||||
if (!skill) return;
|
||||
try {
|
||||
await requestRescan({ skillId: skill._id });
|
||||
toast.success("Rescan requested.", {
|
||||
action: {
|
||||
label: "Dashboard",
|
||||
onClick: () => {
|
||||
window.location.href = "/dashboard";
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(getUserFacingConvexError(error, "Could not request a rescan."));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingSkill || wantsCanonicalRedirect) {
|
||||
return (
|
||||
<main className="section">
|
||||
<Card>
|
||||
<div className="loading-indicator">Loading skill…</div>
|
||||
</Card>
|
||||
<main className="section detail-page-section" aria-busy="true">
|
||||
<div role="status" aria-label="Loading skill details">
|
||||
<SkillDetailSkeleton />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (result === null || !skill) {
|
||||
return (
|
||||
<main className="section">
|
||||
<main className="section detail-page-section">
|
||||
<Card>Skill not found.</Card>
|
||||
</main>
|
||||
);
|
||||
@@ -353,10 +390,25 @@ export function SkillDetailPage({
|
||||
latestTagVersionId === null
|
||||
? []
|
||||
: tagEntries.filter(([, versionId]) => versionId !== latestTagVersionId);
|
||||
const securitySummary = latestVersion ? (
|
||||
<DetailSecuritySummary
|
||||
scannerBasePath={`/${encodeURIComponent(
|
||||
ownerParam ?? ownerHandle ?? "unknown",
|
||||
)}/${encodeURIComponent(skill.slug)}/security`}
|
||||
sha256hash={latestVersion.sha256hash ?? null}
|
||||
vtAnalysis={latestVersion.vtAnalysis ?? null}
|
||||
llmAnalysis={latestVersion.llmAnalysis ?? null}
|
||||
staticScan={latestVersion.staticScan ?? null}
|
||||
rescanState={rescanState ?? null}
|
||||
onRequestRescan={canViewOwnerRescanState ? submitRescanRequest : null}
|
||||
/>
|
||||
) : null;
|
||||
const detailPath = `/${encodeURIComponent(ownerParam ?? ownerHandle ?? "unknown")}/${encodeURIComponent(skill.slug)}`;
|
||||
const settingsHref = canManage ? `${detailPath}/settings` : null;
|
||||
|
||||
return (
|
||||
<main className="section">
|
||||
<div className="skill-detail-stack">
|
||||
<main className="section detail-page-section">
|
||||
<DetailPageShell>
|
||||
<SkillHeader
|
||||
skill={skill}
|
||||
owner={owner}
|
||||
@@ -384,102 +436,183 @@ export function SkillDetailPage({
|
||||
hasPluginBundle={hasPluginBundle}
|
||||
configRequirements={configRequirements}
|
||||
cliHelp={cliHelp}
|
||||
tagEntries={currentTagEntries}
|
||||
historicalTagEntries={historicalTagEntries}
|
||||
versionById={versionById}
|
||||
tagName={tagName}
|
||||
onTagNameChange={setTagName}
|
||||
tagVersionId={tagVersionId}
|
||||
onTagVersionChange={setTagVersionId}
|
||||
onTagSubmit={submitTag}
|
||||
onTagDelete={deleteTag}
|
||||
tagVersions={versions ?? []}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
/>
|
||||
sidebarContent={securitySummary}
|
||||
settingsHref={settingsHref}
|
||||
>
|
||||
{mode === "detail" ? (
|
||||
<>
|
||||
{nixSnippet ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Install via Nix
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">{nixSnippet}</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isOwner && skill ? (
|
||||
<SkillOwnershipPanel
|
||||
skillId={skill._id}
|
||||
slug={skill.slug}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={owner?._id ?? null}
|
||||
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
|
||||
/>
|
||||
) : null}
|
||||
{configExample ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Config example
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">{configExample}</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<SkillMetadataSidebar
|
||||
skill={skill}
|
||||
latestVersion={latestVersion}
|
||||
owner={owner}
|
||||
ownerHandle={ownerHandle}
|
||||
clawdis={clawdis}
|
||||
osLabels={osLabels}
|
||||
tagEntries={currentTagEntries}
|
||||
isMalwareBlocked={modInfo?.isMalwareBlocked}
|
||||
isRemoved={modInfo?.isRemoved}
|
||||
nixPlugin={nixPlugin}
|
||||
/>
|
||||
<SkillDetailTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
onCompareIntent={() => setShouldPrefetchCompare(true)}
|
||||
readmeContent={readmeContent}
|
||||
readmeError={readmeError}
|
||||
latestFiles={latestFiles}
|
||||
latestVersionId={latestVersion?._id ?? null}
|
||||
skill={skill as Doc<"skills">}
|
||||
diffVersions={diffVersions}
|
||||
versions={versions}
|
||||
nixPlugin={Boolean(nixPlugin)}
|
||||
suppressVersionScanResults={suppressVersionScanResults}
|
||||
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
|
||||
<div className="detail-content-full">
|
||||
{nixSnippet ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Install via Nix
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{nixSnippet}
|
||||
</pre>
|
||||
</Card>
|
||||
<Card className="skill-tag-card">
|
||||
<CardHeader>
|
||||
<CardTitle>Version tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="skill-tag-row">
|
||||
{currentTagEntries.length === 0 ? (
|
||||
<span className="section-subtitle m-0">No tags yet.</span>
|
||||
) : (
|
||||
currentTagEntries.map(([tag, versionId]) => (
|
||||
<Badge key={tag}>
|
||||
{tag}
|
||||
<span className="tag-meta">
|
||||
v{versionById.get(versionId)?.version ?? versionId}
|
||||
</span>
|
||||
{canManage && tag !== "latest" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tag-delete"
|
||||
onClick={() => deleteTag(tag)}
|
||||
aria-label={`Delete tag ${tag}`}
|
||||
title={`Delete tag "${tag}"`}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
) : null}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManage && historicalTagEntries.length > 0 ? (
|
||||
<div className="skill-tag-history">
|
||||
<div className="skill-tag-history-label">Historical tags</div>
|
||||
<div className="skill-tag-row">
|
||||
{historicalTagEntries.map(([tag, versionId]) => (
|
||||
<Badge key={tag}>
|
||||
{tag}
|
||||
<span className="tag-meta">
|
||||
v{versionById.get(versionId)?.version ?? versionId}
|
||||
</span>
|
||||
{tag !== "latest" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tag-delete"
|
||||
onClick={() => deleteTag(tag)}
|
||||
aria-label={`Delete tag ${tag}`}
|
||||
title={`Delete tag "${tag}"`}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
) : null}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage ? (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitTag();
|
||||
}}
|
||||
className="tag-form"
|
||||
>
|
||||
<input
|
||||
aria-label="Tag name"
|
||||
className="search-input"
|
||||
name="tagName"
|
||||
value={tagName}
|
||||
onChange={(event) => setTagName(event.target.value)}
|
||||
placeholder="latest..."
|
||||
/>
|
||||
<select
|
||||
aria-label="Tag version"
|
||||
className="search-input"
|
||||
name="tagVersion"
|
||||
value={tagVersionId ?? ""}
|
||||
onChange={(event) =>
|
||||
setTagVersionId(event.target.value as Id<"skillVersions">)
|
||||
}
|
||||
>
|
||||
{(versions ?? []).map((version) => (
|
||||
<option key={version._id} value={version._id}>
|
||||
v{version.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit">Update Tag</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{SHOW_SKILL_COMMENTS ? (
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Comments</h2>
|
||||
<p className="section-subtitle mt-3 mb-0">Loading comments...</p>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<SkillCommentsPanel
|
||||
skillId={skill._id}
|
||||
isAuthenticated={isAuthenticated}
|
||||
me={me ?? null}
|
||||
/>
|
||||
</ClientOnly>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</SkillHeader>
|
||||
|
||||
{configExample ? (
|
||||
<Card>
|
||||
<h3 className="m-0 text-[length:var(--text-base)] font-semibold">
|
||||
Config example
|
||||
</h3>
|
||||
<pre className="hero-install-code mt-2">
|
||||
{configExample}
|
||||
</pre>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<SkillDetailTabs
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
onCompareIntent={() => setShouldPrefetchCompare(true)}
|
||||
readmeContent={readmeContent}
|
||||
readmeError={readmeError}
|
||||
latestFiles={latestFiles}
|
||||
latestVersionId={latestVersion?._id ?? null}
|
||||
skill={skill as Doc<"skills">}
|
||||
diffVersions={diffVersions}
|
||||
versions={versions}
|
||||
nixPlugin={Boolean(nixPlugin)}
|
||||
suppressVersionScanResults={suppressVersionScanResults}
|
||||
scanResultsSuppressedMessage={scanResultsSuppressedMessage}
|
||||
/>
|
||||
|
||||
<ClientOnly
|
||||
fallback={
|
||||
{mode === "settings" ? (
|
||||
<DetailBody>
|
||||
{isOwner && skill ? (
|
||||
<SkillOwnershipPanel
|
||||
skillId={skill._id}
|
||||
slug={skill.slug}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={owner?._id ?? null}
|
||||
ownedSkills={(ownedSkills ?? []).filter((entry) => entry._id !== skill._id)}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<h2 className="section-title text-[1.2rem] m-0">
|
||||
Comments
|
||||
</h2>
|
||||
<h2 className="section-title text-[1.2rem] m-0">Settings unavailable</h2>
|
||||
<p className="section-subtitle mt-3 mb-0">
|
||||
Loading comments...
|
||||
Only the skill owner can manage these settings.
|
||||
</p>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<SkillCommentsPanel
|
||||
skillId={skill._id}
|
||||
isAuthenticated={isAuthenticated}
|
||||
me={me ?? null}
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DetailBody>
|
||||
) : null}
|
||||
</DetailPageShell>
|
||||
|
||||
<SkillReportDialog
|
||||
isOpen={isAuthenticated && isReportDialogOpen}
|
||||
|
||||
+162
-260
@@ -1,14 +1,16 @@
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Package } from "lucide-react";
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
|
||||
import { Calendar, Download, History, Package, Scale, Settings, Star, Upload } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
|
||||
import { type LlmAnalysis, SecurityScanResults } from "./SkillSecurityScanResults";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { DetailHero } from "./DetailPageShell";
|
||||
import { SkillInstallCard } from "./SkillInstallCard";
|
||||
import { SkillInstallSurface } from "./SkillInstallSurface";
|
||||
import { SkillCommandLineCard } from "./SkillInstallSurface";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { UserBadge } from "./UserBadge";
|
||||
@@ -63,18 +65,11 @@ type SkillHeaderProps = {
|
||||
hasPluginBundle: boolean;
|
||||
configRequirements: ClawdisSkillMetadata["config"] | undefined;
|
||||
cliHelp: string | undefined;
|
||||
tagEntries: Array<[string, Id<"skillVersions">]>;
|
||||
historicalTagEntries: Array<[string, Id<"skillVersions">]>;
|
||||
versionById: Map<Id<"skillVersions">, Doc<"skillVersions">>;
|
||||
tagName: string;
|
||||
onTagNameChange: (value: string) => void;
|
||||
tagVersionId: Id<"skillVersions"> | "";
|
||||
onTagVersionChange: (value: Id<"skillVersions"> | "") => void;
|
||||
onTagSubmit: () => void;
|
||||
onTagDelete: (tag: string) => void;
|
||||
tagVersions: Doc<"skillVersions">[];
|
||||
clawdis: ClawdisSkillMetadata | undefined;
|
||||
osLabels: string[];
|
||||
sidebarContent?: ReactNode;
|
||||
settingsHref?: string | null;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function SkillHeader({
|
||||
@@ -104,28 +99,13 @@ export function SkillHeader({
|
||||
hasPluginBundle,
|
||||
configRequirements,
|
||||
cliHelp,
|
||||
tagEntries,
|
||||
historicalTagEntries,
|
||||
versionById,
|
||||
tagName,
|
||||
onTagNameChange,
|
||||
tagVersionId,
|
||||
onTagVersionChange,
|
||||
onTagSubmit,
|
||||
onTagDelete,
|
||||
tagVersions,
|
||||
clawdis,
|
||||
osLabels,
|
||||
sidebarContent,
|
||||
settingsHref,
|
||||
children,
|
||||
}: SkillHeaderProps) {
|
||||
const formattedStats = formatSkillStatsTriplet(skill.stats);
|
||||
const suppressScanResults =
|
||||
!isStaff &&
|
||||
Boolean(modInfo?.overrideActive) &&
|
||||
!modInfo?.isMalwareBlocked &&
|
||||
!modInfo?.isSuspicious;
|
||||
const overrideScanMessage = suppressScanResults
|
||||
? "Security findings were reviewed by staff and cleared for public use."
|
||||
: null;
|
||||
const installOwnerId = owner?._id ?? skill.ownerPublisherId ?? skill.ownerUserId ?? null;
|
||||
|
||||
return (
|
||||
@@ -141,16 +121,6 @@ export function SkillHeader({
|
||||
</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>
|
||||
ClawHub Security 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">
|
||||
@@ -190,145 +160,169 @@ export function SkillHeader({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card skill-hero">
|
||||
<div className={`skill-hero-top${hasPluginBundle ? " has-plugin" : ""}`}>
|
||||
<div className="skill-hero-header">
|
||||
<div className="skill-hero-title">
|
||||
<div className="skill-hero-title-row">
|
||||
<h1 className="section-title m-0">
|
||||
{skill.displayName}
|
||||
</h1>
|
||||
{latestVersion?.version ? (
|
||||
<span className="plugin-version-badge">v{latestVersion.version}</span>
|
||||
) : null}
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
</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.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="skill-hero-inline-meta">
|
||||
<div className="skill-hero-stats-row">
|
||||
<span className="stat">⭐ {formattedStats.stars}</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat"><Package size={14} aria-hidden="true" /> {formattedStats.downloads}</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">{formatCompactStat(skill.stats.installsCurrent ?? 0)} current</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">{formattedStats.installsAllTime} all-time</span>
|
||||
</div>
|
||||
<div className="skill-hero-meta-row">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix="by"
|
||||
size="md"
|
||||
showName
|
||||
/>
|
||||
{forkOf && forkOfHref ? (
|
||||
<>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
{forkOfLabel}{" "}
|
||||
<a href={forkOfHref}>
|
||||
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
|
||||
{forkOf.skill.slug}
|
||||
</a>
|
||||
{forkOf.version ? ` (${forkOf.version})` : null}
|
||||
</span>
|
||||
</>
|
||||
<DetailHero
|
||||
topClassName={hasPluginBundle ? "has-plugin" : undefined}
|
||||
main={
|
||||
<>
|
||||
<div className="skill-hero-title">
|
||||
<div className="skill-hero-title-row">
|
||||
<h1 className="skill-page-title">{skill.displayName}</h1>
|
||||
{latestVersion?.version ? (
|
||||
<span className="plugin-version-badge">v{latestVersion.version}</span>
|
||||
) : null}
|
||||
{canonicalHref ? (
|
||||
<>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
canonical:{" "}
|
||||
<a href={canonicalHref}>
|
||||
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
|
||||
{canonical?.skill?.slug}
|
||||
</a>
|
||||
</span>
|
||||
</>
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
{canManage || isStaff || settingsHref ? (
|
||||
<div className="skill-title-actions">
|
||||
{isStaff ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/management" search={{ skill: skill.slug }}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage ? (
|
||||
<Button asChild variant="outline" size="sm" className="skill-settings-link">
|
||||
<Link to="/publish-skill" search={{ updateSlug: skill.slug }}>
|
||||
<Upload size={14} aria-hidden="true" />
|
||||
New Version
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{settingsHref ? (
|
||||
<Button asChild variant="outline" size="sm" className="skill-settings-link">
|
||||
<a href={settingsHref}>
|
||||
<Settings size={14} aria-hidden="true" />
|
||||
Settings
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</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.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="skill-hero-inline-meta">
|
||||
<div className="skill-hero-stats-row">
|
||||
<span className="stat">
|
||||
<Star size={14} aria-hidden="true" /> {formattedStats.stars}
|
||||
</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
<Download size={14} aria-hidden="true" /> {formattedStats.downloads}
|
||||
</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
<Package size={14} aria-hidden="true" /> {skill.stats.versions ?? 0} versions
|
||||
</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
<History size={14} aria-hidden="true" />{" "}
|
||||
{formatCompactStat(skill.stats.installsCurrent ?? 0)} current
|
||||
</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
<History size={14} aria-hidden="true" /> {formattedStats.installsAllTime}{" "}
|
||||
all-time
|
||||
</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
<Calendar size={14} aria-hidden="true" /> Updated {timeAgo(skill.updatedAt)}
|
||||
</span>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
<Scale size={14} aria-hidden="true" /> {PLATFORM_SKILL_LICENSE}
|
||||
</span>
|
||||
</div>
|
||||
<div className="skill-hero-meta-row">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix="by"
|
||||
size="md"
|
||||
showName
|
||||
/>
|
||||
{forkOf && forkOfHref ? (
|
||||
<>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
{forkOfLabel}{" "}
|
||||
<a href={forkOfHref}>
|
||||
{forkOfOwnerHandle ? `@${forkOfOwnerHandle}/` : ""}
|
||||
{forkOf.skill.slug}
|
||||
</a>
|
||||
{forkOf.version ? ` (${forkOf.version})` : null}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{canonicalHref ? (
|
||||
<>
|
||||
<span className="text-ink-soft opacity-40">·</span>
|
||||
<span className="stat">
|
||||
canonical:{" "}
|
||||
<a href={canonicalHref}>
|
||||
{canonicalOwnerHandle ? `@${canonicalOwnerHandle}/` : ""}
|
||||
{canonical?.skill?.slug}
|
||||
</a>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skill-hero-badges">
|
||||
{getSkillBadges(skill).map((badge) => (
|
||||
<Badge key={badge} variant="compact">
|
||||
{badge}
|
||||
</Badge>
|
||||
))}
|
||||
{isStaff && staffVisibilityTag ? (
|
||||
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
|
||||
{staffVisibilityTag}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skill-hero-badges">
|
||||
{getSkillBadges(skill).map((badge) => (
|
||||
<Badge key={badge} variant="compact">
|
||||
{badge}
|
||||
</Badge>
|
||||
))}
|
||||
{isStaff && staffVisibilityTag ? (
|
||||
<Badge variant={isAutoHidden || isRemoved ? "accent" : "compact"}>
|
||||
{staffVisibilityTag}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="skill-hero-sidebar">
|
||||
<div className="skill-actions">
|
||||
{isAuthenticated ? (
|
||||
</>
|
||||
}
|
||||
sidebar={
|
||||
<>
|
||||
{isAuthenticated ? (
|
||||
<div className="skill-actions">
|
||||
<button
|
||||
className={`star-toggle${isStarred ? " is-active" : ""}`}
|
||||
type="button"
|
||||
onClick={onToggleStar}
|
||||
aria-label={isStarred ? "Unstar skill" : "Star skill"}
|
||||
>
|
||||
<span aria-hidden="true">★</span>
|
||||
<Star size={16} aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
{isAuthenticated ? (
|
||||
<Button variant="ghost" size="sm" type="button" onClick={onOpenReport}>
|
||||
Report
|
||||
</Button>
|
||||
) : null}
|
||||
{isStaff ? (
|
||||
<Button asChild size="sm">
|
||||
<Link to="/management" search={{ skill: skill.slug }}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkillInstallSurface
|
||||
slug={skill.slug}
|
||||
displayName={skill.displayName}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={installOwnerId}
|
||||
clawdis={clawdis}
|
||||
/>
|
||||
|
||||
{/* Security scan — full width below the header columns */}
|
||||
{suppressScanResults ? (
|
||||
<div className="skill-hero-note">{overrideScanMessage}</div>
|
||||
) : latestVersion?.sha256hash ||
|
||||
latestVersion?.llmAnalysis ||
|
||||
(latestVersion?.staticScan?.findings?.length ?? 0) > 0 ||
|
||||
(latestVersion?.capabilityTags?.length ?? 0) > 0 ? (
|
||||
<div className="skill-hero-scan-row">
|
||||
<SecurityScanResults
|
||||
sha256hash={latestVersion?.sha256hash}
|
||||
vtAnalysis={latestVersion?.vtAnalysis}
|
||||
llmAnalysis={latestVersion?.llmAnalysis as LlmAnalysis | undefined}
|
||||
staticFindings={latestVersion?.staticScan?.findings}
|
||||
capabilityTags={latestVersion?.capabilityTags}
|
||||
</div>
|
||||
) : null}
|
||||
{sidebarContent}
|
||||
<SkillCommandLineCard
|
||||
slug={skill.slug}
|
||||
displayName={skill.displayName}
|
||||
ownerHandle={ownerHandle}
|
||||
ownerId={installOwnerId}
|
||||
clawdis={clawdis}
|
||||
/>
|
||||
<p className="scan-disclaimer">
|
||||
Like a lobster shell, security has layers — review code before you run it.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
|
||||
{hasPluginBundle ? (
|
||||
<div className="skill-panel bundle-card">
|
||||
<div className="bundle-header">
|
||||
@@ -368,99 +362,7 @@ export function SkillHeader({
|
||||
</div>
|
||||
) : null}
|
||||
<SkillInstallCard clawdis={clawdis} osLabels={osLabels} />
|
||||
</div>
|
||||
|
||||
<div className="skill-tag-row">
|
||||
{tagEntries.length === 0 ? (
|
||||
<span className="section-subtitle m-0">
|
||||
No tags yet.
|
||||
</span>
|
||||
) : (
|
||||
tagEntries.map(([tag, versionId]) => (
|
||||
<Badge key={tag}>
|
||||
{tag}
|
||||
<span className="tag-meta">
|
||||
v{versionById.get(versionId)?.version ?? versionId}
|
||||
</span>
|
||||
{canManage && tag !== "latest" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tag-delete"
|
||||
onClick={() => onTagDelete(tag)}
|
||||
aria-label={`Delete tag ${tag}`}
|
||||
title={`Delete tag "${tag}"`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManage && historicalTagEntries.length > 0 ? (
|
||||
<div className="skill-tag-history">
|
||||
<div className="skill-tag-history-label">Historical tags</div>
|
||||
<div className="skill-tag-row">
|
||||
{historicalTagEntries.map(([tag, versionId]) => (
|
||||
<Badge key={tag}>
|
||||
{tag}
|
||||
<span className="tag-meta">
|
||||
v{versionById.get(versionId)?.version ?? versionId}
|
||||
</span>
|
||||
{tag !== "latest" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="tag-delete"
|
||||
onClick={() => onTagDelete(tag)}
|
||||
aria-label={`Delete tag ${tag}`}
|
||||
title={`Delete tag "${tag}"`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage ? (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onTagSubmit();
|
||||
}}
|
||||
className="tag-form"
|
||||
>
|
||||
<input
|
||||
aria-label="Tag name"
|
||||
className="search-input"
|
||||
name="tagName"
|
||||
value={tagName}
|
||||
onChange={(event) => onTagNameChange(event.target.value)}
|
||||
placeholder="latest…"
|
||||
/>
|
||||
<select
|
||||
aria-label="Tag version"
|
||||
className="search-input"
|
||||
name="tagVersion"
|
||||
value={tagVersionId ?? ""}
|
||||
onChange={(event) => onTagVersionChange(event.target.value as Id<"skillVersions">)}
|
||||
>
|
||||
{tagVersions.map((version) => (
|
||||
<option key={version._id} value={version._id}>
|
||||
v{version.version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit">
|
||||
Update Tag
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
</DetailHero>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SkillInstallSurface } from "./SkillInstallSurface";
|
||||
import { SkillCommandLineCard, SkillInstallSurface } from "./SkillInstallSurface";
|
||||
|
||||
const writeTextMock = vi.fn();
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("SkillInstallSurface", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Install with OpenClaw" })).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "CLI Commands" })).toBeTruthy();
|
||||
expect(screen.queryByRole("heading", { name: "CLI Commands" })).toBeNull();
|
||||
expect(screen.getByText(/After install, inspect the skill metadata/i)).toBeTruthy();
|
||||
expect(screen.getAllByText("Install & Setup").length).toBeGreaterThan(0);
|
||||
|
||||
@@ -72,9 +72,9 @@ describe("SkillInstallSurface", () => {
|
||||
expect(screen.getAllByText("Install Only").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("switches the ClawHub command and copies the visible CLI command", async () => {
|
||||
it("defaults to CLI install and can copy the compact prompt tab", async () => {
|
||||
render(
|
||||
<SkillInstallSurface
|
||||
<SkillCommandLineCard
|
||||
slug="weather"
|
||||
displayName="Weather"
|
||||
ownerHandle="steipete"
|
||||
@@ -82,13 +82,32 @@ describe("SkillInstallSurface", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use pnpm for ClawHub install command" }));
|
||||
expect(screen.getByText("pnpm dlx clawhub@latest install weather")).toBeTruthy();
|
||||
expect(screen.getByText("openclaw skills install weather")).toBeTruthy();
|
||||
expect(screen.queryByText("npx clawhub@latest install weather")).toBeNull();
|
||||
expect(screen.getByRole("tab", { name: "CLI" }).getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe(
|
||||
"false",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy ClawHub CLI command" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy OpenClaw CLI command" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeTextMock).toHaveBeenCalledWith("pnpm dlx clawhub@latest install weather");
|
||||
expect(writeTextMock).toHaveBeenCalledWith("openclaw skills install weather");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Prompt" }));
|
||||
|
||||
expect(screen.getByText(/Install the skill "Weather"/i)).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: "Prompt" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy OpenClaw prompt" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeTextMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("After install, inspect the skill metadata"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,8 @@ import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { copyText, InstallCopyButton } from "./InstallCopyButton";
|
||||
import {
|
||||
buildSkillInstallTarget,
|
||||
formatClawHubInstallCommand,
|
||||
formatOpenClawInstallCommand,
|
||||
formatOpenClawPrompt,
|
||||
type SkillPackageManager,
|
||||
type SkillPromptMode,
|
||||
} from "./skillDetailUtils";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -19,8 +17,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
|
||||
const PACKAGE_MANAGERS: SkillPackageManager[] = ["npm", "pnpm", "bun"];
|
||||
|
||||
const PROMPT_OPTIONS: Array<{
|
||||
description: string;
|
||||
label: string;
|
||||
@@ -56,7 +52,6 @@ export function SkillInstallSurface({
|
||||
clawdis,
|
||||
}: SkillInstallSurfaceProps) {
|
||||
const headingId = useId();
|
||||
const [packageManager, setPackageManager] = useState<SkillPackageManager>("npm");
|
||||
const [promptMode, setPromptMode] = useState<SkillPromptMode>("install-and-setup");
|
||||
const [promptCopyState, setPromptCopyState] = useState<PromptCopyState>("idle");
|
||||
const promptResetTimeoutRef = useRef<number | null>(null);
|
||||
@@ -81,10 +76,9 @@ export function SkillInstallSurface({
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const selectedPrompt = PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1];
|
||||
const selectedPrompt =
|
||||
PROMPT_OPTIONS.find((option) => option.mode === promptMode) ?? PROMPT_OPTIONS[1];
|
||||
const installTarget = buildSkillInstallTarget(ownerHandle, ownerId, slug);
|
||||
const openClawCommand = formatOpenClawInstallCommand(slug);
|
||||
const clawHubCommand = formatClawHubInstallCommand(slug, packageManager);
|
||||
const promptPreview = formatOpenClawPrompt({
|
||||
mode: promptMode,
|
||||
skillName: displayName,
|
||||
@@ -130,109 +124,120 @@ export function SkillInstallSurface({
|
||||
Install
|
||||
</h2>
|
||||
|
||||
<div className="skill-install-grid">
|
||||
<article className="skill-install-panel">
|
||||
<div className="skill-install-panel-header">
|
||||
<p className="skill-install-kicker">OpenClaw Prompt Flow</p>
|
||||
<h3 className="skill-install-panel-title">Install with OpenClaw</h3>
|
||||
<p className="skill-install-panel-copy">
|
||||
Best for remote or guided setup. Copy the exact prompt, then paste it into OpenClaw
|
||||
for <code translate="no">{installTarget}</code>.
|
||||
</p>
|
||||
</div>
|
||||
<article className="skill-install-panel">
|
||||
<div className="skill-install-panel-header">
|
||||
<p className="skill-install-kicker">OpenClaw Prompt Flow</p>
|
||||
<h3 className="skill-install-panel-title">Install with OpenClaw</h3>
|
||||
<p className="skill-install-panel-copy">
|
||||
Best for remote or guided setup. Copy the exact prompt, then paste it into OpenClaw
|
||||
for <code translate="no">{installTarget}</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="skill-install-actions">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" className="skill-install-prompt-trigger">
|
||||
<span>Copy Prompt</span>
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="skill-install-menu">
|
||||
{PROMPT_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.mode} onSelect={() => selectPromptMode(option.mode)}>
|
||||
<div className="skill-install-menu-copy">
|
||||
<span className="skill-install-menu-label">{option.label}</span>
|
||||
<span className="skill-install-menu-description">{option.description}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="skill-install-copy-feedback" aria-live="polite">
|
||||
{promptFeedback}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="skill-install-preview-meta">
|
||||
<span className="skill-install-preview-label">Prompt Preview</span>
|
||||
<span className="skill-install-preview-mode">{selectedPrompt.label}</span>
|
||||
</div>
|
||||
|
||||
<pre className="skill-install-prompt-preview">
|
||||
<code translate="no">{promptPreview}</code>
|
||||
</pre>
|
||||
</article>
|
||||
|
||||
<article className="skill-install-panel">
|
||||
<div className="skill-install-panel-header">
|
||||
<p className="skill-install-kicker">Command Line</p>
|
||||
<h3 className="skill-install-panel-title">CLI Commands</h3>
|
||||
<p className="skill-install-panel-copy">
|
||||
Use the direct CLI path if you want to install manually and keep every step visible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="skill-install-command-card">
|
||||
<div className="skill-install-command-header">
|
||||
<div className="skill-install-command-copy">
|
||||
<p className="skill-install-command-label">OpenClaw CLI</p>
|
||||
<p className="skill-install-command-caption">Bare skill slug</p>
|
||||
</div>
|
||||
<InstallCopyButton
|
||||
text={openClawCommand}
|
||||
ariaLabel="Copy OpenClaw CLI command"
|
||||
/>
|
||||
</div>
|
||||
<pre className="skill-install-command">
|
||||
<code translate="no">{openClawCommand}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="skill-install-command-card">
|
||||
<div className="skill-install-command-header">
|
||||
<div className="skill-install-command-copy">
|
||||
<p className="skill-install-command-label">ClawHub CLI</p>
|
||||
<p className="skill-install-command-caption">Package manager switcher</p>
|
||||
</div>
|
||||
<InstallCopyButton
|
||||
text={clawHubCommand}
|
||||
ariaLabel="Copy ClawHub CLI command"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="install-switcher-toggle" aria-label="ClawHub install command">
|
||||
{PACKAGE_MANAGERS.map((entry) => (
|
||||
<button
|
||||
key={entry}
|
||||
type="button"
|
||||
aria-label={`Use ${entry} for ClawHub install command`}
|
||||
aria-pressed={packageManager === entry}
|
||||
className={`install-switcher-pill${packageManager === entry ? " is-active" : ""}`}
|
||||
onClick={() => setPackageManager(entry)}
|
||||
>
|
||||
{entry}
|
||||
</button>
|
||||
<div className="skill-install-actions">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" className="skill-install-prompt-trigger">
|
||||
<span>Copy Prompt</span>
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="skill-install-menu">
|
||||
{PROMPT_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.mode} onSelect={() => selectPromptMode(option.mode)}>
|
||||
<div className="skill-install-menu-copy">
|
||||
<span className="skill-install-menu-label">{option.label}</span>
|
||||
<span className="skill-install-menu-description">{option.description}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="skill-install-copy-feedback" aria-live="polite">
|
||||
{promptFeedback}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<pre className="skill-install-command">
|
||||
<code translate="no">{clawHubCommand}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div className="skill-install-preview-meta">
|
||||
<span className="skill-install-preview-label">Prompt Preview</span>
|
||||
<span className="skill-install-preview-mode">{selectedPrompt.label}</span>
|
||||
</div>
|
||||
|
||||
<pre className="skill-install-prompt-preview">
|
||||
<code translate="no">{promptPreview}</code>
|
||||
</pre>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillCommandLineCard({
|
||||
slug,
|
||||
displayName,
|
||||
ownerHandle,
|
||||
ownerId,
|
||||
clawdis,
|
||||
}: SkillInstallSurfaceProps) {
|
||||
const [activeInstallTab, setActiveInstallTab] = useState<"cli" | "prompt">("cli");
|
||||
const openClawCommand = formatOpenClawInstallCommand(slug);
|
||||
const promptPreview = formatOpenClawPrompt({
|
||||
mode: "install-and-setup",
|
||||
skillName: displayName,
|
||||
slug,
|
||||
ownerHandle,
|
||||
ownerId,
|
||||
clawdis,
|
||||
});
|
||||
|
||||
return (
|
||||
<article className="skill-install-command-card">
|
||||
<div className="skill-install-command-header">
|
||||
<h3 className="skill-install-panel-title">Install</h3>
|
||||
<div className="install-switcher-toggle" role="tablist" aria-label="Install option">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeInstallTab === "cli"}
|
||||
className={`install-switcher-pill${activeInstallTab === "cli" ? " is-active" : ""}`}
|
||||
onClick={() => setActiveInstallTab("cli")}
|
||||
>
|
||||
CLI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeInstallTab === "prompt"}
|
||||
className={`install-switcher-pill${
|
||||
activeInstallTab === "prompt" ? " is-active" : ""
|
||||
}`}
|
||||
onClick={() => setActiveInstallTab("prompt")}
|
||||
>
|
||||
Prompt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="skill-install-command-wrap">
|
||||
<pre
|
||||
className={`skill-install-command${
|
||||
activeInstallTab === "prompt" ? " skill-install-prompt-compact" : ""
|
||||
}`}
|
||||
>
|
||||
<code translate="no">
|
||||
{activeInstallTab === "prompt" ? promptPreview : openClawCommand}
|
||||
</code>
|
||||
</pre>
|
||||
<InstallCopyButton
|
||||
text={activeInstallTab === "prompt" ? promptPreview : openClawCommand}
|
||||
ariaLabel={
|
||||
activeInstallTab === "prompt"
|
||||
? "Copy OpenClaw prompt"
|
||||
: "Copy OpenClaw CLI command"
|
||||
}
|
||||
className="skill-install-command-inline-button"
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { ClawdisSkillMetadata } from "clawhub-schema";
|
||||
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
|
||||
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { formatCompactStat } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { getRuntimeEnv } from "../lib/runtimeEnv";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { UserBadge } from "./UserBadge";
|
||||
|
||||
type SkillMetadataSidebarProps = {
|
||||
skill: PublicSkill;
|
||||
latestVersion: { version?: string; _id: Id<"skillVersions"> } | null;
|
||||
owner: PublicPublisher | null;
|
||||
ownerHandle: string | null;
|
||||
clawdis?: ClawdisSkillMetadata;
|
||||
osLabels: string[];
|
||||
tagEntries: Array<[string, Id<"skillVersions">]>;
|
||||
isMalwareBlocked?: boolean;
|
||||
isRemoved?: boolean;
|
||||
nixPlugin?: string;
|
||||
};
|
||||
|
||||
export function SkillMetadataSidebar({
|
||||
skill,
|
||||
latestVersion,
|
||||
owner,
|
||||
ownerHandle,
|
||||
clawdis: _clawdis,
|
||||
osLabels,
|
||||
tagEntries,
|
||||
isMalwareBlocked,
|
||||
isRemoved,
|
||||
nixPlugin,
|
||||
}: SkillMetadataSidebarProps) {
|
||||
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
|
||||
const showDownload = !nixPlugin && !isMalwareBlocked && !isRemoved;
|
||||
|
||||
return (
|
||||
<div className="detail-meta-bar">
|
||||
{/* Stats row */}
|
||||
<div className="meta-bar-stats">
|
||||
<div className="meta-stat">
|
||||
<Download size={14} aria-hidden="true" />
|
||||
<span className="meta-stat-value">{formatCompactStat(skill.stats.downloads)}</span>
|
||||
<span className="meta-stat-label">downloads</span>
|
||||
</div>
|
||||
<div className="meta-stat">
|
||||
<Star size={14} aria-hidden="true" />
|
||||
<span className="meta-stat-value">{formatCompactStat(skill.stats.stars)}</span>
|
||||
<span className="meta-stat-label">stars</span>
|
||||
</div>
|
||||
<div className="meta-stat">
|
||||
<Package size={14} aria-hidden="true" />
|
||||
<span className="meta-stat-value">{formatCompactStat(skill.stats.versions ?? 0)}</span>
|
||||
<span className="meta-stat-label">versions</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details row */}
|
||||
<div className="meta-bar-details">
|
||||
<div className="meta-detail">
|
||||
<Calendar size={12} aria-hidden="true" />
|
||||
<span>Updated {timeAgo(skill.updatedAt)}</span>
|
||||
</div>
|
||||
{latestVersion?.version ? (
|
||||
<div className="meta-detail">
|
||||
<Tag size={12} aria-hidden="true" />
|
||||
<span>v{latestVersion.version}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="meta-detail">
|
||||
<Scale size={12} aria-hidden="true" />
|
||||
<span>{PLATFORM_SKILL_LICENSE}</span>
|
||||
</div>
|
||||
{osLabels.length > 0 ? (
|
||||
<div className="meta-detail">
|
||||
<span>{osLabels.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Tags and Publisher row */}
|
||||
<div className="meta-bar-footer">
|
||||
<div className="meta-bar-publisher">
|
||||
<UserBadge
|
||||
user={owner}
|
||||
fallbackHandle={ownerHandle}
|
||||
prefix=""
|
||||
size="sm"
|
||||
showName
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tagEntries.length > 0 ? (
|
||||
<div className="meta-bar-tags">
|
||||
{tagEntries.map(([tag]) => (
|
||||
<Badge key={tag} variant="compact">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showDownload ? (
|
||||
<Button asChild variant="primary" size="sm">
|
||||
<a href={`${convexSiteUrl}/api/v1/download?slug=${skill.slug}`}>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
@@ -53,10 +54,11 @@ type SecurityScanResultsProps = {
|
||||
llmAnalysis?: LlmAnalysis | null;
|
||||
staticFindings?: StaticFinding[];
|
||||
capabilityTags?: string[] | null;
|
||||
scannerBasePath?: string | null;
|
||||
variant?: "panel" | "badge";
|
||||
};
|
||||
|
||||
function VirusTotalIcon({ className }: { className?: string }) {
|
||||
export function VirusTotalIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
@@ -76,36 +78,11 @@ function VirusTotalIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function OpenClawIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-label="OpenClaw"
|
||||
>
|
||||
<title>OpenClaw</title>
|
||||
<path
|
||||
d="M12 2C8.5 2 5.5 4 4 7c-2 4-1 8 2 11 1.5 1.5 3.5 2.5 6 2.5s4.5-1 6-2.5c3-3 4-7 2-11-1.5-3-4.5-5-8-5z"
|
||||
fill="currentColor"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M9 8c1-2 3-3 5-2s3 3 2 5l-3 4-2-1 3-4c.5-1 0-2-1-2.5S11 7 10.5 8L8 12l-2-1 3-4z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M15 8c-1-2-3-3-5-2s-3 3-2 5l3 4 2-1-3-4c-.5-1 0-2 1-2.5S14 7 14.5 8L17 12l2-1-4-3z"
|
||||
fill="currentColor"
|
||||
opacity="0.6"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export function ClawScanIcon({ className }: { className?: string }) {
|
||||
return <ShieldCheck className={className} aria-label="ClawScan" />;
|
||||
}
|
||||
|
||||
function getScanStatusInfo(status: string) {
|
||||
export function getScanStatusInfo(status: string) {
|
||||
switch (status.toLowerCase()) {
|
||||
case "benign":
|
||||
case "clean":
|
||||
@@ -250,7 +227,7 @@ function getStaticGuidance(findings: StaticFinding[], vtStatus?: string, llmStat
|
||||
return {
|
||||
className: "benign",
|
||||
label: "Confirmed safe by external scanners",
|
||||
text: "Static analysis detected API credential-access patterns, but both VirusTotal and OpenClaw confirmed this skill is safe. These patterns are common in legitimate API integration skills.",
|
||||
text: "Static analysis detected API credential-access patterns, but both VirusTotal and ClawScan confirmed this skill is safe. These patterns are common in legitimate API integration skills.",
|
||||
};
|
||||
}
|
||||
const hasCritical = findings.some((f) => f.severity === "critical");
|
||||
@@ -258,13 +235,13 @@ function getStaticGuidance(findings: StaticFinding[], vtStatus?: string, llmStat
|
||||
return {
|
||||
className: "suspicious",
|
||||
label: "Patterns worth reviewing",
|
||||
text: "These patterns may indicate risky behavior. Check the VirusTotal and OpenClaw results above for context-aware analysis before installing.",
|
||||
text: "These patterns may indicate risky behavior. Check the VirusTotal and ClawScan results above for context-aware analysis before installing.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
className: "benign",
|
||||
label: "About static analysis",
|
||||
text: "These patterns were detected by automated regex scanning. They may be normal for skills that integrate with external APIs. Check the VirusTotal and OpenClaw results above for context-aware analysis.",
|
||||
text: "These patterns were detected by automated regex scanning. They may be normal for skills that integrate with external APIs. Check the VirusTotal and ClawScan results above for context-aware analysis.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,6 +311,7 @@ export function SecurityScanResults({
|
||||
llmAnalysis,
|
||||
staticFindings,
|
||||
capabilityTags,
|
||||
scannerBasePath,
|
||||
variant = "panel",
|
||||
}: SecurityScanResultsProps) {
|
||||
const visibleCapabilityTags = (capabilityTags ?? []).filter(Boolean);
|
||||
@@ -369,12 +347,30 @@ export function SecurityScanResults({
|
||||
↗
|
||||
</a>
|
||||
) : null}
|
||||
{scannerBasePath ? (
|
||||
<a
|
||||
href={`${scannerBasePath}/virustotal`}
|
||||
className="version-scan-link"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
Details
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{llmStatusInfo ? (
|
||||
<div className="version-scan-badge">
|
||||
<OpenClawIcon className="version-scan-icon version-scan-icon-oc" />
|
||||
<ClawScanIcon className="version-scan-icon version-scan-icon-oc" />
|
||||
<span className={llmStatusInfo.className}>{llmStatusInfo.label}</span>
|
||||
{scannerBasePath ? (
|
||||
<a
|
||||
href={`${scannerBasePath}/openclaw`}
|
||||
className="version-scan-link"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
Details
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
@@ -420,6 +416,11 @@ export function SecurityScanResults({
|
||||
View report →
|
||||
</a>
|
||||
) : null}
|
||||
{scannerBasePath ? (
|
||||
<a href={`${scannerBasePath}/virustotal`} className="scan-result-link">
|
||||
Details →
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{isCodeInsight && aiAnalysis && (vtStatus === "malicious" || vtStatus === "suspicious") ? (
|
||||
@@ -431,8 +432,8 @@ export function SecurityScanResults({
|
||||
{llmStatusInfo && llmAnalysis ? (
|
||||
<div className="scan-result-row">
|
||||
<div className="scan-result-scanner">
|
||||
<OpenClawIcon className="scan-result-icon scan-result-icon-oc" />
|
||||
<span className="scan-result-scanner-name">OpenClaw</span>
|
||||
<ClawScanIcon className="scan-result-icon scan-result-icon-oc" />
|
||||
<span className="scan-result-scanner-name">ClawScan</span>
|
||||
</div>
|
||||
<div className={`scan-result-status ${llmStatusInfo.className}`}>
|
||||
{llmStatusInfo.label}
|
||||
@@ -440,6 +441,11 @@ export function SecurityScanResults({
|
||||
{llmAnalysis.confidence ? (
|
||||
<span className="scan-result-confidence">{llmAnalysis.confidence} confidence</span>
|
||||
) : null}
|
||||
{scannerBasePath ? (
|
||||
<a href={`${scannerBasePath}/openclaw`} className="scan-result-link">
|
||||
Details →
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{llmAnalysis &&
|
||||
@@ -449,11 +455,26 @@ export function SecurityScanResults({
|
||||
<LlmAnalysisDetail analysis={llmAnalysis} />
|
||||
) : null}
|
||||
{staticFindings && staticFindings.length > 0 ? (
|
||||
<StaticAnalysisDetail
|
||||
findings={staticFindings}
|
||||
vtStatus={vtStatus}
|
||||
llmStatus={llmVerdict}
|
||||
/>
|
||||
<>
|
||||
{scannerBasePath ? (
|
||||
<div className="scan-result-row">
|
||||
<div className="scan-result-scanner">
|
||||
<span className="scan-result-scanner-name">Static analysis</span>
|
||||
</div>
|
||||
<div className="scan-result-status scan-status-suspicious">
|
||||
{staticFindings.length} finding{staticFindings.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
<a href={`${scannerBasePath}/static-analysis`} className="scan-result-link">
|
||||
Details →
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<StaticAnalysisDetail
|
||||
findings={staticFindings}
|
||||
vtStatus={vtStatus}
|
||||
llmStatus={llmVerdict}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,38 +2,37 @@ import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-page-max px-7 py-10">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-52" />
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="h-[44px] w-32 rounded-[var(--radius-pill)]" />
|
||||
<Skeleton className="h-[44px] w-36 rounded-[var(--radius-pill)]" />
|
||||
<main className="section">
|
||||
<div className="dashboard-header">
|
||||
<div className="grid gap-2">
|
||||
<Skeleton className="h-8 w-36" />
|
||||
<Skeleton className="h-4 w-72 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div className="dashboard-owner-grid">
|
||||
{["skills", "plugins"].map((section) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-2 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5"
|
||||
key={section}
|
||||
className="dashboard-owner-panel flex w-full flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-space-5"
|
||||
>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-7 w-16" />
|
||||
<div className="dashboard-section-header">
|
||||
<Skeleton className="h-7 w-24" />
|
||||
<Skeleton className="h-[34px] w-28 rounded-[var(--r-btn)]" />
|
||||
</div>
|
||||
<div className="dashboard-list">
|
||||
{Array.from({ length: section === "skills" ? 2 : 3 }, (_, index) => (
|
||||
<div key={index} className="dashboard-list-row">
|
||||
<Skeleton className="h-5 w-48 max-w-full" />
|
||||
<Skeleton className="h-5 w-96 max-w-full" />
|
||||
<Skeleton className="h-8 w-24 rounded-[var(--radius-pill)]" />
|
||||
<Skeleton className="h-8 w-8 rounded-[var(--r-btn)]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Publisher tabs */}
|
||||
<Skeleton className="mb-6 h-[44px] w-64 rounded-[var(--radius-pill)]" />
|
||||
|
||||
{/* Table skeleton */}
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-[var(--radius-sm)]" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,71 +2,64 @@ import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export function SkillDetailSkeleton() {
|
||||
return (
|
||||
<div className="mx-auto max-w-page-max px-7 py-10">
|
||||
{/* Breadcrumb */}
|
||||
<Skeleton className="mb-6 h-4 w-48" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_340px]">
|
||||
{/* Main column */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-6 w-16 rounded-[var(--radius-pill)]" />
|
||||
<div className="skill-detail-stack">
|
||||
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-4">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Skeleton className="h-9 w-full max-w-[360px]" />
|
||||
<Skeleton className="h-6 w-20 rounded-[var(--r-pill)]" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-full max-w-[680px]" />
|
||||
<Skeleton className="h-5 w-3/4 max-w-[520px]" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-full max-w-lg" />
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* README skeleton */}
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-6">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="mt-2 h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
|
||||
{/* Tabs skeleton */}
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-[44px] w-24 rounded-[var(--radius-pill)]" />
|
||||
<Skeleton className="h-[44px] w-28 rounded-[var(--radius-pill)]" />
|
||||
<Skeleton className="h-[44px] w-24 rounded-[var(--radius-pill)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Install card */}
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full rounded-[var(--radius-sm)]" />
|
||||
<Skeleton className="h-10 w-full rounded-[var(--radius-pill)]" />
|
||||
</div>
|
||||
{/* Stats card */}
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<div className="w-full space-y-3 lg:max-w-[360px]">
|
||||
<Skeleton className="h-12 w-full rounded-[var(--r-sm)]" />
|
||||
<Skeleton className="h-12 w-full rounded-[var(--r-pill)]" />
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-12" />
|
||||
<Skeleton className="h-14" />
|
||||
<Skeleton className="h-14" />
|
||||
<Skeleton className="h-14" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Security card */}
|
||||
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-layout">
|
||||
<div className="detail-main">
|
||||
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Skeleton className="h-10 w-24 rounded-[var(--r-pill)]" />
|
||||
<Skeleton className="h-10 w-20 rounded-[var(--r-pill)]" />
|
||||
<Skeleton className="h-10 w-24 rounded-[var(--r-pill)]" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-11/12" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="mt-5 h-5 w-52" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[var(--r-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5">
|
||||
<Skeleton className="mb-3 h-6 w-28" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { buttonVariants } from "./button";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-80 bg-overlay-bg backdrop-blur-[3px]",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,520px)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 text-[color:var(--ink)] shadow-dialog outline-none",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col gap-1.5", className)} {...props} />
|
||||
);
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("font-display text-lg font-bold text-[color:var(--ink)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-[color:var(--ink-soft)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: "primary" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
@@ -1,12 +1,55 @@
|
||||
import { Slot, Slottable } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
const buttonVariants = cva(
|
||||
[
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
|
||||
"cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-60",
|
||||
"!no-underline hover:!no-underline",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--surface-muted)]",
|
||||
primary:
|
||||
"border border-[color:var(--border-ui)] bg-[color:var(--surface-muted)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--hover-bg)]",
|
||||
secondary:
|
||||
"border border-[color:var(--line)] bg-[color:var(--surface-muted)] text-[color:var(--ink)] hover:not-disabled:bg-[color:var(--hover-bg)]",
|
||||
destructive:
|
||||
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg",
|
||||
ghost:
|
||||
"border border-transparent bg-transparent text-[color:var(--ink-soft)] hover:not-disabled:bg-[color:var(--surface-muted)] hover:not-disabled:text-[color:var(--ink)]",
|
||||
outline:
|
||||
"border border-[color:var(--border-ui)] bg-transparent text-[color:var(--ink)] hover:not-disabled:border-[color:var(--border-ui-hover)] hover:not-disabled:bg-[color:var(--surface)]",
|
||||
link: "h-auto border border-transparent bg-transparent p-0 text-[color:var(--accent-deep)] underline-offset-4 hover:underline disabled:opacity-60",
|
||||
},
|
||||
size: {
|
||||
default: "min-h-[44px] rounded-[var(--r-btn)] px-4 py-[11px] text-sm",
|
||||
xs: "min-h-[30px] rounded-[var(--r-btn)] px-2.5 py-1 text-xs",
|
||||
sm: "min-h-[34px] rounded-[var(--r-btn)] px-3 py-1.5 text-xs",
|
||||
lg: "min-h-[52px] rounded-[var(--r-btn)] px-6 py-3 text-base",
|
||||
icon: "h-[44px] w-[44px] rounded-[var(--r-btn)] p-0",
|
||||
"icon-xs": "h-7 w-7 rounded-[var(--r-btn)] p-0",
|
||||
"icon-sm": "h-8 w-8 rounded-[var(--r-btn)] p-0",
|
||||
"icon-lg": "h-12 w-12 rounded-[var(--r-btn)] p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
/** Render as the child element (e.g. a Link) instead of a <button>. */
|
||||
asChild?: boolean;
|
||||
variant?: "default" | "primary" | "destructive" | "ghost" | "outline";
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
@@ -28,36 +71,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(
|
||||
// Base styles matching .btn
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-200 ease-out",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
|
||||
"disabled:pointer-events-none disabled:opacity-60",
|
||||
// Hover lift
|
||||
"hover:not-disabled:-translate-y-px hover:not-disabled:shadow-hover",
|
||||
// Variant styles
|
||||
variant === "default" &&
|
||||
"border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)]",
|
||||
variant === "primary" &&
|
||||
"border border-accent bg-accent/10 text-[color:var(--ink)]",
|
||||
variant === "destructive" &&
|
||||
"border border-status-error-fg/20 bg-status-error-bg text-status-error-fg hover:not-disabled:bg-active-bg",
|
||||
variant === "ghost" &&
|
||||
"border-transparent bg-transparent text-[color:var(--ink-soft)] hover:not-disabled:bg-[color:var(--surface-muted)] hover:not-disabled:text-[color:var(--ink)] hover:not-disabled:shadow-none hover:not-disabled:translate-y-0",
|
||||
variant === "outline" &&
|
||||
"border border-[color:var(--border-ui)] bg-transparent text-[color:var(--ink)] hover:not-disabled:border-[color:var(--border-ui-hover)] hover:not-disabled:bg-[color:var(--surface)]",
|
||||
// Size styles
|
||||
size === "default" && "min-h-[44px] rounded-[var(--r-btn)] px-4 py-[11px] text-sm",
|
||||
size === "sm" && "min-h-[34px] rounded-[var(--r-btn)] px-3 py-1.5 text-xs",
|
||||
size === "lg" && "min-h-[52px] rounded-[var(--r-btn)] px-6 py-3 text-base",
|
||||
size === "icon" && "h-[44px] w-[44px] rounded-[var(--r-btn)] p-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current/25 border-t-current" />
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current/25 border-t-current [animation-duration:2.4s]" />
|
||||
)}
|
||||
<Slottable>{children}</Slottable>
|
||||
</Comp>
|
||||
@@ -66,4 +88,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button };
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-[var(--radius-sm)] border border-[color:var(--border-ui)] bg-input-bg text-accent-fg shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent data-[state=checked]:bg-accent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Dialog, DialogContent } from "./dialog";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-[var(--radius-md)] bg-[color:var(--surface)] text-[color:var(--ink)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
const CommandDialog = ({ children, ...props }: React.ComponentProps<typeof Dialog>) => (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command>{children}</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b border-[color:var(--line)] px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 text-[color:var(--ink-soft)]" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-input-placeholder disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
data-slot="command-list"
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm text-[color:var(--ink-soft)]"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-[color:var(--ink)] [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-bold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.12em] [&_[cmdk-group-heading]]:text-[color:var(--ink-soft)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px bg-[color:var(--line)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] px-2 py-2 text-sm font-medium outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-[color:var(--surface-muted)] data-[selected=true]:text-[color:var(--ink)] data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn("ml-auto text-xs tracking-widest text-[color:var(--ink-soft)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
@@ -28,12 +29,15 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
// Matches .report-dialog
|
||||
"fixed top-1/2 left-1/2 z-80 grid w-[min(100%,560px)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-5 shadow-dialog",
|
||||
@@ -44,10 +48,18 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-[var(--radius-sm)] p-1 text-[color:var(--ink-soft)] opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]/35">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute top-4 right-4 text-[color:var(--ink-soft)] opacity-70 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
@@ -59,7 +71,11 @@ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex items-center justify-end gap-2 pt-2", className)} {...props} />
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
@@ -12,13 +13,18 @@ const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 8, ...props }, ref) => (
|
||||
>(({ className, sideOffset = 8, align = "start", ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[180px] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[180px] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -29,12 +35,18 @@ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
>(({ className, inset, variant = "default", ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"flex cursor-pointer select-none items-center gap-2 rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset=true]:pl-8 data-[variant=destructive]:text-status-error-fg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -42,26 +54,154 @@ const DropdownMenuItem = React.forwardRef<
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, children, checked, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] py-2 pr-3 pl-8 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset=true]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, children, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] py-2 pr-3 pl-8 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset=true]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs font-bold uppercase tracking-[0.12em] text-[color:var(--ink-soft)] data-[inset=true]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("my-1 h-px bg-[color:var(--line)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("ml-auto text-xs tracking-widest text-[color:var(--ink-soft)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-[var(--radius-sm)] px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:var(--surface-muted)] data-[state=open]:bg-[color:var(--surface-muted)] data-[inset=true]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[180px] overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 8, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Portal>
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 origin-[var(--radix-hover-card-content-transform-origin)] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-4 text-[color:var(--ink)] shadow-[var(--shadow)] outline-none",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
));
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 8, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 origin-[var(--radix-popover-content-transform-origin)] rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] p-4 text-[color:var(--ink)] shadow-[var(--shadow)] outline-none",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadioGroupPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-[color:var(--border-ui)] bg-input-bg text-accent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]/35 focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
));
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -9,13 +9,18 @@ const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
>(({ className, children, size = "default", ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
// Matches form input token styling
|
||||
"flex w-full min-h-[44px] items-center justify-between rounded-[var(--radius-sm)] border px-3.5 py-space-3 text-sm text-[color:var(--ink)] transition-all duration-[180ms] ease-out",
|
||||
"data-[size=sm]:min-h-8 data-[size=sm]:px-2.5 data-[size=sm]:py-1 data-[size=sm]:text-xs",
|
||||
"border-input-border bg-input-bg",
|
||||
"placeholder:text-input-placeholder",
|
||||
"focus:outline-none focus:border-input-focus-border focus:shadow-[0_0_0_3px_var(--input-focus-ring)]",
|
||||
@@ -63,12 +68,13 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
>(({ className, children, position = "popper", align = "center", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
|
||||
"relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-hidden rounded-[var(--radius-md)] border border-[color:var(--line)] bg-[color:var(--surface)] text-[color:var(--ink)] shadow-[var(--shadow)]",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
@@ -77,6 +83,7 @@ const SelectContent = React.forwardRef<
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
const Sheet = DialogPrimitive.Root;
|
||||
const SheetTrigger = DialogPrimitive.Trigger;
|
||||
@@ -27,16 +28,19 @@ SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ className, children, side = "right", ...props }, ref) => (
|
||||
>(({ className, children, side = "right", showCloseButton = true, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-80 flex flex-col gap-4 border-[color:var(--line)] bg-[color:var(--bg)] p-6 shadow-[var(--shadow)] transition-transform duration-300 ease-out",
|
||||
side === "right" &&
|
||||
@@ -51,10 +55,18 @@ const SheetContent = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<DialogPrimitive.Close className="absolute top-4 right-4 rounded-[var(--radius-sm)] p-1 text-[color:var(--ink-soft)] opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]/35">
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute top-4 right-4 text-[color:var(--ink-soft)] opacity-70 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
|
||||
@@ -8,21 +8,23 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent",
|
||||
"transition-colors duration-200 ease-out",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg)]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:bg-[color:var(--accent)] data-[state=unchecked]:bg-[color:var(--surface-muted)]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0",
|
||||
"transition-transform duration-200 ease-out",
|
||||
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
|
||||
@@ -1,35 +1,81 @@
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const toggleGroupItemVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center rounded-[var(--radius-pill)] text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-fg [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
outline:
|
||||
"border border-[color:var(--border-ui)] data-[state=on]:border-[color:var(--accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-[30px] min-w-[30px] px-2 text-sm",
|
||||
sm: "h-7 min-w-7 px-1.5 text-xs",
|
||||
lg: "h-9 min-w-9 px-3 text-sm",
|
||||
icon: "h-[30px] w-[30px] p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "icon",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleGroupItemVariants>>({
|
||||
variant: "default",
|
||||
size: "icon",
|
||||
});
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleGroupItemVariants>
|
||||
>(({ className, variant = "default", size = "icon", children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"inline-flex h-[38px] items-center gap-0.5 rounded-[var(--radius-pill)] border border-[color:var(--line)] bg-[color:var(--surface)] p-[3px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
));
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-[30px] w-[30px] items-center justify-center rounded-[var(--radius-pill)] text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] data-[state=on]:bg-accent data-[state=on]:text-accent-fg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleGroupItemVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant ?? variant}
|
||||
data-size={context.size ?? size}
|
||||
className={cn(
|
||||
toggleGroupItemVariants({
|
||||
variant: variant ?? context.variant,
|
||||
size: size ?? context.size,
|
||||
className,
|
||||
}),
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
export { ToggleGroup, ToggleGroupItem, toggleGroupItemVariants };
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { fetchPluginCatalog } from "./packageApi";
|
||||
|
||||
export async function fetchFeaturedPlugins(limit: number = 50) {
|
||||
const result = await fetchPluginCatalog({ featured: true, limit });
|
||||
return result.items;
|
||||
}
|
||||
+1
-60
@@ -53,10 +53,6 @@ const SOULS_SEARCH = {
|
||||
focus: undefined,
|
||||
} as const;
|
||||
|
||||
const USERS_SEARCH = { q: undefined } as const;
|
||||
|
||||
const MANAGEMENT_SEARCH = { skill: undefined } as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primary nav items (desktop tabs row + mobile dropdown top section)
|
||||
// These map to the "content-type" tabs: Skills | Plugins | Souls
|
||||
@@ -100,55 +96,6 @@ export const PRIMARY_NAV_ITEMS: NavItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secondary nav items (secondary tabs row + mobile dropdown lower section)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SECONDARY_NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
label: "Users",
|
||||
to: "/users",
|
||||
search: USERS_SEARCH,
|
||||
authRequired: false,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: true,
|
||||
},
|
||||
{
|
||||
label: "About",
|
||||
to: "/about",
|
||||
authRequired: false,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: true,
|
||||
},
|
||||
{
|
||||
label: "Stars",
|
||||
to: "/stars",
|
||||
authRequired: true,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: false,
|
||||
},
|
||||
{
|
||||
label: "Dashboard",
|
||||
to: "/dashboard",
|
||||
authRequired: true,
|
||||
staffOnly: false,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: false,
|
||||
},
|
||||
{
|
||||
label: "Management",
|
||||
to: "/management",
|
||||
search: MANAGEMENT_SEARCH,
|
||||
authRequired: true,
|
||||
staffOnly: true,
|
||||
soulModeOnly: false,
|
||||
soulModeHide: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Footer sections
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -199,17 +146,11 @@ export const FOOTER_NAV_SECTIONS: FooterNavSection[] = [
|
||||
{
|
||||
title: "Community",
|
||||
items: [
|
||||
{ kind: "link", label: "About", to: "/about" },
|
||||
{ kind: "external", label: "GitHub", href: "https://github.com/openclaw/clawhub" },
|
||||
{ kind: "external", label: "OpenClaw", href: "https://openclaw.ai" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Platform",
|
||||
items: [
|
||||
{ kind: "external", label: "Deployed on Vercel", href: "https://vercel.com" },
|
||||
{ kind: "external", label: "Powered by Convex", href: "https://www.convex.dev" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+19
-4
@@ -255,6 +255,7 @@ export async function fetchPackages(params: {
|
||||
cursor?: string;
|
||||
family?: "skill" | "code-plugin" | "bundle-plugin";
|
||||
isOfficial?: boolean;
|
||||
featured?: boolean;
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
limit?: number;
|
||||
@@ -267,6 +268,7 @@ export async function fetchPackages(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -287,6 +289,7 @@ export async function fetchPackages(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -299,6 +302,7 @@ export async function fetchPluginCatalog(params: {
|
||||
cursor?: string;
|
||||
family?: PluginFamily;
|
||||
isOfficial?: boolean;
|
||||
featured?: boolean;
|
||||
executesCode?: boolean;
|
||||
limit?: number;
|
||||
}): Promise<PluginCatalogResult> {
|
||||
@@ -308,6 +312,7 @@ export async function fetchPluginCatalog(params: {
|
||||
cursor: params.cursor,
|
||||
family: params.family,
|
||||
isOfficial: params.isOfficial,
|
||||
featured: params.featured,
|
||||
executesCode: params.executesCode,
|
||||
limit: params.limit,
|
||||
});
|
||||
@@ -332,6 +337,7 @@ export async function fetchPluginCatalog(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -339,7 +345,9 @@ export async function fetchPluginCatalog(params: {
|
||||
results?: Array<{ score: number; package: PackageListItem }>;
|
||||
}>(url);
|
||||
return {
|
||||
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
|
||||
items: (response?.results ?? [])
|
||||
.map((entry) => entry?.package)
|
||||
.filter(Boolean) as PackageListItem[],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
@@ -350,6 +358,7 @@ export async function fetchPluginCatalog(params: {
|
||||
if (typeof params.isOfficial === "boolean") {
|
||||
url.searchParams.set("isOfficial", String(params.isOfficial));
|
||||
}
|
||||
if (params.featured) url.searchParams.set("featured", "true");
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
@@ -370,7 +379,10 @@ export async function fetchPackageDetail(name: string): Promise<PackageDetailRes
|
||||
return (await response.json()) as PackageDetailResponse;
|
||||
}
|
||||
|
||||
export async function fetchPackageVersion(name: string, version: string): Promise<PackageVersionDetail | null> {
|
||||
export async function fetchPackageVersion(
|
||||
name: string,
|
||||
version: string,
|
||||
): Promise<PackageVersionDetail | null> {
|
||||
try {
|
||||
const url = await packageApiUrl(
|
||||
`${ApiRoutes.packages}/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`,
|
||||
@@ -382,13 +394,16 @@ export async function fetchPackageVersion(name: string, version: string): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPackageReadme(name: string, version?: string | null): Promise<string | null> {
|
||||
export async function fetchPackageReadme(
|
||||
name: string,
|
||||
version?: string | null,
|
||||
): Promise<string | null> {
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/${encodeURIComponent(name)}/file`);
|
||||
url.searchParams.set("path", "README.md");
|
||||
if (version) url.searchParams.set("version", version);
|
||||
const response = await packageFetch(url, "text/plain");
|
||||
if (response.ok) return await response.text();
|
||||
if (response.status === 404 || response.status === 423) {
|
||||
if (response.status === 403 || response.status === 404 || response.status === 423) {
|
||||
return null;
|
||||
}
|
||||
throw await createPackageApiError(response);
|
||||
|
||||
+46
-47
@@ -1,9 +1,9 @@
|
||||
import { useAction } from "convex/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import { convexHttp } from "../convex/client";
|
||||
import { fetchPluginCatalog, type PackageListItem } from "./packageApi";
|
||||
import type { PublicUser } from "./publicUser";
|
||||
|
||||
export type UnifiedSearchType = "all" | "skills" | "plugins";
|
||||
|
||||
export type UnifiedSkillResult = {
|
||||
type: "skill";
|
||||
@@ -27,33 +27,44 @@ export type UnifiedPluginResult = {
|
||||
plugin: PackageListItem;
|
||||
};
|
||||
|
||||
export type UnifiedUserResult = {
|
||||
type: "user";
|
||||
user: PublicUser;
|
||||
};
|
||||
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult;
|
||||
|
||||
export type UnifiedResult = UnifiedSkillResult | UnifiedPluginResult | UnifiedUserResult;
|
||||
type UnifiedSearchOptions = {
|
||||
debounceMs?: number;
|
||||
enabled?: boolean;
|
||||
limits?: {
|
||||
skills?: number;
|
||||
plugins?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function useUnifiedSearch(
|
||||
query: string,
|
||||
activeType: "all" | "skills" | "plugins" | "users",
|
||||
activeType: UnifiedSearchType,
|
||||
options: UnifiedSearchOptions = {},
|
||||
) {
|
||||
const searchSkills = useAction(api.search.searchSkills);
|
||||
const [results, setResults] = useState<UnifiedResult[]>([]);
|
||||
const [skillResults, setSkillResults] = useState<UnifiedSkillResult[]>([]);
|
||||
const [pluginResults, setPluginResults] = useState<UnifiedPluginResult[]>([]);
|
||||
const [skillCount, setSkillCount] = useState(0);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [userCount, setUserCount] = useState(0);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const requestRef = useRef(0);
|
||||
const debounceMs = options.debounceMs ?? 300;
|
||||
const enabled = options.enabled ?? true;
|
||||
const skillLimit = options.limits?.skills ?? 25;
|
||||
const pluginLimit = options.limits?.plugins ?? 25;
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
if (!enabled || !trimmed) {
|
||||
requestRef.current += 1;
|
||||
setResults([]);
|
||||
setSkillResults([]);
|
||||
setPluginResults([]);
|
||||
setSkillCount(0);
|
||||
setPluginCount(0);
|
||||
setUserCount(0);
|
||||
setIsSearching(false);
|
||||
return () => {};
|
||||
}
|
||||
@@ -65,40 +76,34 @@ export function useUnifiedSearch(
|
||||
const handle = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const promises: [
|
||||
Promise<unknown> | null,
|
||||
Promise<{ items: PackageListItem[] }> | null,
|
||||
Promise<{ items: PublicUser[] }> | null,
|
||||
] = [null, null, null];
|
||||
const promises: [Promise<unknown> | null, Promise<{ items: PackageListItem[] }> | null] =
|
||||
[null, null];
|
||||
|
||||
if (activeType === "all" || activeType === "skills") {
|
||||
promises[0] = searchSkills({
|
||||
query: trimmed,
|
||||
limit: 25,
|
||||
limit: skillLimit,
|
||||
nonSuspiciousOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeType === "all" || activeType === "plugins") {
|
||||
promises[1] = fetchPluginCatalog({ q: trimmed, limit: 25 });
|
||||
promises[1] = fetchPluginCatalog({ q: trimmed, limit: pluginLimit });
|
||||
}
|
||||
|
||||
if (activeType === "all" || activeType === "users") {
|
||||
promises[2] = convexHttp.query(api.users.listPublic, { search: trimmed, limit: 25 });
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
promises.map((p) => p ?? Promise.resolve(null)),
|
||||
);
|
||||
const settled = await Promise.allSettled(promises.map((p) => p ?? Promise.resolve(null)));
|
||||
|
||||
if (requestId !== requestRef.current) return;
|
||||
|
||||
const skillsRaw = settled[0].status === "fulfilled" ? settled[0].value : null;
|
||||
const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null;
|
||||
const usersRaw = settled[2].status === "fulfilled" ? settled[2].value : null;
|
||||
|
||||
const skillResults: UnifiedSkillResult[] = (
|
||||
(skillsRaw as Array<{ skill: UnifiedSkillResult["skill"]; ownerHandle: string | null; score: number }>) ?? []
|
||||
const nextSkillResults: UnifiedSkillResult[] = (
|
||||
(skillsRaw as Array<{
|
||||
skill: UnifiedSkillResult["skill"];
|
||||
ownerHandle: string | null;
|
||||
score: number;
|
||||
}>) ?? []
|
||||
).map((entry) => ({
|
||||
type: "skill" as const,
|
||||
skill: entry.skill,
|
||||
@@ -106,32 +111,25 @@ export function useUnifiedSearch(
|
||||
score: entry.score,
|
||||
}));
|
||||
|
||||
const pluginResults: UnifiedPluginResult[] = (
|
||||
const nextPluginResults: UnifiedPluginResult[] = (
|
||||
(pluginsRaw as { items: PackageListItem[] })?.items ?? []
|
||||
).map((item) => ({
|
||||
type: "plugin" as const,
|
||||
plugin: item,
|
||||
}));
|
||||
|
||||
setSkillCount(skillResults.length);
|
||||
setPluginCount(pluginResults.length);
|
||||
const userResults: UnifiedUserResult[] = (
|
||||
(usersRaw as { items: PublicUser[] })?.items ?? []
|
||||
).map((user) => ({
|
||||
type: "user" as const,
|
||||
user,
|
||||
}));
|
||||
setUserCount(userResults.length);
|
||||
setSkillCount(nextSkillResults.length);
|
||||
setPluginCount(nextPluginResults.length);
|
||||
setSkillResults(nextSkillResults);
|
||||
setPluginResults(nextPluginResults);
|
||||
|
||||
const merged: UnifiedResult[] = [];
|
||||
if (activeType === "all") {
|
||||
merged.push(...skillResults, ...pluginResults, ...userResults);
|
||||
merged.push(...nextSkillResults, ...nextPluginResults);
|
||||
} else if (activeType === "skills") {
|
||||
merged.push(...skillResults);
|
||||
} else if (activeType === "plugins") {
|
||||
merged.push(...pluginResults);
|
||||
merged.push(...nextSkillResults);
|
||||
} else {
|
||||
merged.push(...userResults);
|
||||
merged.push(...nextPluginResults);
|
||||
}
|
||||
|
||||
setResults(merged);
|
||||
@@ -139,9 +137,10 @@ export function useUnifiedSearch(
|
||||
console.error("Unified search failed:", error);
|
||||
if (requestId === requestRef.current) {
|
||||
setResults([]);
|
||||
setSkillResults([]);
|
||||
setPluginResults([]);
|
||||
setSkillCount(0);
|
||||
setPluginCount(0);
|
||||
setUserCount(0);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestRef.current) {
|
||||
@@ -149,10 +148,10 @@ export function useUnifiedSearch(
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, 300);
|
||||
}, debounceMs);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query, activeType, searchSkills]);
|
||||
}, [query, activeType, searchSkills, debounceMs, enabled, skillLimit, pluginLimit]);
|
||||
|
||||
return { results, skillCount, pluginCount, userCount, isSearching };
|
||||
return { results, skillResults, pluginResults, skillCount, pluginCount, isSearching };
|
||||
}
|
||||
|
||||
+95
-10
@@ -35,6 +35,9 @@ import { Route as PackagesNameRouteImport } from './routes/packages/$name'
|
||||
import { Route as OrgsHandleRouteImport } from './routes/orgs/$handle'
|
||||
import { Route as CliAuthRouteImport } from './routes/cli/auth'
|
||||
import { Route as OwnerSlugRouteImport } from './routes/$owner/$slug'
|
||||
import { Route as OwnerSlugSettingsRouteImport } from './routes/$owner/$slug/settings'
|
||||
import { Route as PluginsNameSecurityScannerRouteImport } from './routes/plugins/$name/security/$scanner'
|
||||
import { Route as OwnerSlugSecurityScannerRouteImport } from './routes/$owner/$slug/security/$scanner'
|
||||
|
||||
const UploadRoute = UploadRouteImport.update({
|
||||
id: '/upload',
|
||||
@@ -166,6 +169,23 @@ const OwnerSlugRoute = OwnerSlugRouteImport.update({
|
||||
path: '/$owner/$slug',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OwnerSlugSettingsRoute = OwnerSlugSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => OwnerSlugRoute,
|
||||
} as any)
|
||||
const PluginsNameSecurityScannerRoute =
|
||||
PluginsNameSecurityScannerRouteImport.update({
|
||||
id: '/security/$scanner',
|
||||
path: '/security/$scanner',
|
||||
getParentRoute: () => PluginsNameRoute,
|
||||
} as any)
|
||||
const OwnerSlugSecurityScannerRoute =
|
||||
OwnerSlugSecurityScannerRouteImport.update({
|
||||
id: '/security/$scanner',
|
||||
path: '/security/$scanner',
|
||||
getParentRoute: () => OwnerSlugRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
@@ -180,12 +200,12 @@ export interface FileRoutesByFullPath {
|
||||
'/settings': typeof SettingsRoute
|
||||
'/stars': typeof StarsRoute
|
||||
'/upload': typeof UploadRoute
|
||||
'/$owner/$slug': typeof OwnerSlugRoute
|
||||
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
|
||||
'/cli/auth': typeof CliAuthRoute
|
||||
'/orgs/$handle': typeof OrgsHandleRoute
|
||||
'/packages/$name': typeof PackagesNameRoute
|
||||
'/packages/new': typeof PackagesNewRoute
|
||||
'/plugins/$name': typeof PluginsNameRoute
|
||||
'/plugins/$name': typeof PluginsNameRouteWithChildren
|
||||
'/plugins/new': typeof PluginsNewRoute
|
||||
'/souls/$slug': typeof SoulsSlugRoute
|
||||
'/u/$handle': typeof UHandleRoute
|
||||
@@ -194,6 +214,9 @@ export interface FileRoutesByFullPath {
|
||||
'/skills/': typeof SkillsIndexRoute
|
||||
'/souls/': typeof SoulsIndexRoute
|
||||
'/users/': typeof UsersIndexRoute
|
||||
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
|
||||
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
|
||||
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
@@ -208,12 +231,12 @@ export interface FileRoutesByTo {
|
||||
'/settings': typeof SettingsRoute
|
||||
'/stars': typeof StarsRoute
|
||||
'/upload': typeof UploadRoute
|
||||
'/$owner/$slug': typeof OwnerSlugRoute
|
||||
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
|
||||
'/cli/auth': typeof CliAuthRoute
|
||||
'/orgs/$handle': typeof OrgsHandleRoute
|
||||
'/packages/$name': typeof PackagesNameRoute
|
||||
'/packages/new': typeof PackagesNewRoute
|
||||
'/plugins/$name': typeof PluginsNameRoute
|
||||
'/plugins/$name': typeof PluginsNameRouteWithChildren
|
||||
'/plugins/new': typeof PluginsNewRoute
|
||||
'/souls/$slug': typeof SoulsSlugRoute
|
||||
'/u/$handle': typeof UHandleRoute
|
||||
@@ -222,6 +245,9 @@ export interface FileRoutesByTo {
|
||||
'/skills': typeof SkillsIndexRoute
|
||||
'/souls': typeof SoulsIndexRoute
|
||||
'/users': typeof UsersIndexRoute
|
||||
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
|
||||
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
|
||||
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -237,12 +263,12 @@ export interface FileRoutesById {
|
||||
'/settings': typeof SettingsRoute
|
||||
'/stars': typeof StarsRoute
|
||||
'/upload': typeof UploadRoute
|
||||
'/$owner/$slug': typeof OwnerSlugRoute
|
||||
'/$owner/$slug': typeof OwnerSlugRouteWithChildren
|
||||
'/cli/auth': typeof CliAuthRoute
|
||||
'/orgs/$handle': typeof OrgsHandleRoute
|
||||
'/packages/$name': typeof PackagesNameRoute
|
||||
'/packages/new': typeof PackagesNewRoute
|
||||
'/plugins/$name': typeof PluginsNameRoute
|
||||
'/plugins/$name': typeof PluginsNameRouteWithChildren
|
||||
'/plugins/new': typeof PluginsNewRoute
|
||||
'/souls/$slug': typeof SoulsSlugRoute
|
||||
'/u/$handle': typeof UHandleRoute
|
||||
@@ -251,6 +277,9 @@ export interface FileRoutesById {
|
||||
'/skills/': typeof SkillsIndexRoute
|
||||
'/souls/': typeof SoulsIndexRoute
|
||||
'/users/': typeof UsersIndexRoute
|
||||
'/$owner/$slug/settings': typeof OwnerSlugSettingsRoute
|
||||
'/$owner/$slug/security/$scanner': typeof OwnerSlugSecurityScannerRoute
|
||||
'/plugins/$name/security/$scanner': typeof PluginsNameSecurityScannerRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -281,6 +310,9 @@ export interface FileRouteTypes {
|
||||
| '/skills/'
|
||||
| '/souls/'
|
||||
| '/users/'
|
||||
| '/$owner/$slug/settings'
|
||||
| '/$owner/$slug/security/$scanner'
|
||||
| '/plugins/$name/security/$scanner'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
@@ -309,6 +341,9 @@ export interface FileRouteTypes {
|
||||
| '/skills'
|
||||
| '/souls'
|
||||
| '/users'
|
||||
| '/$owner/$slug/settings'
|
||||
| '/$owner/$slug/security/$scanner'
|
||||
| '/plugins/$name/security/$scanner'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -337,6 +372,9 @@ export interface FileRouteTypes {
|
||||
| '/skills/'
|
||||
| '/souls/'
|
||||
| '/users/'
|
||||
| '/$owner/$slug/settings'
|
||||
| '/$owner/$slug/security/$scanner'
|
||||
| '/plugins/$name/security/$scanner'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -352,12 +390,12 @@ export interface RootRouteChildren {
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
StarsRoute: typeof StarsRoute
|
||||
UploadRoute: typeof UploadRoute
|
||||
OwnerSlugRoute: typeof OwnerSlugRoute
|
||||
OwnerSlugRoute: typeof OwnerSlugRouteWithChildren
|
||||
CliAuthRoute: typeof CliAuthRoute
|
||||
OrgsHandleRoute: typeof OrgsHandleRoute
|
||||
PackagesNameRoute: typeof PackagesNameRoute
|
||||
PackagesNewRoute: typeof PackagesNewRoute
|
||||
PluginsNameRoute: typeof PluginsNameRoute
|
||||
PluginsNameRoute: typeof PluginsNameRouteWithChildren
|
||||
PluginsNewRoute: typeof PluginsNewRoute
|
||||
SoulsSlugRoute: typeof SoulsSlugRoute
|
||||
UHandleRoute: typeof UHandleRoute
|
||||
@@ -552,9 +590,56 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof OwnerSlugRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/$owner/$slug/settings': {
|
||||
id: '/$owner/$slug/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/$owner/$slug/settings'
|
||||
preLoaderRoute: typeof OwnerSlugSettingsRouteImport
|
||||
parentRoute: typeof OwnerSlugRoute
|
||||
}
|
||||
'/plugins/$name/security/$scanner': {
|
||||
id: '/plugins/$name/security/$scanner'
|
||||
path: '/security/$scanner'
|
||||
fullPath: '/plugins/$name/security/$scanner'
|
||||
preLoaderRoute: typeof PluginsNameSecurityScannerRouteImport
|
||||
parentRoute: typeof PluginsNameRoute
|
||||
}
|
||||
'/$owner/$slug/security/$scanner': {
|
||||
id: '/$owner/$slug/security/$scanner'
|
||||
path: '/security/$scanner'
|
||||
fullPath: '/$owner/$slug/security/$scanner'
|
||||
preLoaderRoute: typeof OwnerSlugSecurityScannerRouteImport
|
||||
parentRoute: typeof OwnerSlugRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface OwnerSlugRouteChildren {
|
||||
OwnerSlugSettingsRoute: typeof OwnerSlugSettingsRoute
|
||||
OwnerSlugSecurityScannerRoute: typeof OwnerSlugSecurityScannerRoute
|
||||
}
|
||||
|
||||
const OwnerSlugRouteChildren: OwnerSlugRouteChildren = {
|
||||
OwnerSlugSettingsRoute: OwnerSlugSettingsRoute,
|
||||
OwnerSlugSecurityScannerRoute: OwnerSlugSecurityScannerRoute,
|
||||
}
|
||||
|
||||
const OwnerSlugRouteWithChildren = OwnerSlugRoute._addFileChildren(
|
||||
OwnerSlugRouteChildren,
|
||||
)
|
||||
|
||||
interface PluginsNameRouteChildren {
|
||||
PluginsNameSecurityScannerRoute: typeof PluginsNameSecurityScannerRoute
|
||||
}
|
||||
|
||||
const PluginsNameRouteChildren: PluginsNameRouteChildren = {
|
||||
PluginsNameSecurityScannerRoute: PluginsNameSecurityScannerRoute,
|
||||
}
|
||||
|
||||
const PluginsNameRouteWithChildren = PluginsNameRoute._addFileChildren(
|
||||
PluginsNameRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AboutRoute: AboutRoute,
|
||||
@@ -568,12 +653,12 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
SettingsRoute: SettingsRoute,
|
||||
StarsRoute: StarsRoute,
|
||||
UploadRoute: UploadRoute,
|
||||
OwnerSlugRoute: OwnerSlugRoute,
|
||||
OwnerSlugRoute: OwnerSlugRouteWithChildren,
|
||||
CliAuthRoute: CliAuthRoute,
|
||||
OrgsHandleRoute: OrgsHandleRoute,
|
||||
PackagesNameRoute: PackagesNameRoute,
|
||||
PackagesNewRoute: PackagesNewRoute,
|
||||
PluginsNameRoute: PluginsNameRoute,
|
||||
PluginsNameRoute: PluginsNameRouteWithChildren,
|
||||
PluginsNewRoute: PluginsNewRoute,
|
||||
SoulsSlugRoute: SoulsSlugRoute,
|
||||
UHandleRoute: UHandleRoute,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user