mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
155
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5be24fd13 | ||
|
|
15bc4440cc | ||
|
|
11a20f5755 | ||
|
|
05f8674628 | ||
|
|
d2b2252770 | ||
|
|
a2387253ec | ||
|
|
411260767b | ||
|
|
731d0ce0c5 | ||
|
|
361f2affde | ||
|
|
a17f7bb07e | ||
|
|
7980788ea6 | ||
|
|
2d03b827d3 | ||
|
|
835094ea2c | ||
|
|
7bd7e4c99e | ||
|
|
1722a48055 | ||
|
|
29178898bb | ||
|
|
b16861f422 | ||
|
|
6896e61fa1 | ||
|
|
cf137aa592 | ||
|
|
a28d014d4f | ||
|
|
b2038fc931 | ||
|
|
4a72b543b2 | ||
|
|
54e99c8cc2 | ||
|
|
5826001795 | ||
|
|
0708a43fde | ||
|
|
9a45c371fc | ||
|
|
311a123fbe | ||
|
|
59e93862ed | ||
|
|
8fcd53f899 | ||
|
|
ba2c73e180 | ||
|
|
298cbdd6db | ||
|
|
fa87dc3509 | ||
|
|
f636b31fca | ||
|
|
b255b5865f | ||
|
|
5003c1bec8 | ||
|
|
a8a6242f87 | ||
|
|
39c0fa2531 | ||
|
|
9df6fe37c5 | ||
|
|
d5d806516d | ||
|
|
67c74e10ea | ||
|
|
3501af6bb4 | ||
|
|
051b1dafcd | ||
|
|
383844cacf | ||
|
|
b7923edbfd | ||
|
|
655c914c77 | ||
|
|
b15eeab93f | ||
|
|
a39f07427e | ||
|
|
1a42207879 | ||
|
|
9bd3a63edf | ||
|
|
be4c51ed1b | ||
|
|
8711de4441 | ||
|
|
f4db0ee32b | ||
|
|
f1cf715b89 | ||
|
|
57ed1f62a4 | ||
|
|
bde7529fbe | ||
|
|
5892718a23 | ||
|
|
922eecdbd9 | ||
|
|
e53a4433ae | ||
|
|
68870a1dcc | ||
|
|
c6aaf27886 | ||
|
|
f71db92fc3 | ||
|
|
07a1da4285 | ||
|
|
cb9d854e92 | ||
|
|
74ec5191e0 | ||
|
|
cf98936105 | ||
|
|
dd9c42be28 | ||
|
|
e8a4e094e7 | ||
|
|
068dd78a05 | ||
|
|
2f5e17f56e | ||
|
|
5302bf8598 | ||
|
|
749c89e77b | ||
|
|
fd08d74071 | ||
|
|
67c8e188e3 | ||
|
|
bad07ff95c | ||
|
|
a7547eee51 | ||
|
|
fddbb35b40 | ||
|
|
ba313e2d0a | ||
|
|
f6f31ac78d | ||
|
|
f32500b1b4 | ||
|
|
5bc6402b5e | ||
|
|
9721461aca | ||
|
|
91f80c264a | ||
|
|
712302eb30 | ||
|
|
f044164bae | ||
|
|
997da8857a | ||
|
|
1a31e07332 | ||
|
|
cb761a73a9 | ||
|
|
d4ad4ea489 | ||
|
|
d7c9126c4b | ||
|
|
5dc6720d9e | ||
|
|
087f2c75b0 | ||
|
|
03c42fa947 | ||
|
|
572110b1d5 | ||
|
|
812276f1b7 | ||
|
|
b9676f674c | ||
|
|
32d601aab7 | ||
|
|
4aae925e76 | ||
|
|
e41fd677bb | ||
|
|
31520ef02a | ||
|
|
e829cb1ae6 | ||
|
|
24cc59a425 | ||
|
|
522fa22026 | ||
|
|
7e76336ae4 | ||
|
|
e41d5d6314 | ||
|
|
c62ab8bc96 | ||
|
|
6df3a3b9ef | ||
|
|
7452cf6f69 | ||
|
|
8ded548da2 | ||
|
|
4ff2d1a0cd | ||
|
|
b4e8a26eb4 | ||
|
|
7ff601bcb8 | ||
|
|
70af109cb2 | ||
|
|
1504708208 | ||
|
|
4af2bd50a7 | ||
|
|
88fe310ff1 | ||
|
|
0a31b31f1b | ||
|
|
079e390da6 | ||
|
|
15da02c70a | ||
|
|
8b289618ba | ||
|
|
4ae0406948 | ||
|
|
78d0a637fa | ||
|
|
65bc5d3335 | ||
|
|
8592272720 | ||
|
|
15f5769cda | ||
|
|
bb2a05e501 | ||
|
|
af29ca7f9d | ||
|
|
13caea7cfd | ||
|
|
86259eef42 | ||
|
|
bf2ecab92e | ||
|
|
70516bd0f5 | ||
|
|
66fca9a286 | ||
|
|
a03fe3bbd0 | ||
|
|
d3d9298389 | ||
|
|
2fd0aa01d5 | ||
|
|
5aad36dab0 | ||
|
|
1bab92313f | ||
|
|
f77993d614 | ||
|
|
3a28344c53 | ||
|
|
ea49143fe0 | ||
|
|
bd38fef5c5 | ||
|
|
b7a015523d | ||
|
|
d36f98faa1 | ||
|
|
eeb0ecd932 | ||
|
|
51beceeb20 | ||
|
|
a8a687eba2 | ||
|
|
b9341202c7 | ||
|
|
d701c2a33b | ||
|
|
79d17a91ed | ||
|
|
2f15202a68 | ||
|
|
342a2b1ca4 | ||
|
|
75915cd2b5 | ||
|
|
5395d9d159 | ||
|
|
87c236e8ce | ||
|
|
d8db9b99a2 | ||
|
|
d8e1f0daa1 |
@@ -27,15 +27,22 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: bun run test
|
||||
env:
|
||||
VITE_CONVEX_URL: https://example.invalid
|
||||
|
||||
- name: Coverage
|
||||
run: bun run coverage
|
||||
env:
|
||||
VITE_CONVEX_URL: https://example.invalid
|
||||
|
||||
- name: ClawHub CLI Verify
|
||||
run: bun run --cwd packages/clawhub verify
|
||||
|
||||
- name: Typecheck
|
||||
run: |
|
||||
bunx tsc --noEmit
|
||||
bunx tsc -p packages/schema/tsconfig.json --noEmit
|
||||
bunx tsc -p packages/clawdhub/tsconfig.json --noEmit
|
||||
bunx tsc -p packages/clawhub/tsconfig.json --noEmit
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
name: ClawHub CLI NPM Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to publish, for example v0.10.0
|
||||
required: true
|
||||
type: string
|
||||
preflight_only:
|
||||
description: Run validation/build only and skip the gated publish job
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
preflight_run_id:
|
||||
description: Existing successful preflight workflow run id to promote without rebuilding
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: clawhub-cli-npm-release-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
BUN_VERSION: "1.3.10"
|
||||
|
||||
jobs:
|
||||
preflight_clawhub_cli_npm:
|
||||
if: ${{ inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Forbid preflight artifact promotion on validation-only runs
|
||||
if: ${{ inputs.preflight_run_id != '' }}
|
||||
run: |
|
||||
echo "preflight_run_id is only valid for real publish runs."
|
||||
exit 1
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Resolve CLI package directory
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -d "packages/clawhub" ]]; then
|
||||
echo "PACKAGE_DIR=packages/clawhub" >> "$GITHUB_ENV"
|
||||
elif [[ -d "packages/clawdhub" ]]; then
|
||||
echo "PACKAGE_DIR=packages/clawdhub" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "Unable to find clawhub CLI package directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_VERSION="$(node --input-type=module <<'EOF'
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync(`./${process.env.PACKAGE_DIR}/package.json`, "utf8"));
|
||||
process.stdout.write(String(pkg.version ?? "").trim());
|
||||
EOF
|
||||
)"
|
||||
|
||||
if npm view "clawhub@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
|
||||
echo "clawhub@${PACKAGE_VERSION} is already published on npm; continuing because preflight_only=true."
|
||||
exit 0
|
||||
fi
|
||||
echo "clawhub@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Publishing clawhub@${PACKAGE_VERSION}"
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
export RELEASE_SHA
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
node scripts/clawhub-cli-npm-release-check.mjs
|
||||
|
||||
- name: Verify CLI package
|
||||
run: bun run --cwd "$PACKAGE_DIR" verify
|
||||
|
||||
- name: Pack prepared npm tarball
|
||||
id: packed_tarball
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pushd "$PACKAGE_DIR" >/dev/null
|
||||
PACK_JSON="$(npm pack --json --ignore-scripts)"
|
||||
echo "$PACK_JSON"
|
||||
PACK_PATH="$(printf '%s\n' "$PACK_JSON" | node --input-type=module -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); const first = Array.isArray(parsed) ? parsed[0] : null; if (!first || typeof first.filename !== "string" || !first.filename) process.exit(1); process.stdout.write(first.filename); });')"
|
||||
popd >/dev/null
|
||||
if [[ -z "${PACK_PATH}" || ! -f "${PACKAGE_DIR}/${PACK_PATH}" ]]; then
|
||||
echo "npm pack did not produce a tarball file." >&2
|
||||
exit 1
|
||||
fi
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
PACKAGE_VERSION="$(node --input-type=module <<'EOF'
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync(`./${process.env.PACKAGE_DIR}/package.json`, "utf8"));
|
||||
process.stdout.write(String(pkg.version ?? "").trim());
|
||||
EOF
|
||||
)"
|
||||
ARTIFACT_DIR="$RUNNER_TEMP/clawhub-cli-npm-preflight"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
cp "${PACKAGE_DIR}/${PACK_PATH}" "$ARTIFACT_DIR/"
|
||||
printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt"
|
||||
printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt"
|
||||
printf '%s\n' "$PACKAGE_VERSION" > "$ARTIFACT_DIR/package-version.txt"
|
||||
echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload prepared npm publish bundle
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: clawhub-cli-npm-preflight-${{ inputs.tag }}
|
||||
path: ${{ steps.packed_tarball.outputs.dir }}
|
||||
if-no-files-found: error
|
||||
|
||||
validate_publish_request:
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require main workflow ref for publish
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Real publish runs must be dispatched from main. Use preflight_only=true for branch validation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Require preflight artifact promotion on real publish
|
||||
env:
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then
|
||||
echo "Real publish requires preflight_run_id from a successful npm preflight run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish_clawhub_cli_npm:
|
||||
needs: [validate_publish_request]
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Resolve CLI package directory
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -d "packages/clawhub" ]]; then
|
||||
echo "PACKAGE_DIR=packages/clawhub" >> "$GITHUB_ENV"
|
||||
elif [[ -d "packages/clawdhub" ]]; then
|
||||
echo "PACKAGE_DIR=packages/clawdhub" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "Unable to find clawhub CLI package directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Ensure version is not already published
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_VERSION="$(node --input-type=module <<'EOF'
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync(`./${process.env.PACKAGE_DIR}/package.json`, "utf8"));
|
||||
process.stdout.write(String(pkg.version ?? "").trim());
|
||||
EOF
|
||||
)"
|
||||
|
||||
if npm view "clawhub@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "clawhub@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Publishing clawhub@${PACKAGE_VERSION}"
|
||||
|
||||
- name: Verify preflight run metadata
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | node --input-type=module -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const run = JSON.parse(Buffer.concat(chunks).toString("utf8")); const checks = [["workflowName", "ClawHub CLI NPM Release"], ["headBranch", "main"], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`); });'
|
||||
|
||||
- name: Download prepared npm tarball
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: clawhub-cli-npm-preflight-${{ inputs.tag }}
|
||||
path: preflight-tarball
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ inputs.preflight_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
export RELEASE_SHA
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
node scripts/clawhub-cli-npm-release-check.mjs
|
||||
|
||||
- name: Verify prepared tarball provenance
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
EXPECTED_PACKAGE_VERSION="$(node --input-type=module <<'EOF'
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync(`./${process.env.PACKAGE_DIR}/package.json`, "utf8"));
|
||||
process.stdout.write(String(pkg.version ?? "").trim());
|
||||
EOF
|
||||
)"
|
||||
TAG_FILE="preflight-tarball/release-tag.txt"
|
||||
SHA_FILE="preflight-tarball/release-sha.txt"
|
||||
VERSION_FILE="preflight-tarball/package-version.txt"
|
||||
if [[ ! -f "$TAG_FILE" || ! -f "$SHA_FILE" || ! -f "$VERSION_FILE" ]]; then
|
||||
echo "Prepared preflight metadata is missing." >&2
|
||||
ls -la preflight-tarball >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
ARTIFACT_RELEASE_TAG="$(tr -d '\r\n' < "$TAG_FILE")"
|
||||
ARTIFACT_RELEASE_SHA="$(tr -d '\r\n' < "$SHA_FILE")"
|
||||
ARTIFACT_PACKAGE_VERSION="$(tr -d '\r\n' < "$VERSION_FILE")"
|
||||
if [[ "$ARTIFACT_RELEASE_TAG" != "$RELEASE_TAG" ]]; then
|
||||
echo "Prepared preflight tag mismatch: expected $RELEASE_TAG, got $ARTIFACT_RELEASE_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ARTIFACT_RELEASE_SHA" != "$EXPECTED_RELEASE_SHA" ]]; then
|
||||
echo "Prepared preflight SHA mismatch: expected $EXPECTED_RELEASE_SHA, got $ARTIFACT_RELEASE_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ARTIFACT_PACKAGE_VERSION" != "$EXPECTED_PACKAGE_VERSION" ]]; then
|
||||
echo "Prepared preflight package version mismatch: expected $EXPECTED_PACKAGE_VERSION, got $ARTIFACT_PACKAGE_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve publish tarball
|
||||
id: publish_tarball
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL_PATH="$(find preflight-tarball -type f -name '*.tgz' -print | sort | tail -n 1)"
|
||||
if [[ -z "$TARBALL_PATH" ]]; then
|
||||
echo "Prepared preflight tarball not found." >&2
|
||||
ls -la preflight-tarball >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "path=$TARBALL_PATH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_target="${{ steps.publish_tarball.outputs.path }}"
|
||||
if [[ -n "${publish_target}" ]]; then
|
||||
publish_target="./${publish_target}"
|
||||
fi
|
||||
bash scripts/clawhub-cli-npm-publish.sh --publish "${publish_target}"
|
||||
@@ -1,46 +1,100 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: "What to deploy"
|
||||
required: true
|
||||
default: full
|
||||
type: choice
|
||||
options:
|
||||
- full
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
preflight-secrets:
|
||||
validate-deploy-request:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
deploy_backend: ${{ steps.mode.outputs.deploy_backend }}
|
||||
deploy_frontend: ${{ steps.mode.outputs.deploy_frontend }}
|
||||
run_smoke: ${{ steps.mode.outputs.run_smoke }}
|
||||
target: ${{ steps.mode.outputs.target }}
|
||||
steps:
|
||||
- name: Require main ref for production deploy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Production deploys must run from main."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve deploy mode
|
||||
id: mode
|
||||
run: |
|
||||
set -euo pipefail
|
||||
target="${{ inputs.target }}"
|
||||
case "$target" in
|
||||
full)
|
||||
echo "deploy_backend=true" >> "$GITHUB_OUTPUT"
|
||||
echo "deploy_frontend=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_smoke=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
backend)
|
||||
echo "deploy_backend=true" >> "$GITHUB_OUTPUT"
|
||||
echo "deploy_frontend=false" >> "$GITHUB_OUTPUT"
|
||||
echo "run_smoke=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
frontend)
|
||||
echo "deploy_backend=false" >> "$GITHUB_OUTPUT"
|
||||
echo "deploy_frontend=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_smoke=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported deploy target: $target" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "target=$target" >> "$GITHUB_OUTPUT"
|
||||
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
needs: validate-deploy-request
|
||||
environment:
|
||||
name: Production
|
||||
url: https://clawhub.ai
|
||||
env:
|
||||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||||
PLAYWRIGHT_AUTH_STORAGE_STATE_JSON: ${{ secrets.PLAYWRIGHT_AUTH_STORAGE_STATE_JSON }}
|
||||
PLAYWRIGHT_BASE_URL: https://clawhub.ai
|
||||
steps:
|
||||
- name: Check deploy secrets
|
||||
- name: Check deploy configuration
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=()
|
||||
|
||||
if [[ -z "$CONVEX_DEPLOY_KEY" ]]; then
|
||||
if [[ "${{ needs.validate-deploy-request.outputs.deploy_backend }}" == "true" && -z "$CONVEX_DEPLOY_KEY" ]]; then
|
||||
missing+=("CONVEX_DEPLOY_KEY")
|
||||
fi
|
||||
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
echo "::error::Missing required GitHub Actions secrets: ${missing[*]}"
|
||||
echo "::error::Missing required production environment secrets: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deploy target: ${{ needs.validate-deploy-request.outputs.target }}"
|
||||
|
||||
if [[ -z "$PLAYWRIGHT_AUTH_STORAGE_STATE_JSON" ]]; then
|
||||
echo "PLAYWRIGHT_AUTH_STORAGE_STATE_JSON not set; authenticated smoke will be skipped."
|
||||
fi
|
||||
|
||||
deploy-convex:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: preflight-secrets
|
||||
env:
|
||||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
|
||||
@@ -51,35 +105,38 @@ jobs:
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Stamp Convex build SHA
|
||||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||||
run: bunx convex env set APP_BUILD_SHA "${GITHUB_SHA}" --prod
|
||||
|
||||
- name: Stamp Convex deploy time
|
||||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||||
run: bunx convex env set APP_DEPLOYED_AT "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" --prod
|
||||
|
||||
- name: Deploy Convex
|
||||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||||
run: bun run convex:deploy
|
||||
|
||||
- name: Verify Convex contract
|
||||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||||
run: bun run verify:convex-contract -- --prod
|
||||
|
||||
wait-vercel-production:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs:
|
||||
- preflight-secrets
|
||||
- deploy-convex
|
||||
steps:
|
||||
- name: Wait for Vercel production deployment
|
||||
if: needs.validate-deploy-request.outputs.deploy_frontend == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
VERCEL_STATUS_CONTEXT: Vercel – clawhub
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in {1..90}; do
|
||||
state="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/status" \
|
||||
if ! state="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/status" \
|
||||
--jq '.statuses[] | select(.context == env.VERCEL_STATUS_CONTEXT) | .state' \
|
||||
2>/dev/null | head -n1)"
|
||||
2>/dev/null | head -n1)"; then
|
||||
echo "GitHub status check failed for $GITHUB_SHA; retrying..."
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
|
||||
case "$state" in
|
||||
success)
|
||||
@@ -104,35 +161,16 @@ jobs:
|
||||
echo "::error::Timed out waiting for Vercel production deployment for $GITHUB_SHA"
|
||||
exit 1
|
||||
|
||||
smoke-production:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs:
|
||||
- preflight-secrets
|
||||
- deploy-convex
|
||||
- wait-vercel-production
|
||||
env:
|
||||
PLAYWRIGHT_BASE_URL: https://clawhub.ai
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browser
|
||||
if: needs.validate-deploy-request.outputs.run_smoke == 'true'
|
||||
run: bunx playwright install --with-deps chromium
|
||||
|
||||
- name: Write authenticated storage state
|
||||
if: env.PLAYWRIGHT_AUTH_STORAGE_STATE_JSON != ''
|
||||
env:
|
||||
PLAYWRIGHT_AUTH_STORAGE_STATE_JSON: ${{ secrets.PLAYWRIGHT_AUTH_STORAGE_STATE_JSON }}
|
||||
if: needs.validate-deploy-request.outputs.run_smoke == 'true' && env.PLAYWRIGHT_AUTH_STORAGE_STATE_JSON != ''
|
||||
run: |
|
||||
echo "$PLAYWRIGHT_AUTH_STORAGE_STATE_JSON" > "$RUNNER_TEMP/playwright-auth.json"
|
||||
echo "PLAYWRIGHT_AUTH_STORAGE_STATE=$RUNNER_TEMP/playwright-auth.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Smoke test production
|
||||
if: needs.validate-deploy-request.outputs.run_smoke == 'true'
|
||||
run: bunx playwright test e2e/menu-smoke.pw.test.ts e2e/upload-auth-smoke.pw.test.ts
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
name: Package Publish
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
source:
|
||||
description: Package source to publish. Usually owner/repo, owner/repo@ref, or a GitHub URL.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
ref:
|
||||
description: Optional ref to append to the source when source is not already pinned.
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Preview only. When true, no publish mutation is performed.
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
json:
|
||||
description: Emit structured JSON output.
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
registry:
|
||||
description: ClawHub registry URL.
|
||||
required: false
|
||||
type: string
|
||||
default: https://clawhub.ai
|
||||
site:
|
||||
description: ClawHub site URL.
|
||||
required: false
|
||||
type: string
|
||||
default: https://clawhub.ai
|
||||
owner:
|
||||
description: Optional owner handle override for org/shared publishing.
|
||||
required: false
|
||||
type: string
|
||||
version:
|
||||
description: Optional package version override.
|
||||
required: false
|
||||
type: string
|
||||
tags:
|
||||
description: Optional comma-separated tags override.
|
||||
required: false
|
||||
type: string
|
||||
default: latest
|
||||
source_repo:
|
||||
description: Optional source repo override for local-folder publishes.
|
||||
required: false
|
||||
type: string
|
||||
source_commit:
|
||||
description: Optional source commit override for local-folder publishes.
|
||||
required: false
|
||||
type: string
|
||||
source_ref:
|
||||
description: Optional source ref override for local-folder publishes.
|
||||
required: false
|
||||
type: string
|
||||
clawhub_version:
|
||||
description: Legacy npm CLI version input. Kept for compatibility; the workflow now runs the checked-out source.
|
||||
required: false
|
||||
type: string
|
||||
default: latest
|
||||
secrets:
|
||||
clawhub_token:
|
||||
required: false
|
||||
outputs:
|
||||
publish_json:
|
||||
description: Structured JSON output from clawhub package publish.
|
||||
value: ${{ jobs.publish.outputs.publish_json }}
|
||||
release_id:
|
||||
description: Published release id when dry_run is false.
|
||||
value: ${{ jobs.publish.outputs.release_id }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
outputs:
|
||||
publish_json: ${{ steps.capture.outputs.publish_json }}
|
||||
release_id: ${{ steps.capture.outputs.release_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Resolve ClawHub workflow source
|
||||
id: clawhub_source
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
request_token = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "").strip()
|
||||
request_url = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL", "").strip()
|
||||
if not request_token or not request_url:
|
||||
raise SystemExit("GitHub OIDC token request env vars are missing; id-token: write is required.")
|
||||
|
||||
audience = "clawhub-workflow-source"
|
||||
joiner = "&" if "?" in request_url else "?"
|
||||
token_url = f"{request_url}{joiner}audience={audience}"
|
||||
request = Request(
|
||||
token_url,
|
||||
headers={"Authorization": f"Bearer {request_token}"},
|
||||
)
|
||||
with urlopen(request) as response:
|
||||
payload = json.load(response)
|
||||
|
||||
token = str(payload.get("value", "")).strip()
|
||||
if not token:
|
||||
raise SystemExit("GitHub OIDC token response did not include a token value.")
|
||||
|
||||
try:
|
||||
encoded_payload = token.split(".")[1]
|
||||
except IndexError as exc:
|
||||
raise SystemExit("GitHub OIDC token was not a valid JWT.") from exc
|
||||
padding = "=" * (-len(encoded_payload) % 4)
|
||||
claims = json.loads(
|
||||
base64.urlsafe_b64decode(encoded_payload + padding).decode("utf-8")
|
||||
)
|
||||
|
||||
workflow_ref = str(claims.get("job_workflow_ref", "")).strip()
|
||||
workflow_sha = str(claims.get("job_workflow_sha", "")).strip()
|
||||
repo, marker, _ = workflow_ref.partition("/.github/workflows/")
|
||||
if not marker or not repo or not workflow_sha:
|
||||
raise SystemExit(
|
||||
"Unable to resolve reusable workflow source from GitHub OIDC claims: "
|
||||
f"job_workflow_ref={workflow_ref!r} job_workflow_sha={workflow_sha!r}"
|
||||
)
|
||||
|
||||
output_path = Path(os.environ["GITHUB_OUTPUT"])
|
||||
with output_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(f"repository={repo}\n")
|
||||
fh.write(f"ref={workflow_sha}\n")
|
||||
PY
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ steps.clawhub_source.outputs.repository }}
|
||||
ref: ${{ steps.clawhub_source.outputs.ref }}
|
||||
path: clawhub-source
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
working-directory: clawhub-source
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Validate publish mode inputs
|
||||
env:
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
JSON_MODE: ${{ inputs.json }}
|
||||
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
if [[ "$JSON_MODE" != "true" ]]; then
|
||||
echo "::warning::This reusable workflow always emits JSON output; forcing --json for downstream parsing."
|
||||
fi
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ -n "$CLAWHUB_TOKEN" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" && -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]]; then
|
||||
echo "No ClawHub token provided; publish will rely on GitHub OIDC trusted publishing."
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::Real publishes need secrets.clawhub_token, or GitHub OIDC on workflow_dispatch runs (permissions.id-token=write)."
|
||||
exit 1
|
||||
|
||||
- name: Write ClawHub config
|
||||
env:
|
||||
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
||||
CLAWHUB_REGISTRY: ${{ inputs.registry }}
|
||||
run: |
|
||||
if [[ -z "$CLAWHUB_TOKEN" ]]; then
|
||||
echo "No ClawHub token provided, skipping config file creation."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(os.environ["RUNNER_TEMP"]) / "clawhub-config.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"registry": os.environ["CLAWHUB_REGISTRY"],
|
||||
"token": os.environ["CLAWHUB_TOKEN"],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(path)
|
||||
PY
|
||||
echo "CLAWHUB_CONFIG_PATH=$RUNNER_TEMP/clawhub-config.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Resolve publish command
|
||||
env:
|
||||
INPUT_SOURCE: ${{ inputs.source }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
INPUT_OWNER: ${{ inputs.owner }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_TAGS: ${{ inputs.tags }}
|
||||
INPUT_SOURCE_REPO: ${{ inputs.source_repo }}
|
||||
INPUT_SOURCE_COMMIT: ${{ inputs.source_commit }}
|
||||
INPUT_SOURCE_REF: ${{ inputs.source_ref }}
|
||||
INPUT_SITE: ${{ inputs.site }}
|
||||
INPUT_REGISTRY: ${{ inputs.registry }}
|
||||
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
source = os.environ["INPUT_SOURCE"].strip()
|
||||
if not source:
|
||||
source = os.environ["GITHUB_REPOSITORY"]
|
||||
source_is_current_repo = source == os.environ["GITHUB_REPOSITORY"]
|
||||
ref = os.environ["INPUT_REF"].strip()
|
||||
if not ref and source_is_current_repo:
|
||||
ref = os.environ["GITHUB_SHA"].strip()
|
||||
is_local_source = source.startswith(".") or source.startswith("/") or Path(source).exists()
|
||||
if ref and "@" not in source and not source.startswith("http") and not is_local_source:
|
||||
source = f"{source}@{ref}"
|
||||
|
||||
cli_entry = (
|
||||
Path(os.environ["GITHUB_WORKSPACE"])
|
||||
/ "clawhub-source"
|
||||
/ "packages"
|
||||
/ "clawhub"
|
||||
/ "src"
|
||||
/ "cli.ts"
|
||||
)
|
||||
if not cli_entry.exists():
|
||||
raise SystemExit(f"Missing ClawHub CLI entrypoint at {cli_entry}")
|
||||
|
||||
cmd = [
|
||||
"bun",
|
||||
str(cli_entry),
|
||||
"package",
|
||||
"publish",
|
||||
source,
|
||||
"--site",
|
||||
os.environ["INPUT_SITE"],
|
||||
"--registry",
|
||||
os.environ["INPUT_REGISTRY"],
|
||||
]
|
||||
|
||||
if os.environ["INPUT_DRY_RUN"] == "true":
|
||||
cmd.append("--dry-run")
|
||||
cmd.append("--json")
|
||||
|
||||
owner = os.environ["INPUT_OWNER"].strip()
|
||||
version = os.environ["INPUT_VERSION"].strip()
|
||||
tags = os.environ["INPUT_TAGS"].strip()
|
||||
if owner:
|
||||
cmd += ["--owner", owner]
|
||||
if version:
|
||||
cmd += ["--version", version]
|
||||
if tags:
|
||||
cmd += ["--tags", tags]
|
||||
source_repo = os.environ["INPUT_SOURCE_REPO"].strip()
|
||||
source_commit = os.environ["INPUT_SOURCE_COMMIT"].strip()
|
||||
source_ref = os.environ["INPUT_SOURCE_REF"].strip()
|
||||
if source_repo:
|
||||
cmd += ["--source-repo", source_repo]
|
||||
if source_commit:
|
||||
cmd += ["--source-commit", source_commit]
|
||||
if source_ref:
|
||||
cmd += ["--source-ref", source_ref]
|
||||
elif source_is_current_repo:
|
||||
github_ref = os.environ["GITHUB_REF"].strip()
|
||||
if github_ref:
|
||||
cmd += ["--source-ref", github_ref]
|
||||
if os.environ["INPUT_DRY_RUN"] != "true" and os.environ["CLAWHUB_TOKEN"].strip():
|
||||
cmd += [
|
||||
"--manual-override-reason",
|
||||
f"GitHub Actions {os.environ['GITHUB_EVENT_NAME'].strip()} publish via CLAWHUB_TOKEN",
|
||||
]
|
||||
|
||||
path = Path(os.environ["RUNNER_TEMP"]) / "clawhub-package-publish-command.sh"
|
||||
shell_line = " ".join(shlex.quote(part) for part in cmd)
|
||||
path.write_text("#!/usr/bin/env bash\nset -euo pipefail\n" + shell_line + "\n", encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
print(shell_line)
|
||||
PY
|
||||
|
||||
- name: Run package publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
"$RUNNER_TEMP/clawhub-package-publish-command.sh" | tee "$RUNNER_TEMP/package-publish.json"
|
||||
|
||||
- name: Capture workflow outputs
|
||||
id: capture
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
output_path = Path(os.environ["RUNNER_TEMP"]) / "package-publish.json"
|
||||
raw = output_path.read_text(encoding="utf-8").strip()
|
||||
parsed = json.loads(raw)
|
||||
|
||||
github_output = Path(os.environ["GITHUB_OUTPUT"])
|
||||
with github_output.open("a", encoding="utf-8") as fh:
|
||||
fh.write("publish_json<<__CLAWHUB_JSON__\n")
|
||||
fh.write(json.dumps(parsed, indent=2))
|
||||
fh.write("\n__CLAWHUB_JSON__\n")
|
||||
release_id = str(parsed.get("releaseId", "") or "")
|
||||
fh.write(f"release_id={release_id}\n")
|
||||
PY
|
||||
|
||||
- name: Upload publish JSON artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: clawhub-package-publish-json
|
||||
path: ${{ runner.temp }}/package-publish.json
|
||||
if-no-files-found: error
|
||||
@@ -1,6 +1,8 @@
|
||||
name: "Security Gate: Secret Scanning"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
@@ -16,6 +18,33 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # necessary to support the scoping requirements below
|
||||
|
||||
- name: Resolve scan range
|
||||
id: scan_range
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_BASE_SHA: ${{ github.event.before }}
|
||||
PUSH_HEAD_SHA: ${{ github.sha }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zero_sha="0000000000000000000000000000000000000000"
|
||||
|
||||
if [[ "$EVENT_NAME" == "pull_request" ]]; then
|
||||
base="$PR_BASE_SHA"
|
||||
head="$PR_HEAD_SHA"
|
||||
else
|
||||
base="$PUSH_BASE_SHA"
|
||||
head="$PUSH_HEAD_SHA"
|
||||
if [[ -z "$base" || "$base" == "$zero_sha" ]]; then
|
||||
base="origin/$DEFAULT_BRANCH"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "base=$base" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$head" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: TruffleHog OSS
|
||||
id: trufflehog
|
||||
# Use a concrete released ref that resolves in upstream action registry.
|
||||
@@ -23,8 +52,8 @@ jobs:
|
||||
uses: trufflesecurity/trufflehog@v3.93.8
|
||||
with:
|
||||
path: ./
|
||||
base: ${{ github.event.pull_request.base.sha }} # scope it to the committed files
|
||||
head: ${{ github.event.pull_request.head.sha }}
|
||||
base: ${{ steps.scan_range.outputs.base }}
|
||||
head: ${{ steps.scan_range.outputs.head }}
|
||||
extra_args: --only-verified --debug
|
||||
|
||||
- name: Notify on Failure
|
||||
|
||||
@@ -24,3 +24,7 @@ coverage
|
||||
playwright-report
|
||||
test-results
|
||||
.playwright
|
||||
convex/_generated/
|
||||
skills-lock.json
|
||||
*/skills/*
|
||||
skills/*
|
||||
@@ -38,10 +38,22 @@
|
||||
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`…).
|
||||
- Keep changes scoped; avoid repo-wide search/replace.
|
||||
- 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/clawdhub/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`.
|
||||
- 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.
|
||||
- Reject PRs that add skills into source code/repo content directly (for example under `skills/` or seed-only additions intended as published skills). Skills must be uploaded/published via CLI.
|
||||
|
||||
## Production Release
|
||||
|
||||
- Production deploys are manual-only. Merging to `main` does **not** deploy.
|
||||
- To release production, start the GitHub Actions `Deploy` workflow from `main`:
|
||||
`gh workflow run deploy.yml --repo openclaw/clawhub --ref main`
|
||||
- The workflow supports `full`, `backend`, and `frontend` targets.
|
||||
- `frontend` currently means: wait for the Vercel production deploy for the selected `main` SHA, then run production smoke checks. It does not call `vercel deploy` directly yet.
|
||||
- The workflow uses the GitHub `Production` environment for deploy secrets, but it does not require a separate approval step.
|
||||
- Prod deploy secrets live on the `Production` environment, not as ordinary repo secrets. Required: `CONVEX_DEPLOY_KEY`. Optional: `PLAYWRIGHT_AUTH_STORAGE_STATE_JSON`.
|
||||
- CLI npm releases are also manual-only and tag-based. Stable tags only: `vX.Y.Z`. Start `ClawHub CLI NPM Release` from `main`, first with `preflight_only=true`, then rerun it with the same tag and the successful `preflight_run_id`.
|
||||
- Real CLI publishes wait at the GitHub `npm-release` environment and use npm trusted publishing. Required npm trusted publisher settings: repository `openclaw/clawhub`, workflow `clawhub-cli-npm-release.yml`, environment `npm-release`.
|
||||
|
||||
## Git Notes
|
||||
|
||||
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
|
||||
@@ -75,3 +87,11 @@
|
||||
- **32K document limit per query.** Split `.collect()` calls by a partition field (e.g., one day at a time instead of a 7-day range). See `rebuildTrendingLeaderboardAction` in `convex/leaderboards.ts` for an example.
|
||||
- **Common mistakes**: `.filter().collect()` without an index; `ctx.db.get()` on large docs in a loop for list views; while loops that paginate the whole table to find filtered results.
|
||||
- **Before writing or reviewing Convex queries, check deployment health.** Run `bunx convex insights` to check for OCC conflicts, `bytesReadLimit`, and `documentsReadLimit` errors. Run `bunx convex logs --failure` to see individual error messages and stack traces. This helps identify which functions are causing bandwidth issues so you can prioritize fixes.
|
||||
|
||||
<!-- convex-ai-start -->
|
||||
This project uses [Convex](https://convex.dev) as its backend.
|
||||
|
||||
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
|
||||
|
||||
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
|
||||
<!-- convex-ai-end -->
|
||||
|
||||
@@ -1,5 +1,59 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
|
||||
|
||||
## 0.10.0 - 2026-04-05
|
||||
|
||||
### Added
|
||||
|
||||
- Design system: introduce a shared UI component library (`src/components/ui/`) built on Radix UI primitives — Button, Card, Badge, Tabs, Dialog, Input, Textarea, Label, Select, Avatar, Separator, Tooltip, ScrollArea, Sheet, Skeleton, and Table — following the shadcn/ui pattern with `cn()` + Tailwind utilities.
|
||||
- Design system: `Button` supports `asChild` via Radix Slot for polymorphic rendering (e.g., wrapping `<Link>` without extra DOM).
|
||||
- Layout: add `Container` component with `narrow` / `default` / `wide` size presets and `Breadcrumb` component for hierarchical navigation.
|
||||
- Loading: add skeleton loading states (`SkillCardSkeleton`, `SkillDetailSkeleton`, `DashboardSkeleton`) replacing text-based "Loading..." indicators with animated placeholders.
|
||||
- Errors: add `ErrorBoundary` with `resetKey` prop that auto-resets on route changes, wired into the root layout.
|
||||
- Errors: surface fallback messages from Convex API error payloads in mutation/action error toasts.
|
||||
- UX: add `EmptyState` component with icon, headline, description, and optional CTA action used across dashboard, stars, profile, and publish pages.
|
||||
- UX: add confirmation dialogs for destructive skill ownership actions (transfer, abandon).
|
||||
- Markdown: add `MarkdownPreview` component with `react-markdown`, `remark-gfm`, and `react-syntax-highlighter` for rich rendering of skill/plugin READMEs with syntax-highlighted code blocks, GFM tables, and task lists.
|
||||
- Markdown: render tables with the new `Table` UI primitive for consistent styling across skill docs.
|
||||
- Navigation: replace DropdownMenu-based mobile nav with a slide-out `Sheet` panel.
|
||||
- Validation: add Zod schemas (`src/lib/schemas.ts`) for publish-skill, settings, report, and org forms.
|
||||
- Management: restore capability-tags UI (crypto, requires-wallet, can-make-purchases, etc.) that was silently removed during the initial refactor.
|
||||
- Management: add `.catch()` error handling with toast feedback on `setSoftDeleted` calls; prompt for hide/restore reasons.
|
||||
|
||||
### Changed
|
||||
|
||||
- CSS: migrate from a monolithic 5,161-line `styles.css` to Tailwind utilities on components, pruning CSS to ~1,000 lines (81% reduction). Dark mode now uses Tailwind `dark:` variants via a `@variant dark` directive bridging existing CSS custom properties.
|
||||
- Tailwind: add `@theme` block mapping all CSS design tokens (`--bg`, `--surface`, `--ink`, `--accent`, `--line`, `--radius-*`, etc.) into first-class Tailwind utilities.
|
||||
- Pages: modernize all route pages (home, skills browse, skill detail, dashboard, settings, publish-skill, publish-plugin, import, about, CLI auth, stars, souls, user profile, org profile, management, plugins browse, plugin detail) from CSS class selectors to Tailwind + UI primitives.
|
||||
- Skills browse: widen container to `wide` (1400px) for better use of screen space on desktop; same for plugins browse.
|
||||
- Skills browse: replace text-based filter toggles with pill chips and modernize toolbar layout.
|
||||
- Skill detail: migrate tab controls from CSS-styled buttons to Radix `Tabs` primitive with proper `role="tab"` accessibility.
|
||||
- Skill detail: replace inline CSS class-based install card with `SkillInstallCard` using Card + Button primitives.
|
||||
- Header/Footer: migrate from CSS classes to Tailwind utilities with responsive Sheet-based mobile navigation.
|
||||
- Dashboard: replace CSS table layout with `Table` UI primitive; add metric cards and skeleton loading.
|
||||
- Settings: modernize form inputs with `Input`/`Textarea`/`Label` primitives and structured layout.
|
||||
- Publish: use `Dialog` primitive for modals; inline validation indicators; modernized file list display.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Auth: `EmptyState` "Sign in" button on publish page now triggers GitHub OAuth via `useAuthActions` instead of linking to non-existent `/signin` route.
|
||||
- API: fix plugins page dev-mode `{"error":"Only HTML requests are supported here"}` by routing SSR and localhost API fetches directly to the Convex site URL instead of through TanStack Start's request pipeline.
|
||||
- API: fix CORS error when `credentials: "include"` conflicts with `Access-Control-Allow-Origin: *` by making credentials conditional on same-origin requests.
|
||||
- API: fix SSR `packageApiUrl` to always use `VITE_CONVEX_SITE_URL` directly, avoiding `getRequestUrl()` failures when SSR request context is unavailable.
|
||||
- Management: restore `setSoftDeleted` reason parameter for hide/restore actions.
|
||||
- Tests: rename `settings.test.tsx` to `-settings.test.tsx` to exclude from TanStack Router's file-based route discovery.
|
||||
- Tests: add `@convex-dev/auth/react` mock for `useAuthActions` in upload route tests.
|
||||
- Tests: update skill detail tests for Radix tab roles (`role="tab"` instead of `role="button"`), skeleton loading classes (`animate-pulse`), and capability tag data.
|
||||
- Tests: update skills index tests for refreshed UI copy (placeholder text, empty state wording, loading indicator patterns).
|
||||
- Tests: update SkillDiffCard tests for Tailwind active-tab class (`shadow-sm` replacing `.is-active`).
|
||||
- Tests: update packages publish route tests for Tailwind border classes.
|
||||
- Tests: update packageApi tests for conditional credentials and SSR URL resolution.
|
||||
|
||||
## 0.9.0 - 2026-03-23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -30,7 +30,26 @@
|
||||
- NEVER use `--typecheck=disable` on `npx convex deploy`.
|
||||
- Use `npx convex dev --once` to push functions once (not long-running watcher).
|
||||
|
||||
## Production Release
|
||||
|
||||
- Production deploys are manual-only. Merging to `main` does **not** deploy.
|
||||
- Start the GitHub Actions `Deploy` workflow from `main` with `gh workflow run deploy.yml --repo openclaw/clawhub --ref main`.
|
||||
- The workflow supports `full`, `backend`, and `frontend` targets.
|
||||
- `frontend` currently waits for the Vercel production deploy on the selected `main` SHA and then runs smoke checks. It does not trigger Vercel directly yet.
|
||||
- The workflow uses the `Production` environment for deploy secrets, but it does not wait for a separate approval.
|
||||
- Required prod secret: `CONVEX_DEPLOY_KEY` on the `Production` environment. Optional smoke secret: `PLAYWRIGHT_AUTH_STORAGE_STATE_JSON`.
|
||||
- CLI npm releases are manual-only and tag-based through `ClawHub CLI NPM Release`. Stable tags only: `vX.Y.Z`. Run a `preflight_only=true` pass first, then rerun with the same tag plus `preflight_run_id` for the real publish.
|
||||
- Real CLI publishes wait at `npm-release` and rely on npm trusted publishing for `openclaw/clawhub` + `clawhub-cli-npm-release.yml` + `npm-release`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Tests use `._handler` to call mutation handlers directly with mock `db` objects.
|
||||
- Mock `db` objects MUST include `normalizeId: vi.fn()` for trigger wrapper compatibility.
|
||||
|
||||
<!-- convex-ai-start -->
|
||||
This project uses [Convex](https://convex.dev) as its backend.
|
||||
|
||||
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
|
||||
|
||||
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
|
||||
<!-- convex-ai-end -->
|
||||
|
||||
+13
-1
@@ -109,7 +109,7 @@ These features degrade gracefully without their keys:
|
||||
|
||||
## CLI Development
|
||||
|
||||
The CLI source lives in [`packages/clawdhub/`](packages/clawdhub/). Both `clawhub` and `clawdhub` are registered as bin aliases.
|
||||
The CLI source lives in [`packages/clawhub/`](packages/clawhub/). Both `clawhub` and `clawdhub` are registered as bin aliases.
|
||||
|
||||
To test the CLI against your local instance:
|
||||
|
||||
@@ -117,6 +117,17 @@ To test the CLI against your local instance:
|
||||
CLAWHUB_REGISTRY=http://127.0.0.1:3210 CLAWHUB_SITE=http://localhost:3000 clawhub search "padel"
|
||||
```
|
||||
|
||||
Use the package-local verification contract when working on the CLI:
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/clawhub test
|
||||
bun run --cwd packages/clawhub verify:build
|
||||
bun run --cwd packages/clawhub test:artifact
|
||||
bun run --cwd packages/clawhub verify
|
||||
```
|
||||
|
||||
`bun test packages/clawhub/` is not the supported workflow. Source tests and built-artifact smoke tests are intentionally split.
|
||||
|
||||
Manual smoke tests are documented in [`docs/manual-testing.md`](docs/manual-testing.md).
|
||||
|
||||
## Skill & Soul Publishing
|
||||
@@ -137,6 +148,7 @@ clawhub publish <path-to-skill-directory>
|
||||
bun run lint # oxlint
|
||||
bun run test # Vitest (80% coverage threshold)
|
||||
bun run build # Vite + Nitro
|
||||
bun run --cwd packages/clawhub verify
|
||||
```
|
||||
|
||||
These are the same checks that run in CI (`.github/workflows/ci.yml`).
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
# ClawHub Design System
|
||||
|
||||
This document outlines the design rules, patterns, and guidelines for the ClawHub platform to ensure consistency, accessibility, and maintainability across all components.
|
||||
|
||||
---
|
||||
|
||||
## Color System
|
||||
|
||||
### Brand Palette (OpenClaw)
|
||||
|
||||
ClawHub uses a strict **3-5 color palette** based on the OpenClaw brand:
|
||||
|
||||
| Token | Light Mode | Dark Mode | Usage |
|
||||
|-------|------------|-----------|-------|
|
||||
| `--accent` | `#dc2626` | `#dc2626` | Primary actions, interactive elements, emphasis |
|
||||
| `--accent-deep` | `#b91c1c` | `#ef4444` | Hover states, secondary emphasis |
|
||||
| `--ink` | `#0a0a0a` | `#fafafa` | Primary text |
|
||||
| `--ink-soft` | `#525252` | `#a1a1a1` | Secondary text, descriptions |
|
||||
| `--surface` | `#ffffff` | `#121212` | Card backgrounds, elevated surfaces |
|
||||
| `--bg` | `#fafafa` | `#0a0a0a` | Page background |
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Never exceed 5 colors** without explicit design approval
|
||||
2. **Never use purple/violet prominently** unless explicitly requested
|
||||
3. **Always override text color** when changing background color to ensure contrast
|
||||
4. **Use semantic tokens** (`--accent`, `--ink`, `--surface`) instead of raw colors
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Stack
|
||||
|
||||
```css
|
||||
--font-sans: 'Geist', system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono', monospace;
|
||||
--font-display: 'Geist', system-ui, sans-serif;
|
||||
```
|
||||
|
||||
### Scale
|
||||
|
||||
| Token | Size | Usage |
|
||||
|-------|------|-------|
|
||||
| `--fs-xs` | 0.75rem (12px) | Labels, badges, metadata |
|
||||
| `--fs-sm` | 0.875rem (14px) | Body text, descriptions |
|
||||
| `--fs-base` | 1rem (16px) | Default body text |
|
||||
| `--fs-md` | 1.125rem (18px) | Subheadings |
|
||||
| `--fs-lg` | 1.25rem (20px) | Section titles |
|
||||
| `--fs-xl` | 1.5rem (24px) | Page headings |
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Maximum 2 font families** per page
|
||||
2. **Line height 1.4-1.6** for body text (use `leading-relaxed`)
|
||||
3. **Never use decorative fonts** for body text
|
||||
4. **Minimum font size: 14px** for readability
|
||||
5. Use `text-balance` or `text-pretty` for titles
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
### Method Priority
|
||||
|
||||
Use this hierarchy for layout decisions:
|
||||
|
||||
1. **Flexbox** - Default for most layouts
|
||||
2. **CSS Grid** - Only for complex 2D layouts (cards, galleries)
|
||||
3. **Never use floats** or absolute positioning unless absolutely necessary
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
```css
|
||||
--space-1: 0.25rem /* 4px */
|
||||
--space-2: 0.5rem /* 8px */
|
||||
--space-3: 0.75rem /* 12px */
|
||||
--space-4: 1rem /* 16px */
|
||||
--space-5: 1.5rem /* 24px */
|
||||
--space-6: 2rem /* 32px */
|
||||
```
|
||||
|
||||
### Grid Patterns
|
||||
|
||||
#### Auto-fit Grid (Recommended for Cards)
|
||||
```css
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
```
|
||||
- Automatically adjusts columns based on container width
|
||||
- Prevents orphan items on partial rows
|
||||
- Maintains consistent card widths
|
||||
|
||||
#### Fixed Grid (When exact columns needed)
|
||||
```css
|
||||
/* 3-column at desktop, 2 at tablet, 1 at mobile */
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@media (max-width: 860px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
```
|
||||
|
||||
### Container Widths
|
||||
|
||||
| Size | Max Width | Usage |
|
||||
|------|-----------|-------|
|
||||
| Default | `--page-max` (1200px) | Standard pages |
|
||||
| Narrow | `--page-narrow` (720px) | Reading content, forms |
|
||||
| Wide | Full width | Dashboards, data tables |
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Always use `display: flex; flex-direction: column;` for consistent height
|
||||
- Add `flex: 1` to content area for equal-height cards in grids
|
||||
- Include hover state with `border-color` and subtle `box-shadow`
|
||||
|
||||
### Buttons
|
||||
|
||||
| Variant | Usage |
|
||||
|---------|-------|
|
||||
| `primary` | Main actions (Submit, Save, Download) |
|
||||
| `secondary` | Alternative actions |
|
||||
| `ghost` | Tertiary actions, navigation |
|
||||
| `destructive` | Delete, remove, dangerous actions |
|
||||
|
||||
**Rules:**
|
||||
- Always include visible focus state
|
||||
- Minimum touch target: 44x44px on mobile
|
||||
- Include `aria-label` when icon-only
|
||||
|
||||
### Form Controls
|
||||
|
||||
- Labels above inputs (not inline)
|
||||
- Error states use `--status-error-fg`
|
||||
- Focus rings use `--accent` with 0.2 opacity
|
||||
- Minimum input height: 40px
|
||||
|
||||
---
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
```css
|
||||
/* Mobile first - base styles for mobile */
|
||||
|
||||
@media (min-width: 520px) {
|
||||
/* Small tablets, large phones */
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
/* Tablets */
|
||||
}
|
||||
|
||||
@media (min-width: 860px) {
|
||||
/* Small desktops, landscape tablets */
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
/* Desktops */
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
/* Large desktops */
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Mobile-first approach** - Base styles target mobile
|
||||
2. **Progressive enhancement** - Add complexity as viewport increases
|
||||
3. **Test intermediate breakpoints** - Avoid jarring layout jumps
|
||||
4. **Never hide critical content** on mobile
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Color Contrast
|
||||
|
||||
- Normal text: Minimum 4.5:1 ratio
|
||||
- Large text (18px+): Minimum 3:1 ratio
|
||||
- Interactive elements: Minimum 3:1 ratio
|
||||
|
||||
### Focus States
|
||||
|
||||
```css
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
### Screen Readers
|
||||
|
||||
- Use `sr-only` class for visually hidden but accessible text
|
||||
- Always include `alt` text for images (empty `alt=""` for decorative)
|
||||
- Use semantic HTML elements (`main`, `nav`, `article`, `section`)
|
||||
- Proper heading hierarchy (h1 > h2 > h3, no skipping)
|
||||
|
||||
### Motion
|
||||
|
||||
```css
|
||||
/* Respect user preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animation
|
||||
|
||||
### Timing
|
||||
|
||||
```css
|
||||
--transition-fast: 150ms;
|
||||
--transition-base: 200ms;
|
||||
--transition-slow: 300ms;
|
||||
```
|
||||
|
||||
### Easing
|
||||
|
||||
- Use `ease` or `ease-out` for most transitions
|
||||
- Use `ease-in-out` for enter/exit animations
|
||||
- Never use `linear` except for continuous animations
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Subtle by default** - Avoid flashy animations
|
||||
2. **Purpose-driven** - Animation should provide feedback
|
||||
3. **Respect preferences** - Support `prefers-reduced-motion`
|
||||
4. **Performance** - Use `transform` and `opacity` only
|
||||
|
||||
---
|
||||
|
||||
## Icons
|
||||
|
||||
### Usage
|
||||
|
||||
- Use Lucide icons consistently
|
||||
- Standard sizes: 14px, 16px, 20px, 24px
|
||||
- Include `aria-hidden="true"` for decorative icons
|
||||
- Never use emojis as icons
|
||||
|
||||
### Placement
|
||||
|
||||
- Left of labels in buttons and navigation
|
||||
- Right of labels for external links or dropdowns
|
||||
- Centered when used alone with `aria-label`
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode
|
||||
|
||||
### Implementation
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
/* Dark mode overrides */
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. Never use pure white (`#ffffff`) on dark backgrounds
|
||||
2. Reduce shadow intensity in dark mode
|
||||
3. Adjust image brightness if needed
|
||||
4. Test contrast ratios in both modes
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### CSS
|
||||
|
||||
1. Use CSS custom properties for theming
|
||||
2. Avoid deeply nested selectors (max 3 levels)
|
||||
3. Use `will-change` sparingly
|
||||
4. Prefer `transform` over `top/left` for animations
|
||||
|
||||
### Images
|
||||
|
||||
1. Always specify `width` and `height` attributes
|
||||
2. Use `loading="lazy"` for below-fold images
|
||||
3. Use appropriate formats (WebP with fallbacks)
|
||||
4. Include placeholder or skeleton states
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
### CSS Class Naming
|
||||
|
||||
```css
|
||||
/* Component */
|
||||
.component-name { }
|
||||
|
||||
/* Component modifier */
|
||||
.component-name.variant { }
|
||||
|
||||
/* Component child */
|
||||
.component-name-child { }
|
||||
|
||||
/* State */
|
||||
.component-name.is-active { }
|
||||
.component-name[data-state="open"] { }
|
||||
```
|
||||
|
||||
### File Organization
|
||||
|
||||
```
|
||||
src/
|
||||
components/
|
||||
ui/ # Primitive components (Button, Input, Card)
|
||||
layout/ # Layout components (Container, Header)
|
||||
styles.css # Global styles and design tokens
|
||||
lib/
|
||||
theme.ts # Theme utilities
|
||||
preferences.ts # User preference management
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Before shipping any UI changes, verify:
|
||||
|
||||
- [ ] Color contrast meets WCAG AA standards
|
||||
- [ ] Focus states are visible
|
||||
- [ ] Layout works at all breakpoints
|
||||
- [ ] Animations respect `prefers-reduced-motion`
|
||||
- [ ] Text is readable at default browser zoom
|
||||
- [ ] Interactive elements have 44px minimum touch target
|
||||
- [ ] Semantic HTML is used appropriately
|
||||
- [ ] Dark mode has been tested
|
||||
@@ -61,8 +61,8 @@ Common CLI flows:
|
||||
- Browse unified catalog (skills + plugins): `clawhub package explore`, `clawhub package inspect <name>`
|
||||
- Manage local installs: `clawhub install <slug>`, `clawhub uninstall <slug>`, `clawhub list`, `clawhub update --all`
|
||||
- Inspect without installing: `clawhub inspect <slug>`
|
||||
- Publish/sync: `clawhub publish <path>`, `clawhub sync`
|
||||
- Publish plugins: `clawhub package publish <path> [--owner <handle>] --source-repo <owner/repo> --source-commit <sha>`
|
||||
- Publish/sync skills: `clawhub skill publish <path>`, `clawhub sync`
|
||||
- Publish plugins: `clawhub package publish <source>`
|
||||
- Canonicalize owned skills: `clawhub skill rename <slug> <new-slug>`, `clawhub skill merge <source> <target>`
|
||||
|
||||
Docs: [`docs/quickstart.md`](docs/quickstart.md), [`docs/cli.md`](docs/cli.md).
|
||||
|
||||
@@ -1,71 +1,91 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "clawhub",
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.37.4",
|
||||
"@convex-dev/auth": "^0.0.91",
|
||||
"@create-markdown/core": "^2.0.2",
|
||||
"@create-markdown/preview": "^2.0.2",
|
||||
"@fontsource/bricolage-grotesque": "^5.2.10",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/manrope": "^5.2.8",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@resvg/resvg-wasm": "^2.6.2",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/react-devtools": "0.10.0",
|
||||
"@tanstack/react-router": "1.168.1",
|
||||
"@tanstack/react-router-devtools": "1.166.10",
|
||||
"@tanstack/react-start": "1.167.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/router-plugin": "1.167.2",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.34.0",
|
||||
"convex": "^1.34.1",
|
||||
"convex-helpers": "^0.1.114",
|
||||
"fflate": "^0.8.2",
|
||||
"h3": "2.0.1-rc.18",
|
||||
"ignore": "^7.0.5",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nitro": "3.0.260311-beta",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.4",
|
||||
"shiki": "^4.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tanstack/devtools-vite": "0.6.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"jsdom": "^29.0.1",
|
||||
"only-allow": "^1.2.2",
|
||||
"oxfmt": "0.41.0",
|
||||
"oxlint": "^1.56.0",
|
||||
"oxlint-tsgolint": "^0.17.1",
|
||||
"oxlint": "^1.58.0",
|
||||
"oxlint-tsgolint": "^0.17.4",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.24.5",
|
||||
"vite": "8.0.1",
|
||||
"vitest": "^4.1.0",
|
||||
"undici": "^7.24.7",
|
||||
"vite": "8.0.5",
|
||||
"vitest": "^4.1.2",
|
||||
},
|
||||
},
|
||||
"packages/clawdhub": {
|
||||
"packages/clawhub": {
|
||||
"name": "clawhub",
|
||||
"version": "0.8.0",
|
||||
"version": "0.10.0",
|
||||
"bin": {
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
"clawhub": "bin/clawdhub.js",
|
||||
@@ -162,6 +182,10 @@
|
||||
|
||||
"@convex-dev/auth": ["@convex-dev/auth@0.0.91", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "cookie": "^1.0.1", "is-network-error": "^1.1.0", "jose": "^5.2.2", "jwt-decode": "^4.0.0", "lucia": "^3.2.0", "oauth4webapi": "^3.1.2", "path-to-regexp": "^6.3.0", "server-only": "^0.0.1" }, "peerDependencies": { "@auth/core": "^0.37.0", "convex": "^1.17.0", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["react"], "bin": { "auth": "dist/bin.cjs" } }, "sha512-wLD4hszo3IhhMkwPs6ozWf0cUauwmhOvjUVn0g//kC338n/jApOjeDYWKCrn/qYUkveyDsbag5zrY8mVzA09Qg=="],
|
||||
|
||||
"@create-markdown/core": ["@create-markdown/core@2.0.2", "", {}, "sha512-maA3zw9HkdOZORpKyvmxcRFTTOCpClLW01oAuVtzW7LvafHippRz67VHngIBEsIPKEO5j4COItwXKFnzo9/dfA=="],
|
||||
|
||||
"@create-markdown/preview": ["@create-markdown/preview@2.0.2", "", { "peerDependencies": { "@create-markdown/core": ">=2.0.2", "mermaid": ">=10.0.0", "shiki": ">=1.0.0" }, "optionalPeers": ["@create-markdown/core", "mermaid", "shiki"] }, "sha512-ty1mp7qXVI0Bap8M0jiDiJsAqZkP3oaYNp0JX0wiY4K+KfWgK4IqeB8R2W+9vLpRxzxhX6Rggf5Qj2Sv5p75Eg=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.1.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ=="],
|
||||
@@ -248,6 +272,56 @@
|
||||
|
||||
"@fontsource/manrope": ["@fontsource/manrope@5.2.8", "", {}, "sha512-gJHJmcuUk7qWcNCfcAri/DJQtXtBYqi9yKratr4jXhSo0I3xUtNNKI+igQIcw5c+m95g0vounk8ZnX/kb8o0TA=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -264,6 +338,24 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
|
||||
|
||||
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
|
||||
|
||||
"@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="],
|
||||
@@ -280,7 +372,7 @@
|
||||
|
||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw=="],
|
||||
|
||||
@@ -320,69 +412,75 @@
|
||||
|
||||
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.41.0", "", { "os": "win32", "cpu": "x64" }, "sha512-49ZSpbZ1noozyPapE8SUOSm3IN0Ze4b5nkO+4+7fq6oEYQQJFhE0saj5k/Gg4oewVPdjn0L3ZFeWk2Vehjcw7A=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.17.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JNWNwyvSDcUQSBlQRl10XrCeNcN66TMvDw3gIDQeop5SNa1F7wFhsEx4zitYb7fGHwGh9095tsNttmuCaNXCbw=="],
|
||||
"@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.17.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XEA7vl/T1+wiVnMq2MR6u5OYr2pwKHiAPgklxpK8tPrjQ1ci/amNmwI8ECn6TPXSCsC8SJsSN5xvzXm5H3dTfw=="],
|
||||
|
||||
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.17.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-SluNf6CW88pgGPqQUGC5GoK5qESWo2ct1PRDbza3vbf9SK2npx3igvylGQIgE9qYYOcjgnVdLOJ0+q0gItgUmQ=="],
|
||||
"@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.17.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-EY2wmHWqkz72B0/ddMiAM564ZXpEuN1i7JqJJhLmDUQfiHX0/X0EqK3xlSScMCFcVicitOxbKO9oqbde3658yg=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.17.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-BJxQ7/cdo2dNdGIBs2PIR6BaPA7cPfe+r1HE/uY+K7g2ygip+0LHB3GUO9GaNDZuWpsnDyjLYYowEGrVK8dokA=="],
|
||||
"@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.17.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-XL2X8hgp3/TZWeHFLUnWrveTCBPxy1kNtpzfvVkLtBgyoaRyopPYL0Mnm+ypXKgGvUdcjDaiJhnRjFHWmqZkew=="],
|
||||
|
||||
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.17.1", "", { "os": "linux", "cpu": "x64" }, "sha512-s6UjmuaJbZ4zz/wJKdEw/s5mc0t41rgwxQJCSHPuzMumMK6ylrB7nydhDf8ObTtzhTIZdAS/2S/uayJmDcGbxw=="],
|
||||
"@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.17.4", "", { "os": "linux", "cpu": "x64" }, "sha512-jT+aWtQuU8jefwfBLAZu16p4t8xUDjxL6KKlOeuwX3cS6NO60ITJ4Glm8eQYq5cGsOmYIKXNIe4ckPpL5LC+5g=="],
|
||||
|
||||
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.17.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-EO/Oj0ixHX+UQdu9hM7YUzibZI888MvPUo/DF8lSxFBt4JNEt8qGkwJEbCYjB/1LhUNmPHzSw2Tr9dCFVfW9nw=="],
|
||||
"@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.17.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-pnnkBaI5tHBFhx+EhmpUHccBT3VOAXTgWK2eQBVLE4a/ywhpHN+8D6/QQN+ZTaA4LTkKowvlGD6vDOVP5KRPvw=="],
|
||||
|
||||
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.17.1", "", { "os": "win32", "cpu": "x64" }, "sha512-jhv7XktAJ1sMRSb//yDYTauFSZ06H81i2SLEBPaSUKxSKoPMK8p1ACUJlnmwZX2MgapRLEj1Ml22B6+HiM2YIA=="],
|
||||
"@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.17.4", "", { "os": "win32", "cpu": "x64" }, "sha512-JxT81aEUBNA/s01Ql2OQ2DLAsuM0M+mK9iLHunukOdPMhjA6NvFE/GtTablBYJKScK21d/xTvnoSLgQU3l22Cw=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-IyfYPthZyiSKwAv/dLjeO18SaK8MxLI9Yss2JrRDyweQAkuL3LhEy7pwIwI7uA3KQc1Vdn20kdmj3q0oUIQL6A=="],
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.58.0", "", { "os": "android", "cpu": "arm" }, "sha512-1T7UN3SsWWxpWyWGn1cT3ASNJOo+pI3eUkmEl7HgtowapcV8kslYpFQcYn431VuxghXakPNlbjRwhqmR37PFOg=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.56.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ga5zYrzH6vc/VFxhn6MmyUnYEfy9vRpwTIks99mY3j6Nz30yYpIkWryI0QKPCgvGUtDSXVLEaMum5nA+WrNOSg=="],
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.58.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GryzujxuiRv2YFF7bRy8mKcxlbuAN+euVUtGJt9KKbLT8JBUIosamVhcthLh+VEr6KE6cjeVMAQxKAzJcoN7dg=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.56.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ogmbdJysnw/D4bDcpf1sPLpFThZ48lYp4aKYm10Z/6Nh1SON6NtnNhTNOlhEY296tDFItsZUz+2tgcSYqh8Eyw=="],
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.58.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7/bRSJIwl4GxeZL9rPZ11anNTyUO9epZrfEJH/ZMla3+/gbQ6xZixh9nOhsZ0QwsTW7/5J2A/fHbD1udC5DQQA=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.56.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-x8QE1h+RAtQ2g+3KPsP6Fk/tdz6zJQUv5c7fTrJxXV3GHOo+Ry5p/PsogU4U+iUZg0rj6hS+E4xi+mnwwlDCWQ=="],
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.58.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EqdtJSiHweS2vfILNrpyJ6HUwpEq2g7+4Zx1FPi4hu3Hu7tC3znF6ufbXO8Ub2LD4mGgznjI7kSdku9NDD1Mkg=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.56.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6G+WMZvwJpMvY7my+/SHEjb7BTk/PFbePqLpmVmUJRIsJMy/UlyYqjpuh0RCgYYkPLcnXm1rUM04kbTk8yS1Yg=="],
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.58.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-VQt5TH4M42mY20F545G637RKxV/yjwVtKk2vfXuazfReSIiuvWBnv+FVSvIV5fKVTJNjt3GSJibh6JecbhGdBw=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-YYHBsk/sl7fYwQOok+6W5lBPeUEvisznV/HZD2IfZmF3Bns6cPC3Z0vCtSEOaAWTjYWN3jVsdu55jMxKlsdlhg=="],
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-fBYcj4ucwpAtjJT3oeBdFBYKvNyjRSK+cyuvBOTQjh0jvKp4yeA4S/D0IsCHus/VPaNG5L48qQkh+Vjy3HL2/Q=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+AZK8rOUr78y8WT6XkDb04IbMRqauNV+vgT6f8ZLOH8wnpQ9i7Nol0XLxAu+Cq7Sb+J9wC0j6Km5hG8rj47/yQ=="],
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0BeuFfwlUHlJ1xpEdSD1YO3vByEFGPg36uLjK1JgFaxFb4W6w17F8ET8sz5cheZ4+x5f2xzdnRrrWv83E3Yd8g=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-urse2SnugwJRojUkGSSeH2LPMaje5Q50yQtvtL9HFckiyeqXzoFwOAZqD5TR29R2lq7UHidfFDM9EGcchcbb8A=="],
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TXlZgnPTlxrQzxG9ZXU7BNwx1Ilrr17P3GwZY0If2EzrinqRH3zXPc3HrRcBJgcsoZNMuNL5YivtkJYgp467UQ=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-rkTZkBfJ4TYLjansjSzL6mgZOdN5IvUnSq3oNJSLwBcNvy3dlgQtpHPrRxrCEbbcp7oQ6If0tkNaqfOsphYZ9g=="],
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zSoYRo5dxHLcUx93Stl2hW3hSNjPt99O70eRVWt5A1zwJ+FPjeCCANCD2a9R4JbHsdcl11TIQOjyigcRVOH2mw=="],
|
||||
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-uqL1kMH3u69/e1CH2EJhP3CP28jw2ExLsku4o8RVAZ7fySo9zOyI2fy9pVlTAp4voBLVgzndXi3SgtdyCTa2aA=="],
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.58.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NQ0U/lqxH2/VxBYeAIvMNUK1y0a1bJ3ZicqkF2c6wfakbEciP9jvIE4yNzCFpZaqeIeRYaV7AVGqEO1yrfVPjA=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-j0CcMBOgV6KsRaBdsebIeiy7hCjEvq2KdEsiULf2LZqAq0v1M1lWjelhCV57LxsqaIGChXFuFJ0RiFrSRHPhSg=="],
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-X9J+kr3gIC9FT8GuZt0ekzpNUtkBVzMVU4KiKDSlocyQuEgi3gBbXYN8UkQiV77FTusLDPsovjo95YedHr+3yg=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-7VDOiL8cDG3DQ/CY3yKjbV1c4YPvc4vH8qW09Vv+5ukq3l/Kcyr6XGCd5NvxUmxqDb2vjMpM+eW/4JrEEsUetA=="],
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-CDze3pi1OO3Wvb/QsXjmLEY4XPKGM6kIo82ssNOgmcl1IdndF9VSGAE38YLhADWmOac7fjqhBw82LozuUVxD0Q=="],
|
||||
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.56.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JGRpX0M+ikD3WpwJ7vKcHKV6Kg0dT52BW2Eu2BupXotYeqGXBrbY+QPkAyKO6MNgKozyTNaRh3r7g+VWgyAQYQ=="],
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.58.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-b/89glbxFaEAcA6Uf1FvCNecBJEgcUTsV1quzrqXM/o4R1M4u+2KCVuyGCayN2UpsRWtGGLb+Ver0tBBpxaPog=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-dNaICPvtmuxFP/VbqdofrLqdS3bM/AKJN3LMJD52si44ea7Be1cBk6NpfIahaysG9Uo+L98QKddU9CD5L8UHnQ=="],
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0/yYpkq9VJFCEcuRlrViGj8pJUFFvNS4EkEREaN7CB1EcLXJIaVSSa5eCihwBGXtOZxhnblWgxks9juRdNQI7w=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pF1vOtM+GuXmbklM1hV8WMsn6tCNPvkUzklj/Ej98JhlanbmA2RB1BILgOpwSuCTRTIYx2MXssmEyQQ90QF5aA=="],
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hr6FNvmcAXiH+JxSvaJ4SJ1HofkdqEElXICW9sm3/Rd5eC3t7kzvmLyRAB3NngKO2wzXRCAm4Z/mGWfrsS4X8w=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.56.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bp8NQ4RE6fDIFLa4bdBiOA+TAvkNkg+rslR+AvvjlLTYXLy9/uKAYLQudaQouWihLD/hgkrXIKKzXi5IXOewwg=="],
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.58.0", "", { "os": "none", "cpu": "arm64" }, "sha512-R+O368VXgRql1K6Xar+FEo7NEwfo13EibPMoTv3sesYQedRXd6m30Dh/7lZMxnrQVFfeo4EOfYIP4FpcgWQNHg=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.56.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-PxT4OJDfMOQBzo3OlzFb9gkoSD+n8qSBxyVq2wQSZIHFQYGEqIRTo9M0ZStvZm5fdhMqaVYpOnJvH2hUMEDk/g=="],
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.58.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q0FZiAY/3c4YRj4z3h9K1PgaByrifrfbBoODSeX7gy97UtB7pySPUQfC2B/GbxWU6k7CzQrRy5gME10PltLAFQ=="],
|
||||
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.56.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-PTRy6sIEPqy2x8PTP1baBNReN/BNEFmde0L+mYeHmjXE1Vlcc9+I5nsqENsB2yAm5wLkzPoTNCMY/7AnabT4/A=="],
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.58.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Y8FKBABrSPp9H0QkRLHDHOSUgM/309a3IvOVgPcVxYcX70wxJrk608CuTg7w+C6vEd724X5wJoNkBcGYfH7nNQ=="],
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ=="],
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.58.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bCn5rbiz5My+Bj7M09sDcnqW0QJyINRVxdZ65x1/Y2tGrMwherwK/lpk+HRQCKvXa8pcaQdF5KY5j54VGZLwNg=="],
|
||||
|
||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
|
||||
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@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-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.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-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-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=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||
|
||||
@@ -396,6 +494,8 @@
|
||||
|
||||
"@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-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=="],
|
||||
@@ -404,16 +504,28 @@
|
||||
|
||||
"@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.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-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-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=="],
|
||||
|
||||
"@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-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@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-use-callback-ref": "1.1.1", "@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-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@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-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "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-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||
|
||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||
|
||||
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@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-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
|
||||
|
||||
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@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-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="],
|
||||
|
||||
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.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-dismissable-layer": "1.1.11", "@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", "@radix-ui/react-visually-hidden": "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-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@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-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
@@ -422,48 +534,70 @@
|
||||
|
||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||
|
||||
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@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-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@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-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "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-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="],
|
||||
|
||||
"@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.5", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA=="],
|
||||
|
||||
"@solid-primitives/keyboard": ["@solid-primitives/keyboard@1.3.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-sav+l+PL+74z3yaftVs7qd8c2SXkqzuxPOVibUe5wYMt+U5Hxp3V3XCPgBPN2I6cANjvoFtz0NiU8uHVLdi9FQ=="],
|
||||
@@ -478,6 +612,8 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="],
|
||||
@@ -536,6 +672,8 @@
|
||||
|
||||
"@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="],
|
||||
|
||||
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
|
||||
|
||||
"@tanstack/router-core": ["@tanstack/router-core@1.168.1", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "bin": { "intent": "bin/intent.js" } }, "sha512-RtpshTLZsMOkwW7rI52WFWGZSSfMAyDR1zWP9kVm91UX28gedc+LXih1CTP6TchS+TvxK4q8oW7ApMTvnpiY1w=="],
|
||||
|
||||
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.167.0", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.168.0", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-icgcpE7CQqOzZs4hbFfmICvwk4k7R8ErhUUuUHIKbAYioowQWKm1F3oJhkv6CtoTiofzplUrv9Jy8KE+U/sTKA=="],
|
||||
@@ -558,6 +696,8 @@
|
||||
|
||||
"@tanstack/store": ["@tanstack/store@0.9.2", "", {}, "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA=="],
|
||||
|
||||
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||
|
||||
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="],
|
||||
|
||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||
@@ -584,7 +724,7 @@
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
"@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
@@ -602,21 +742,21 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.0", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.0", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.1.0", "vitest": "4.1.0" }, "optionalPeers": ["@vitest/browser"] }, "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ=="],
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.2", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.2", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.2", "vitest": "4.1.2" }, "optionalPeers": ["@vitest/browser"] }, "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA=="],
|
||||
"@vitest/expect": ["@vitest/expect@4.1.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.0", "", { "dependencies": { "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw=="],
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.2", "", { "dependencies": { "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.0", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A=="],
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.0", "", { "dependencies": { "@vitest/utils": "4.1.0", "pathe": "^2.0.3" } }, "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ=="],
|
||||
"@vitest/runner": ["@vitest/runner@4.1.2", "", { "dependencies": { "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg=="],
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.0", "", {}, "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw=="],
|
||||
"@vitest/spy": ["@vitest/spy@4.1.2", "", {}, "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw=="],
|
||||
"@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
@@ -682,7 +822,9 @@
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"clawhub": ["clawhub@workspace:packages/clawdhub"],
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clawhub": ["clawhub@workspace:packages/clawhub"],
|
||||
|
||||
"clawhub-schema": ["clawhub-schema@workspace:packages/schema"],
|
||||
|
||||
@@ -690,6 +832,8 @@
|
||||
|
||||
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
@@ -700,7 +844,7 @@
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"convex": ["convex@1.34.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-TbC509Z4urZMChZR2aLPgalQ8gMhAYSz2VMxaYsCvba8YqB0Uxma7zWnXwRn7aEGXuA8ro5/uHgD1IJ0HhYYPg=="],
|
||||
"convex": ["convex@1.34.1", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-ooyFnZVVq0u6b5zt0Ptq8QB2ixhf/2vXe+PIcUtdtrs0lq/TwpkmmruHdqkFmWgMd6N+Tmfy8AGkz6QnZUYZBA=="],
|
||||
|
||||
"convex-helpers": ["convex-helpers@0.1.114", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.32.0", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-elEdh+gG6BDv2dWIWVvBeJPbHnDQS5+WexUuwlGVJXz1EbMkXz/UIQwFIfLMZIXUwW6ot4JYf/1JJKNStrE6lg=="],
|
||||
|
||||
@@ -812,6 +956,8 @@
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
||||
|
||||
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
@@ -824,6 +970,8 @@
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
|
||||
"httpxy": ["httpxy@0.3.1", "", {}, "sha512-XjG/CEoofEisMrnFr0D6U6xOZ4mRfnwcYQ9qvvnT4lvnX8BoeA3x3WofB75D+vZwpaobFVkBIHrZzoK40w8XSw=="],
|
||||
@@ -1030,6 +1178,10 @@
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
"nf3": ["nf3@0.3.13", "", {}, "sha512-drDt0yl4d/yUhlpD0GzzqahSpA5eUNeIfFq0/aoZb0UlPY0ZwP4u1EfREVvZrYdEnJ3OU9Le9TrzbvWgEkkeKw=="],
|
||||
|
||||
"nitro": ["nitro@3.0.260311-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.4", "db0": "^0.3.4", "env-runner": "^0.1.6", "h3": "^2.0.1-rc.16", "hookable": "^6.0.1", "nf3": "^0.3.11", "ocache": "^0.1.2", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.0.0-rc.8", "srvx": "^0.11.9", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.6" }, "peerDependencies": { "dotenv": "*", "giget": "*", "jiti": "^2.6.1", "rollup": "^4.59.0", "vite": "^7 || ^8 || >=8.0.0-0", "xml2js": "^0.6.2", "zephyr-agent": "^0.1.15" }, "optionalPeers": ["dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-0o0fJ9LUh4WKUqJNX012jyieUOtMCnadkNDWr0mHzdraoHpJP/1CGNefjRyZyMXSpoJfwoWdNEZu2iGf35TUvQ=="],
|
||||
@@ -1052,15 +1204,19 @@
|
||||
|
||||
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
|
||||
|
||||
"oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="],
|
||||
|
||||
"only-allow": ["only-allow@1.2.2", "", { "dependencies": { "which-pm-runs": "1.1.0" }, "bin": { "only-allow": "bin.js" } }, "sha512-uxyNYDsCh5YIJ780G7hC5OHjVUr9reHsbZNMM80L9tZlTpb3hUzb36KXgW4ZUGtJKQnGA3xegmWg1BxhWV0jJA=="],
|
||||
|
||||
"ora": ["ora@9.3.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.1", "string-width": "^8.1.0" } }, "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw=="],
|
||||
|
||||
"oxfmt": ["oxfmt@0.41.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.41.0", "@oxfmt/binding-android-arm64": "0.41.0", "@oxfmt/binding-darwin-arm64": "0.41.0", "@oxfmt/binding-darwin-x64": "0.41.0", "@oxfmt/binding-freebsd-x64": "0.41.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.41.0", "@oxfmt/binding-linux-arm-musleabihf": "0.41.0", "@oxfmt/binding-linux-arm64-gnu": "0.41.0", "@oxfmt/binding-linux-arm64-musl": "0.41.0", "@oxfmt/binding-linux-ppc64-gnu": "0.41.0", "@oxfmt/binding-linux-riscv64-gnu": "0.41.0", "@oxfmt/binding-linux-riscv64-musl": "0.41.0", "@oxfmt/binding-linux-s390x-gnu": "0.41.0", "@oxfmt/binding-linux-x64-gnu": "0.41.0", "@oxfmt/binding-linux-x64-musl": "0.41.0", "@oxfmt/binding-openharmony-arm64": "0.41.0", "@oxfmt/binding-win32-arm64-msvc": "0.41.0", "@oxfmt/binding-win32-ia32-msvc": "0.41.0", "@oxfmt/binding-win32-x64-msvc": "0.41.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-sKLdJZdQ3bw6x9qKiT7+eID4MNEXlDHf5ZacfIircrq6Qwjk0L6t2/JQlZZrVHTXJawK3KaMuBoJnEJPcqCEdg=="],
|
||||
|
||||
"oxlint": ["oxlint@1.56.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.56.0", "@oxlint/binding-android-arm64": "1.56.0", "@oxlint/binding-darwin-arm64": "1.56.0", "@oxlint/binding-darwin-x64": "1.56.0", "@oxlint/binding-freebsd-x64": "1.56.0", "@oxlint/binding-linux-arm-gnueabihf": "1.56.0", "@oxlint/binding-linux-arm-musleabihf": "1.56.0", "@oxlint/binding-linux-arm64-gnu": "1.56.0", "@oxlint/binding-linux-arm64-musl": "1.56.0", "@oxlint/binding-linux-ppc64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-gnu": "1.56.0", "@oxlint/binding-linux-riscv64-musl": "1.56.0", "@oxlint/binding-linux-s390x-gnu": "1.56.0", "@oxlint/binding-linux-x64-gnu": "1.56.0", "@oxlint/binding-linux-x64-musl": "1.56.0", "@oxlint/binding-openharmony-arm64": "1.56.0", "@oxlint/binding-win32-arm64-msvc": "1.56.0", "@oxlint/binding-win32-ia32-msvc": "1.56.0", "@oxlint/binding-win32-x64-msvc": "1.56.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-Q+5Mj5PVaH/R6/fhMMFzw4dT+KPB+kQW4kaL8FOIq7tfhlnEVp6+3lcWqFruuTNlUo9srZUW3qH7Id4pskeR6g=="],
|
||||
"oxlint": ["oxlint@1.58.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.58.0", "@oxlint/binding-android-arm64": "1.58.0", "@oxlint/binding-darwin-arm64": "1.58.0", "@oxlint/binding-darwin-x64": "1.58.0", "@oxlint/binding-freebsd-x64": "1.58.0", "@oxlint/binding-linux-arm-gnueabihf": "1.58.0", "@oxlint/binding-linux-arm-musleabihf": "1.58.0", "@oxlint/binding-linux-arm64-gnu": "1.58.0", "@oxlint/binding-linux-arm64-musl": "1.58.0", "@oxlint/binding-linux-ppc64-gnu": "1.58.0", "@oxlint/binding-linux-riscv64-gnu": "1.58.0", "@oxlint/binding-linux-riscv64-musl": "1.58.0", "@oxlint/binding-linux-s390x-gnu": "1.58.0", "@oxlint/binding-linux-x64-gnu": "1.58.0", "@oxlint/binding-linux-x64-musl": "1.58.0", "@oxlint/binding-openharmony-arm64": "1.58.0", "@oxlint/binding-win32-arm64-msvc": "1.58.0", "@oxlint/binding-win32-ia32-msvc": "1.58.0", "@oxlint/binding-win32-x64-msvc": "1.58.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-t4s9leczDMqlvOSjnbCQe7gtoLkWgBGZ7sBdCJ9EOj5IXFSG/X7OAzK4yuH4iW+4cAYe8kLFbC8tuYMwWZm+Cg=="],
|
||||
|
||||
"oxlint-tsgolint": ["oxlint-tsgolint@0.17.1", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.17.1", "@oxlint-tsgolint/darwin-x64": "0.17.1", "@oxlint-tsgolint/linux-arm64": "0.17.1", "@oxlint-tsgolint/linux-x64": "0.17.1", "@oxlint-tsgolint/win32-arm64": "0.17.1", "@oxlint-tsgolint/win32-x64": "0.17.1" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-gJc7hb1ZQFbWjRDYpu1XG+5IRdr1S/Jz/W2ohcpaqIXuDmHU0ujGiM0x05J0nIfwMF3HOEcANi/+j6T0Uecdpg=="],
|
||||
"oxlint-tsgolint": ["oxlint-tsgolint@0.17.4", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.17.4", "@oxlint-tsgolint/darwin-x64": "0.17.4", "@oxlint-tsgolint/linux-arm64": "0.17.4", "@oxlint-tsgolint/linux-x64": "0.17.4", "@oxlint-tsgolint/win32-arm64": "0.17.4", "@oxlint-tsgolint/win32-x64": "0.17.4" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-4F/NXJiK2KnK4LQiULUPXRzVq0LOfextGvwCVRW1VKQbF5epI3MDMEGVAl5XjAGL6IFc7xBc/eVA95wczPeEQg=="],
|
||||
|
||||
"p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="],
|
||||
|
||||
@@ -1080,9 +1236,9 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
||||
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
@@ -1116,6 +1272,12 @@
|
||||
|
||||
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
|
||||
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
|
||||
|
||||
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
|
||||
|
||||
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
@@ -1130,7 +1292,7 @@
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
|
||||
"rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
|
||||
|
||||
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
|
||||
|
||||
@@ -1148,8 +1310,12 @@
|
||||
|
||||
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
@@ -1158,6 +1324,8 @@
|
||||
|
||||
"solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
|
||||
|
||||
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||
|
||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
@@ -1184,6 +1352,8 @@
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
@@ -1228,11 +1398,13 @@
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
|
||||
|
||||
"undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
|
||||
"undici": ["undici@7.24.7", "", {}, "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
@@ -1266,13 +1438,13 @@
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="],
|
||||
"vite": ["vite@8.0.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ=="],
|
||||
|
||||
"vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="],
|
||||
|
||||
"vitest": ["vitest@4.1.0", "", { "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", "@vitest/pretty-format": "4.1.0", "@vitest/runner": "4.1.0", "@vitest/snapshot": "4.1.0", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.0", "@vitest/browser-preview": "4.1.0", "@vitest/browser-webdriverio": "4.1.0", "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw=="],
|
||||
"vitest": ["vitest@4.1.2", "", { "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", "@vitest/pretty-format": "4.1.2", "@vitest/runner": "4.1.2", "@vitest/snapshot": "4.1.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.2", "@vitest/browser-preview": "4.1.2", "@vitest/browser-webdriverio": "4.1.2", "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
@@ -1304,7 +1476,7 @@
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
@@ -1314,6 +1486,76 @@
|
||||
|
||||
"@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-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-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@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-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-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-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-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||
@@ -1328,22 +1570,38 @@
|
||||
|
||||
"@tanstack/devtools-event-bus/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@tanstack/start-plugin-core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
"@tanstack/start-plugin-core/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.40", "", {}, "sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w=="],
|
||||
|
||||
"@tanstack/start-plugin-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
|
||||
|
||||
"cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"cheerio/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
|
||||
|
||||
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"jsdom/undici": ["undici@7.24.5", "", {}, "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q=="],
|
||||
|
||||
<<<<<<< staging
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
=======
|
||||
"nitro/rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
|
||||
>>>>>>> main
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
@@ -1356,8 +1614,110 @@
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
|
||||
|
||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
<<<<<<< staging
|
||||
"@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=="],
|
||||
=======
|
||||
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"vitest/vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="],
|
||||
|
||||
"nitro/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
|
||||
|
||||
"nitro/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
|
||||
|
||||
"vitest/vite/rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
|
||||
|
||||
"vitest/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
|
||||
|
||||
"vitest/vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
|
||||
>>>>>>> main
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { existsSync } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const distCliUrl = new URL('./packages/clawdhub/dist/cli.js', import.meta.url)
|
||||
const packageRootPath = fileURLToPath(new URL('./packages/clawhub/', import.meta.url))
|
||||
const distCliUrl = new URL('./packages/clawhub/dist/cli.js', import.meta.url)
|
||||
const distCliPath = fileURLToPath(distCliUrl)
|
||||
const srcRootPath = fileURLToPath(new URL('./packages/clawdhub/src/', import.meta.url))
|
||||
const srcRootPath = fileURLToPath(new URL('./packages/clawhub/src/', import.meta.url))
|
||||
|
||||
const shouldBuild = await (async () => {
|
||||
if (!existsSync(distCliPath)) return true
|
||||
@@ -19,7 +20,8 @@ const shouldBuild = await (async () => {
|
||||
})()
|
||||
|
||||
if (shouldBuild) {
|
||||
const proc = Bun.spawn(['bunx', 'tsc', '-p', 'packages/clawdhub/tsconfig.json'], {
|
||||
const proc = Bun.spawn(['bun', 'run', 'build'], {
|
||||
cwd: packageRootPath,
|
||||
stdin: 'inherit',
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
@@ -34,6 +36,7 @@ async function getLatestMtime(root: string) {
|
||||
let latest = 0
|
||||
const glob = new Bun.Glob('**/*.ts')
|
||||
for await (const rel of glob.scan({ cwd: root, onlyFiles: true })) {
|
||||
if (rel.endsWith('.test.ts')) continue
|
||||
const path = `${root}${root.endsWith('/') ? '' : '/'}${rel}`
|
||||
try {
|
||||
const entry = await stat(path)
|
||||
|
||||
@@ -3,9 +3,10 @@ import { existsSync } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const distCliUrl = new URL('./packages/clawdhub/dist/cli.js', import.meta.url)
|
||||
const packageRootPath = fileURLToPath(new URL('./packages/clawhub/', import.meta.url))
|
||||
const distCliUrl = new URL('./packages/clawhub/dist/cli.js', import.meta.url)
|
||||
const distCliPath = fileURLToPath(distCliUrl)
|
||||
const srcRootPath = fileURLToPath(new URL('./packages/clawdhub/src/', import.meta.url))
|
||||
const srcRootPath = fileURLToPath(new URL('./packages/clawhub/src/', import.meta.url))
|
||||
|
||||
const shouldBuild = await (async () => {
|
||||
if (!existsSync(distCliPath)) return true
|
||||
@@ -19,7 +20,8 @@ const shouldBuild = await (async () => {
|
||||
})()
|
||||
|
||||
if (shouldBuild) {
|
||||
const proc = Bun.spawn(['bunx', 'tsc', '-p', 'packages/clawdhub/tsconfig.json'], {
|
||||
const proc = Bun.spawn(['bun', 'run', 'build'], {
|
||||
cwd: packageRootPath,
|
||||
stdin: 'inherit',
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
@@ -34,6 +36,7 @@ async function getLatestMtime(root: string) {
|
||||
let latest = 0
|
||||
const glob = new Bun.Glob('**/*.ts')
|
||||
for await (const rel of glob.scan({ cwd: root, onlyFiles: true })) {
|
||||
if (rel.endsWith('.test.ts')) continue
|
||||
const path = `${root}${root.endsWith('/') ? '' : '/'}${rel}`
|
||||
try {
|
||||
const entry = await stat(path)
|
||||
|
||||
Vendored
+8
@@ -48,6 +48,7 @@ import type * as lib_contentTypes from "../lib/contentTypes.js";
|
||||
import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js";
|
||||
import type * as lib_embeddings from "../lib/embeddings.js";
|
||||
import type * as lib_githubAccount from "../lib/githubAccount.js";
|
||||
import type * as lib_githubActionsOidc from "../lib/githubActionsOidc.js";
|
||||
import type * as lib_githubBackup from "../lib/githubBackup.js";
|
||||
import type * as lib_githubIdentity from "../lib/githubIdentity.js";
|
||||
import type * as lib_githubImport from "../lib/githubImport.js";
|
||||
@@ -76,6 +77,7 @@ import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
|
||||
import type * as lib_searchText from "../lib/searchText.js";
|
||||
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
|
||||
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
|
||||
import type * as lib_skillCapabilityTags from "../lib/skillCapabilityTags.js";
|
||||
import type * as lib_skillPublish from "../lib/skillPublish.js";
|
||||
import type * as lib_skillQuality from "../lib/skillQuality.js";
|
||||
import type * as lib_skillSafety from "../lib/skillSafety.js";
|
||||
@@ -92,11 +94,13 @@ import type * as lib_userSearch from "../lib/userSearch.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 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 search from "../search.js";
|
||||
import type * as seed from "../seed.js";
|
||||
import type * as seedDemo from "../seedDemo.js";
|
||||
import type * as seedSouls from "../seedSouls.js";
|
||||
import type * as skillStatEvents from "../skillStatEvents.js";
|
||||
import type * as skillTransfers from "../skillTransfers.js";
|
||||
@@ -161,6 +165,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
|
||||
"lib/embeddings": typeof lib_embeddings;
|
||||
"lib/githubAccount": typeof lib_githubAccount;
|
||||
"lib/githubActionsOidc": typeof lib_githubActionsOidc;
|
||||
"lib/githubBackup": typeof lib_githubBackup;
|
||||
"lib/githubIdentity": typeof lib_githubIdentity;
|
||||
"lib/githubImport": typeof lib_githubImport;
|
||||
@@ -189,6 +194,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/searchText": typeof lib_searchText;
|
||||
"lib/securityPrompt": typeof lib_securityPrompt;
|
||||
"lib/skillBackfill": typeof lib_skillBackfill;
|
||||
"lib/skillCapabilityTags": typeof lib_skillCapabilityTags;
|
||||
"lib/skillPublish": typeof lib_skillPublish;
|
||||
"lib/skillQuality": typeof lib_skillQuality;
|
||||
"lib/skillSafety": typeof lib_skillSafety;
|
||||
@@ -205,11 +211,13 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/webhooks": typeof lib_webhooks;
|
||||
llmEval: typeof llmEval;
|
||||
maintenance: typeof maintenance;
|
||||
packagePublishTokens: typeof packagePublishTokens;
|
||||
packages: typeof packages;
|
||||
publishers: typeof publishers;
|
||||
rateLimits: typeof rateLimits;
|
||||
search: typeof search;
|
||||
seed: typeof seed;
|
||||
seedDemo: typeof seedDemo;
|
||||
seedSouls: typeof seedSouls;
|
||||
skillStatEvents: typeof skillStatEvents;
|
||||
skillTransfers: typeof skillTransfers;
|
||||
|
||||
+18
-2
@@ -10,7 +10,12 @@ function makeCtx({
|
||||
user,
|
||||
banRecords,
|
||||
}: {
|
||||
user: { deletedAt?: number; deactivatedAt?: number; purgedAt?: number } | null;
|
||||
user: {
|
||||
deletedAt?: number;
|
||||
deactivatedAt?: number;
|
||||
purgedAt?: number;
|
||||
banReason?: string;
|
||||
} | null;
|
||||
banRecords?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
const query = {
|
||||
@@ -112,7 +117,7 @@ describe("handleDeletedUserSignIn", () => {
|
||||
|
||||
it("blocks users auto-banned for malware", async () => {
|
||||
const { ctx } = makeCtx({
|
||||
user: { deletedAt: 123 },
|
||||
user: { deletedAt: 123, banReason: "malware auto-ban" },
|
||||
banRecords: [{ action: "user.autoban.malware" }],
|
||||
});
|
||||
|
||||
@@ -122,4 +127,15 @@ describe("handleDeletedUserSignIn", () => {
|
||||
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes the moderator ban reason in the sign-in error", async () => {
|
||||
const { ctx } = makeCtx({
|
||||
user: { deletedAt: 123, banReason: "Chargeback fraud" },
|
||||
banRecords: [{ action: "user.ban" }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
||||
).rejects.toThrow(`${BANNED_REAUTH_MESSAGE} Reason: Chargeback fraud`);
|
||||
});
|
||||
});
|
||||
|
||||
+16
-3
@@ -7,16 +7,29 @@ import type { DataModel, Id } from "./_generated/dataModel";
|
||||
import { shouldScheduleGitHubProfileSync } from "./lib/githubProfileSync";
|
||||
|
||||
export const BANNED_REAUTH_MESSAGE =
|
||||
"Your account has been banned for uploading malicious skills. If you believe this is a mistake, please contact security@openclaw.ai and we will work with you to restore access.";
|
||||
"This account has been banned and cannot sign in. If you believe this is a mistake, please contact security@openclaw.ai and we will review it.";
|
||||
export const DELETED_ACCOUNT_REAUTH_MESSAGE =
|
||||
"This account has been permanently deleted and cannot be restored.";
|
||||
|
||||
const REAUTH_BLOCKING_BAN_ACTIONS = new Set(["user.ban", "user.autoban.malware"]);
|
||||
|
||||
function getBannedReauthMessage(reason: string | undefined) {
|
||||
const normalizedReason = reason?.trim();
|
||||
if (!normalizedReason || normalizedReason.toLowerCase() === "malware auto-ban") {
|
||||
return BANNED_REAUTH_MESSAGE;
|
||||
}
|
||||
return `${BANNED_REAUTH_MESSAGE} Reason: ${normalizedReason}`;
|
||||
}
|
||||
|
||||
export async function handleDeletedUserSignIn(
|
||||
ctx: GenericMutationCtx<DataModel>,
|
||||
args: { userId: Id<"users">; existingUserId: Id<"users"> | null },
|
||||
userOverride?: { deletedAt?: number; deactivatedAt?: number; purgedAt?: number } | null,
|
||||
userOverride?: {
|
||||
deletedAt?: number;
|
||||
deactivatedAt?: number;
|
||||
purgedAt?: number;
|
||||
banReason?: string;
|
||||
} | null,
|
||||
) {
|
||||
const user = userOverride !== undefined ? userOverride : await ctx.db.get(args.userId);
|
||||
if (!user?.deletedAt && !user?.deactivatedAt) return;
|
||||
@@ -42,7 +55,7 @@ export async function handleDeletedUserSignIn(
|
||||
);
|
||||
|
||||
if (hasBlockingBan) {
|
||||
throw new ConvexError(BANNED_REAUTH_MESSAGE);
|
||||
throw new ConvexError(getBannedReauthMessage(user.banReason));
|
||||
}
|
||||
|
||||
// Migrate legacy self-deleted accounts (stored in deletedAt) to the new
|
||||
|
||||
@@ -237,6 +237,74 @@ xuezh snapshot --profile default
|
||||
xuezh review next --limit 10
|
||||
xuezh audio process-voice --file ./utterance.wav
|
||||
\`\`\`
|
||||
`,
|
||||
},
|
||||
{
|
||||
slug: "hanzi-helper",
|
||||
displayName: "汉字助手",
|
||||
summary: "汉字学习与分析工具,支持笔画查询、部首检索和组词生成。",
|
||||
version: "0.1.0",
|
||||
metadata: {
|
||||
clawdbot: {
|
||||
nix: {
|
||||
plugin: "github:example/hanzi-helper",
|
||||
systems: ["aarch64-darwin", "x86_64-linux"],
|
||||
},
|
||||
config: {
|
||||
requiredEnv: ["HANZI_DB_PATH"],
|
||||
stateDirs: [".config/hanzi"],
|
||||
example:
|
||||
'config = { env = { HANZI_DB_PATH = ".config/hanzi/db"; }; stateDirs = [ ".config/hanzi" ]; };',
|
||||
},
|
||||
cliHelp: `汉字助手 - Chinese character learning and analysis
|
||||
|
||||
Usage:
|
||||
hanzi-helper [command]
|
||||
|
||||
Available Commands:
|
||||
lookup 查询汉字信息(笔画、部首、释义)
|
||||
radical 按部首检索汉字
|
||||
stroke 按笔画数筛选汉字
|
||||
words 生成汉字组词
|
||||
practice 练习汉字书写
|
||||
quiz 汉字听写测试
|
||||
|
||||
Flags:
|
||||
-h, --help help for hanzi-helper
|
||||
--json Output JSON
|
||||
`,
|
||||
},
|
||||
},
|
||||
rawSkillMd: `---
|
||||
name: hanzi-helper
|
||||
description: 汉字学习与分析工具,提供笔画查询、部首检索、组词生成和汉字听写练习功能。
|
||||
---
|
||||
|
||||
# 汉字助手
|
||||
|
||||
## 功能介绍
|
||||
|
||||
汉字助手是一个强大的中文汉字学习工具,帮助用户深入了解每个汉字的结构和含义。
|
||||
|
||||
## CLI
|
||||
|
||||
\`\`\`bash
|
||||
hanzi-helper lookup --char 学
|
||||
hanzi-helper radical --name 木
|
||||
hanzi-helper stroke --count 8
|
||||
hanzi-helper words --char 大 --limit 20
|
||||
\`\`\`
|
||||
|
||||
## 使用场景
|
||||
|
||||
- **汉字查询**:输入任意汉字,查看笔画数、部首、繁体形式和基本释义
|
||||
- **部首检索**:按部首浏览相关汉字,了解汉字的分类规律
|
||||
- **组词生成**:输入一个汉字,自动生成常用词语和成语
|
||||
- **听写练习**:随机生成汉字听写测试,巩固学习效果
|
||||
|
||||
## 学习建议
|
||||
|
||||
建议每天学习五个新汉字,结合组词和例句加深记忆。坚持使用听写练习功能可以有效提高汉字识别能力。
|
||||
`,
|
||||
},
|
||||
];
|
||||
@@ -401,6 +469,11 @@ export const seedSkillMutation = internalMutation({
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.patch(userId, {
|
||||
publishedSkills: 1,
|
||||
totalStars: 0,
|
||||
totalDownloads: 0,
|
||||
});
|
||||
|
||||
const versionId = await ctx.db.insert("skillVersions", {
|
||||
skillId,
|
||||
|
||||
@@ -448,6 +448,22 @@ const EXTRA_SEED_SKILLS: SeedSkillSpec[] = [
|
||||
["SSH_KEY_DIR"],
|
||||
["generate", "rotate", "deploy", "list", "revoke"],
|
||||
),
|
||||
|
||||
// CJK Language Support (2)
|
||||
makeSkill(
|
||||
"nihongo-check",
|
||||
"日本語チェッカー",
|
||||
"日本語文章の文法チェックと翻訳支援ツール。Japanese grammar checker and translation assistant.",
|
||||
["NIHONGO_API_KEY"],
|
||||
["check", "translate", "kanji", "grammar", "vocabulary"],
|
||||
),
|
||||
makeSkill(
|
||||
"hangukgeo-helper",
|
||||
"한국어 도우미",
|
||||
"한국어 학습 보조 도구입니다. Korean language learning assistant with vocabulary and grammar support.",
|
||||
["HANGUL_API_KEY"],
|
||||
["learn", "quiz", "vocabulary", "grammar", "pronunciation"],
|
||||
),
|
||||
];
|
||||
|
||||
function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
|
||||
|
||||
@@ -62,10 +62,7 @@ describe("package digest sync", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await syncPackageSearchDigestForPackageId(
|
||||
ctx as never,
|
||||
"packages:demo" as never,
|
||||
);
|
||||
await syncPackageSearchDigestForPackageId(ctx as never, "packages:demo" as never);
|
||||
|
||||
expect(ctx.db.insert).toHaveBeenCalledWith(
|
||||
"packageSearchDigest",
|
||||
@@ -128,10 +125,7 @@ describe("package digest sync", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await syncPackageSearchDigestForPackageId(
|
||||
ctx as never,
|
||||
"packages:demo" as never,
|
||||
);
|
||||
await syncPackageSearchDigestForPackageId(ctx as never, "packages:demo" as never);
|
||||
|
||||
expect(ctx.db.insert).toHaveBeenCalledWith(
|
||||
"packageSearchDigest",
|
||||
@@ -443,13 +437,11 @@ describe("package digest sync", () => {
|
||||
updatedAt: 2,
|
||||
verification: undefined,
|
||||
};
|
||||
const paginate = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
page: [pkg],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
});
|
||||
const paginate = vi.fn().mockResolvedValueOnce({
|
||||
page: [pkg],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
@@ -488,10 +480,7 @@ describe("package digest sync", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await syncPackageSearchDigestsForOwnerUserId(
|
||||
ctx as never,
|
||||
"users:owner" as never,
|
||||
);
|
||||
await syncPackageSearchDigestsForOwnerUserId(ctx as never, "users:owner" as never);
|
||||
|
||||
expect(paginate).toHaveBeenCalledWith({ cursor: null, numItems: 100 });
|
||||
expect(ctx.db.insert).toHaveBeenCalledWith(
|
||||
|
||||
+7
-4
@@ -128,7 +128,8 @@ async function syncPackageSearchDigest(
|
||||
});
|
||||
await upsertPackageSearchDigest(ctx, {
|
||||
...fields,
|
||||
latestVersion: latestRelease && !latestRelease.softDeletedAt ? latestRelease.version : undefined,
|
||||
latestVersion:
|
||||
latestRelease && !latestRelease.softDeletedAt ? latestRelease.version : undefined,
|
||||
ownerHandle: owner?.handle ?? "",
|
||||
ownerKind: owner?.kind,
|
||||
});
|
||||
@@ -344,13 +345,15 @@ triggers.register("packages", async (ctx, change) => {
|
||||
|
||||
triggers.register("packageReleases", async (ctx, change) => {
|
||||
if (change.operation === "insert") return;
|
||||
if (change.operation === "update" && change.oldDoc.softDeletedAt === change.newDoc.softDeletedAt) {
|
||||
if (
|
||||
change.operation === "update" &&
|
||||
change.oldDoc.softDeletedAt === change.newDoc.softDeletedAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const packageId =
|
||||
change.operation === "delete" ? change.oldDoc.packageId : change.newDoc.packageId;
|
||||
const affectedReleaseId =
|
||||
change.operation === "delete" ? change.oldDoc._id : change.newDoc._id;
|
||||
const affectedReleaseId = change.operation === "delete" ? change.oldDoc._id : change.newDoc._id;
|
||||
if (change.operation === "delete" || change.newDoc.softDeletedAt) {
|
||||
await repointPackageLatestRelease(ctx, packageId, affectedReleaseId);
|
||||
return;
|
||||
|
||||
+22
-1
@@ -20,10 +20,13 @@ import {
|
||||
listPluginsV1Http,
|
||||
listSkillsV1Http,
|
||||
listSoulsV1Http,
|
||||
mintPublishTokenV1Http,
|
||||
packagesDeleteRouterV1Http,
|
||||
packagesGetRouterV1Http,
|
||||
packagesPostRouterV1Http,
|
||||
pluginsGetRouterV1Http,
|
||||
publishSkillV1Http,
|
||||
publishPackageV1Http,
|
||||
publishSkillV1Http,
|
||||
publishSoulV1Http,
|
||||
resolveSkillVersionV1Http,
|
||||
searchSkillsV1Http,
|
||||
@@ -124,6 +127,24 @@ http.route({
|
||||
handler: publishPackageV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.publishTokenMint,
|
||||
method: "POST",
|
||||
handler: mintPublishTokenV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.packages}/`,
|
||||
method: "POST",
|
||||
handler: packagesPostRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.packages}/`,
|
||||
method: "DELETE",
|
||||
handler: packagesDeleteRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.skills}/`,
|
||||
method: "POST",
|
||||
|
||||
@@ -11,6 +11,12 @@ vi.mock("@convex-dev/auth/server", () => ({
|
||||
vi.mock("./lib/apiTokenAuth", () => ({
|
||||
requireApiTokenUser: vi.fn(),
|
||||
getOptionalApiTokenUserId: vi.fn(),
|
||||
requirePackagePublishAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./lib/githubActionsOidc", () => ({
|
||||
fetchGitHubRepositoryIdentity: vi.fn(),
|
||||
verifyGitHubActionsTrustedPublishJwt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./skills", () => ({
|
||||
@@ -18,7 +24,10 @@ vi.mock("./skills", () => ({
|
||||
}));
|
||||
|
||||
const { getAuthUserId } = await import("@convex-dev/auth/server");
|
||||
const { getOptionalApiTokenUserId, requireApiTokenUser } = await import("./lib/apiTokenAuth");
|
||||
const { getOptionalApiTokenUserId, requireApiTokenUser, requirePackagePublishAuth } =
|
||||
await import("./lib/apiTokenAuth");
|
||||
const { fetchGitHubRepositoryIdentity, verifyGitHubActionsTrustedPublishJwt } =
|
||||
await import("./lib/githubActionsOidc");
|
||||
const { publishVersionForUser } = await import("./skills");
|
||||
const { __handlers } = await import("./httpApiV1");
|
||||
|
||||
@@ -83,6 +92,9 @@ beforeEach(() => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockReset();
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue(null);
|
||||
vi.mocked(requireApiTokenUser).mockReset();
|
||||
vi.mocked(requirePackagePublishAuth).mockReset();
|
||||
vi.mocked(fetchGitHubRepositoryIdentity).mockReset();
|
||||
vi.mocked(verifyGitHubActionsTrustedPublishJwt).mockReset();
|
||||
vi.mocked(publishVersionForUser).mockReset();
|
||||
});
|
||||
|
||||
@@ -1250,6 +1262,7 @@ describe("httpApiV1 handlers", () => {
|
||||
changelog: "c",
|
||||
changelogSource: "auto",
|
||||
sha256hash: "b".repeat(64),
|
||||
capabilityTags: ["crypto", "requires-wallet", "can-make-purchases"],
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
checkedAt: 111,
|
||||
@@ -1284,6 +1297,11 @@ describe("httpApiV1 handlers", () => {
|
||||
const json = await response.json();
|
||||
expect(json.security.status).toBe("suspicious");
|
||||
expect(json.security.hasScanResult).toBe(true);
|
||||
expect(json.security.capabilityTags).toEqual([
|
||||
"crypto",
|
||||
"requires-wallet",
|
||||
"can-make-purchases",
|
||||
]);
|
||||
expect(json.security.scanners.llm.verdict).toBe("suspicious");
|
||||
expect(json.moderation.scope).toBe("skill");
|
||||
expect(json.moderation.sourceVersion).toEqual({
|
||||
@@ -1345,6 +1363,51 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(json.security.scanners.llm.normalizedStatus).toBe("error");
|
||||
});
|
||||
|
||||
it("returns capability tags even when no scanner result exists yet", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("slug" in args) {
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
summary: "s",
|
||||
tags: { latest: "versions:1" },
|
||||
stats: {},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "c",
|
||||
changelogSource: "auto",
|
||||
capabilityTags: ["posts-externally", "requires-oauth-token"],
|
||||
files: [],
|
||||
},
|
||||
owner: { _id: "users:1", handle: "owner", displayName: "Owner" },
|
||||
moderationInfo: {
|
||||
isPendingScan: true,
|
||||
isMalwareBlocked: false,
|
||||
isSuspicious: false,
|
||||
isHiddenByMod: false,
|
||||
isRemoved: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const response = await __handlers.skillsGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/skills/demo/scan"),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.security.capabilityTags).toEqual(["posts-externally", "requires-oauth-token"]);
|
||||
expect(json.security.hasScanResult).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps hasScanResult true when one scanner returns a definitive verdict", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("slug" in args) {
|
||||
@@ -2405,6 +2468,37 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("packages search falls back to anonymous when cookie auth resolves to an invalid user", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:broken" as never);
|
||||
const runQuery = vi.fn(async (query: unknown, args: Record<string, unknown>) => {
|
||||
if (query === internal.users.getByIdInternal) {
|
||||
throw new Error("Table mismatch");
|
||||
}
|
||||
if ("query" in args && args.query === "secret") return [];
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/search?q=secret&channel=community"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runQuery).toHaveBeenCalledWith(
|
||||
internal.users.getByIdInternal,
|
||||
expect.objectContaining({ userId: "users:broken" }),
|
||||
);
|
||||
expect(runQuery).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
query: "secret",
|
||||
channel: "community",
|
||||
viewerUserId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("packages detail falls back to public skills", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) return null;
|
||||
@@ -2458,6 +2552,51 @@ describe("httpApiV1 handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("packages detail returns stats for plugins", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:demo-plugin",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
summary: "Plugin summary",
|
||||
latestVersion: "1.2.3",
|
||||
stats: { downloads: 7, installs: 3, stars: 2, versions: 4 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "users:owner", handle: "owner", displayName: "Owner" },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin"),
|
||||
);
|
||||
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
latestVersion: "1.2.3",
|
||||
stats: { downloads: 7, installs: 3, stars: 2, versions: 4 },
|
||||
},
|
||||
owner: {
|
||||
handle: "owner",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("packages file serves SKILL.md for skill README requests", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) return null;
|
||||
@@ -2994,7 +3133,10 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
|
||||
const zipEntries = unzipSync(new Uint8Array(await response.arrayBuffer()));
|
||||
expect(Object.keys(zipEntries).sort()).toEqual(["package/dist/index.js", "package/package.json"]);
|
||||
expect(Object.keys(zipEntries).sort()).toEqual([
|
||||
"package/dist/index.js",
|
||||
"package/package.json",
|
||||
]);
|
||||
expect(zipEntries["_meta.json"]).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -3269,7 +3411,9 @@ describe("httpApiV1 handlers", () => {
|
||||
|
||||
const fileResponse = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation, storage: { get: vi.fn() } }),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/file?version=1.0.0&path=README.md"),
|
||||
new Request(
|
||||
"https://example.com/api/v1/packages/demo-plugin/file?version=1.0.0&path=README.md",
|
||||
),
|
||||
);
|
||||
const downloadResponse = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation, storage: { get: vi.fn() } }),
|
||||
@@ -3284,12 +3428,15 @@ describe("httpApiV1 handlers", () => {
|
||||
|
||||
it("package publish uses write rate limiting", async () => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never);
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
vi.mocked(requirePackagePublishAuth).mockResolvedValue({
|
||||
kind: "user",
|
||||
userId: "users:1",
|
||||
user: { _id: "users:1", handle: "p" },
|
||||
} as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runAction = vi.fn().mockResolvedValue({ ok: true, packageId: "pkg:1", releaseId: "rel:1" });
|
||||
const runAction = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, packageId: "pkg:1", releaseId: "rel:1" });
|
||||
|
||||
const response = await __handlers.publishPackageV1Handler(
|
||||
makeCtx({ runAction, runMutation }),
|
||||
@@ -3335,12 +3482,15 @@ describe("httpApiV1 handlers", () => {
|
||||
|
||||
it("multipart package publish ignores macOS junk files", async () => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never);
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
vi.mocked(requirePackagePublishAuth).mockResolvedValue({
|
||||
kind: "user",
|
||||
userId: "users:1",
|
||||
user: { _id: "users:1", handle: "p" },
|
||||
} as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runAction = vi.fn().mockResolvedValue({ ok: true, packageId: "pkg:1", releaseId: "rel:1" });
|
||||
const runAction = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, packageId: "pkg:1", releaseId: "rel:1" });
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
"payload",
|
||||
@@ -3353,10 +3503,7 @@ describe("httpApiV1 handlers", () => {
|
||||
}),
|
||||
);
|
||||
form.append("files", new File(["{}"], ".DS_Store", { type: "application/octet-stream" }));
|
||||
form.append(
|
||||
"files",
|
||||
new File(["{}"], "openclaw.bundle.json", { type: "application/json" }),
|
||||
);
|
||||
form.append("files", new File(["{}"], "openclaw.bundle.json", { type: "application/json" }));
|
||||
|
||||
const response = await __handlers.publishPackageV1Handler(
|
||||
makeCtx({
|
||||
@@ -3388,6 +3535,427 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("package publish routes GitHub Actions auth through the trusted publisher action", async () => {
|
||||
vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never);
|
||||
vi.mocked(requirePackagePublishAuth).mockResolvedValue({
|
||||
kind: "github-actions",
|
||||
publishToken: { _id: "packagePublishTokens:1" },
|
||||
} as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runAction = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, packageId: "pkg:1", releaseId: "rel:1" });
|
||||
|
||||
const response = await __handlers.publishPackageV1Handler(
|
||||
makeCtx({ runAction, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer clh_publish",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "demo-plugin",
|
||||
family: "bundle-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "init",
|
||||
bundle: { hostTargets: ["desktop"] },
|
||||
files: [
|
||||
{
|
||||
path: "openclaw.bundle.json",
|
||||
size: 2,
|
||||
storageId: "storage:1",
|
||||
sha256: "a".repeat(64),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runAction).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
publishTokenId: "packagePublishTokens:1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns trusted publisher config for a package", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:1",
|
||||
name: "@openclaw/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: null,
|
||||
};
|
||||
}
|
||||
if ("packageId" in args) {
|
||||
return {
|
||||
_id: "packageTrustedPublishers:1",
|
||||
packageId: "packages:1",
|
||||
provider: "github-actions",
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-release",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request(
|
||||
"https://example.com/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher",
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
trustedPublisher: {
|
||||
provider: "github-actions",
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-release",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("mints a short-lived publish token after verifying GitHub OIDC", async () => {
|
||||
vi.mocked(verifyGitHubActionsTrustedPublishJwt).mockResolvedValue({
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-release",
|
||||
runId: "101",
|
||||
runAttempt: "1",
|
||||
sha: "abc123",
|
||||
ref: "refs/heads/main",
|
||||
refType: "branch",
|
||||
actor: "onur",
|
||||
actorId: "42",
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if ("key" in args) return okRate();
|
||||
return "mutation:ok";
|
||||
});
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
_id: "packages:1",
|
||||
name: "@openclaw/demo-plugin",
|
||||
ownerUserId: "users:owner",
|
||||
};
|
||||
}
|
||||
if ("packageId" in args) {
|
||||
return {
|
||||
_id: "packageTrustedPublishers:1",
|
||||
packageId: "packages:1",
|
||||
provider: "github-actions",
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-release",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.mintPublishTokenV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/publish/token/mint", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
packageName: "@openclaw/demo-plugin",
|
||||
version: "1.0.0",
|
||||
githubOidcToken: "gh.jwt",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
const body = await response.json();
|
||||
expect(body.token).toEqual(expect.any(String));
|
||||
expect(body.expiresAt).toEqual(expect.any(Number));
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
packageId: "packages:1",
|
||||
version: "1.0.0",
|
||||
repository: "openclaw/openclaw",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-release",
|
||||
runId: "101",
|
||||
sha: "abc123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("mints a short-lived publish token without environment when none is pinned", async () => {
|
||||
vi.mocked(verifyGitHubActionsTrustedPublishJwt).mockResolvedValue({
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-release",
|
||||
runId: "101",
|
||||
runAttempt: "1",
|
||||
sha: "abc123",
|
||||
ref: "refs/heads/main",
|
||||
refType: "branch",
|
||||
actor: "onur",
|
||||
actorId: "42",
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if ("key" in args) return okRate();
|
||||
return "mutation:ok";
|
||||
});
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
return {
|
||||
_id: "packages:1",
|
||||
name: "@openclaw/demo-plugin",
|
||||
ownerUserId: "users:owner",
|
||||
};
|
||||
}
|
||||
if ("packageId" in args) {
|
||||
return {
|
||||
_id: "packageTrustedPublishers:1",
|
||||
packageId: "packages:1",
|
||||
provider: "github-actions",
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.mintPublishTokenV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/publish/token/mint", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
packageName: "@openclaw/demo-plugin",
|
||||
version: "1.0.0",
|
||||
githubOidcToken: "gh.jwt",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
const body = await response.json();
|
||||
expect(body.token).toEqual(expect.any(String));
|
||||
expect(body.expiresAt).toEqual(expect.any(Number));
|
||||
const createCall = runMutation.mock.calls.find(
|
||||
([, args]) =>
|
||||
typeof args === "object" && args !== null && "packageId" in args && "tokenHash" in args,
|
||||
);
|
||||
expect(createCall?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
packageId: "packages:1",
|
||||
version: "1.0.0",
|
||||
repository: "openclaw/openclaw",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
runId: "101",
|
||||
sha: "abc123",
|
||||
}),
|
||||
);
|
||||
expect(createCall?.[1]).not.toHaveProperty("environment");
|
||||
});
|
||||
|
||||
it("sets trusted publisher config for a package", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { _id: "users:1", handle: "p" },
|
||||
} as never);
|
||||
vi.mocked(fetchGitHubRepositoryIdentity).mockResolvedValue({
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if ("key" in args) return okRate();
|
||||
return {
|
||||
_id: "packageTrustedPublishers:1",
|
||||
packageId: "packages:1",
|
||||
provider: "github-actions",
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-release",
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request(
|
||||
"https://example.com/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer clh_test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: "https://github.com/openclaw/openclaw",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-release",
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
expect(fetchGitHubRepositoryIdentity).toHaveBeenCalledWith(
|
||||
"https://github.com/openclaw/openclaw",
|
||||
);
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
actorUserId: "users:1",
|
||||
packageName: "@openclaw/demo-plugin",
|
||||
repository: "openclaw/openclaw",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-release",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets trusted publisher config for a package without environment", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { _id: "users:1", handle: "p" },
|
||||
} as never);
|
||||
vi.mocked(fetchGitHubRepositoryIdentity).mockResolvedValue({
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if ("key" in args) return okRate();
|
||||
return {
|
||||
_id: "packageTrustedPublishers:1",
|
||||
packageId: "packages:1",
|
||||
provider: "github-actions",
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
};
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request(
|
||||
"https://example.com/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer clh_test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: "https://github.com/openclaw/openclaw",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
expect(fetchGitHubRepositoryIdentity).toHaveBeenCalledWith(
|
||||
"https://github.com/openclaw/openclaw",
|
||||
);
|
||||
expect(await response.json()).toEqual({
|
||||
trustedPublisher: {
|
||||
provider: "github-actions",
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
},
|
||||
});
|
||||
const setCall = runMutation.mock.calls.find(
|
||||
([, args]) =>
|
||||
typeof args === "object" && args !== null && "packageName" in args && "actorUserId" in args,
|
||||
);
|
||||
expect(setCall?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
actorUserId: "users:1",
|
||||
packageName: "@openclaw/demo-plugin",
|
||||
repository: "openclaw/openclaw",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
}),
|
||||
);
|
||||
expect(setCall?.[1]).not.toHaveProperty("environment");
|
||||
});
|
||||
|
||||
it("deletes trusted publisher config for a package", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { _id: "users:1", handle: "p" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
|
||||
if ("key" in args) return okRate();
|
||||
return { deleted: true };
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesDeleteRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request(
|
||||
"https://example.com/api/v1/packages/%40openclaw%2Fdemo-plugin/trusted-publisher",
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: "Bearer clh_test" },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
actorUserId: "users:1",
|
||||
packageName: "@openclaw/demo-plugin",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("delete/undelete map forbidden/not-found/unknown to 403/404/500", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
listCodePluginsV1Handler,
|
||||
listPackagesV1Handler,
|
||||
listPluginsV1Handler,
|
||||
mintPublishTokenV1Handler,
|
||||
packagesDeleteRouterV1Handler,
|
||||
packagesGetRouterV1Handler,
|
||||
packagesPostRouterV1Handler,
|
||||
pluginsGetRouterV1Handler,
|
||||
publishPackageV1Handler,
|
||||
} from "./httpApiV1/packagesV1";
|
||||
@@ -32,8 +35,11 @@ import { whoamiV1Handler } from "./httpApiV1/whoamiV1";
|
||||
export const listPackagesV1Http = httpAction(listPackagesV1Handler);
|
||||
export const listPluginsV1Http = httpAction(listPluginsV1Handler);
|
||||
export const packagesGetRouterV1Http = httpAction(packagesGetRouterV1Handler);
|
||||
export const packagesPostRouterV1Http = httpAction(packagesPostRouterV1Handler);
|
||||
export const packagesDeleteRouterV1Http = httpAction(packagesDeleteRouterV1Handler);
|
||||
export const pluginsGetRouterV1Http = httpAction(pluginsGetRouterV1Handler);
|
||||
export const publishPackageV1Http = httpAction(publishPackageV1Handler);
|
||||
export const mintPublishTokenV1Http = httpAction(mintPublishTokenV1Handler);
|
||||
export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler);
|
||||
export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler);
|
||||
|
||||
@@ -63,8 +69,11 @@ export const __handlers = {
|
||||
listPackagesV1Handler,
|
||||
listPluginsV1Handler,
|
||||
packagesGetRouterV1Handler,
|
||||
packagesPostRouterV1Handler,
|
||||
packagesDeleteRouterV1Handler,
|
||||
pluginsGetRouterV1Handler,
|
||||
publishPackageV1Handler,
|
||||
mintPublishTokenV1Handler,
|
||||
listCodePluginsV1Handler,
|
||||
listBundlePluginsV1Handler,
|
||||
searchSkillsV1Handler,
|
||||
|
||||
+459
-140
@@ -1,21 +1,32 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { PackagePublishRequestSchema, parseArk } from "clawhub-schema";
|
||||
import {
|
||||
PackagePublishRequestSchema,
|
||||
PackageTrustedPublisherUpsertRequestSchema,
|
||||
PublishTokenMintRequestSchema,
|
||||
parseArk,
|
||||
} from "clawhub-schema";
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
|
||||
import {
|
||||
fetchGitHubRepositoryIdentity,
|
||||
verifyGitHubActionsTrustedPublishJwt,
|
||||
} from "../lib/githubActionsOidc";
|
||||
import { corsHeaders, mergeHeaders } from "../lib/httpHeaders";
|
||||
import { applyRateLimit } from "../lib/httpRateLimit";
|
||||
import { getPackageDownloadSecurityBlock } from "../lib/packageSecurity";
|
||||
import { getPublishFileSizeError, MAX_PUBLISH_FILE_BYTES } from "../lib/publishLimits";
|
||||
import { applyRateLimit } from "../lib/httpRateLimit";
|
||||
import { buildDeterministicPackageZip } from "../lib/skillZip";
|
||||
import { isMacJunkPath, isTextFile } from "../lib/skills";
|
||||
import { buildDeterministicPackageZip } from "../lib/skillZip";
|
||||
import { generateToken, hashToken } from "../lib/tokens";
|
||||
import {
|
||||
MAX_RAW_FILE_BYTES,
|
||||
getPathSegments,
|
||||
json,
|
||||
resolveTagsBatch,
|
||||
requireApiTokenUserOrResponse,
|
||||
requirePackagePublishAuthOrResponse,
|
||||
safeTextFileResponse,
|
||||
text,
|
||||
toOptionalNumber,
|
||||
@@ -39,11 +50,20 @@ const internalRefs = internal as unknown as {
|
||||
listPageForViewerInternal: unknown;
|
||||
searchForViewerInternal: unknown;
|
||||
listVersionsForViewerInternal: unknown;
|
||||
getPackageByNameInternal: unknown;
|
||||
getTrustedPublisherByPackageIdInternal: unknown;
|
||||
getVersionByNameForViewerInternal: unknown;
|
||||
publishPackageForUserInternal: unknown;
|
||||
publishPackageForTrustedPublisherInternal: unknown;
|
||||
setTrustedPublisherForUserInternal: unknown;
|
||||
deleteTrustedPublisherForUserInternal: unknown;
|
||||
getReleasesByIdsInternal: unknown;
|
||||
getReleaseByPackageAndVersionInternal: unknown;
|
||||
getReleaseByIdInternal: unknown;
|
||||
insertAuditLogInternal: unknown;
|
||||
};
|
||||
packagePublishTokens: {
|
||||
createInternal: unknown;
|
||||
};
|
||||
skills: {
|
||||
getSkillBySlugInternal: unknown;
|
||||
@@ -60,11 +80,21 @@ async function runActionRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Pro
|
||||
return (await ctx.runAction(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
async function runMutationRef<T>(ctx: ActionCtx, ref: unknown, args: unknown): Promise<T> {
|
||||
return (await ctx.runMutation(ref as never, args as never)) as T;
|
||||
}
|
||||
|
||||
async function getOptionalViewerUserIdForRequest(ctx: ActionCtx, request: Request) {
|
||||
const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request);
|
||||
if (apiTokenUserId) return apiTokenUserId;
|
||||
try {
|
||||
return (await getAuthUserId(ctx)) ?? null;
|
||||
const userId = (await getAuthUserId(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.
|
||||
return null;
|
||||
@@ -134,11 +164,38 @@ type ReleaseLike = {
|
||||
softDeletedAt?: number;
|
||||
};
|
||||
|
||||
type PackageTrustedPublisherLike = {
|
||||
_id: Id<"packageTrustedPublishers">;
|
||||
packageId: Id<"packages">;
|
||||
provider: "github-actions";
|
||||
repository: string;
|
||||
repositoryId: string;
|
||||
repositoryOwner: string;
|
||||
repositoryOwnerId: string;
|
||||
workflowFilename: string;
|
||||
environment?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function toVisibleRelease(release: ReleaseLike | null) {
|
||||
if (!release || ("softDeletedAt" in release && release.softDeletedAt !== undefined)) return null;
|
||||
return release;
|
||||
}
|
||||
|
||||
function toPublicTrustedPublisher(trustedPublisher: PackageTrustedPublisherLike | null) {
|
||||
if (!trustedPublisher) return null;
|
||||
return {
|
||||
provider: trustedPublisher.provider,
|
||||
repository: trustedPublisher.repository,
|
||||
repositoryId: trustedPublisher.repositoryId,
|
||||
repositoryOwner: trustedPublisher.repositoryOwner,
|
||||
repositoryOwnerId: trustedPublisher.repositoryOwnerId,
|
||||
workflowFilename: trustedPublisher.workflowFilename,
|
||||
...(trustedPublisher.environment ? { environment: trustedPublisher.environment } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getReleaseSecurityBlock(release: ReleaseLike) {
|
||||
return getPackageDownloadSecurityBlock(release);
|
||||
}
|
||||
@@ -149,9 +206,13 @@ async function resolvePackageTags(
|
||||
): Promise<Record<string, string>> {
|
||||
const releaseIds = Object.values(tags);
|
||||
if (releaseIds.length === 0) return {};
|
||||
const releases = await runQueryRef<ReleaseLike[]>(ctx, internalRefs.packages.getReleasesByIdsInternal, {
|
||||
releaseIds,
|
||||
});
|
||||
const releases = await runQueryRef<ReleaseLike[]>(
|
||||
ctx,
|
||||
internalRefs.packages.getReleasesByIdsInternal,
|
||||
{
|
||||
releaseIds,
|
||||
},
|
||||
);
|
||||
const byId = new Map(releases.map((release) => [release._id, release.version]));
|
||||
return Object.fromEntries(
|
||||
Object.entries(tags)
|
||||
@@ -222,8 +283,12 @@ function decodeUnifiedCatalogCursor(raw: string | null | undefined): UnifiedCata
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw.slice(UNIFIED_CATALOG_CURSOR_PREFIX.length)) as Partial<UnifiedCatalogCursorState>;
|
||||
const normalize = (input: Partial<CatalogSourceCursorState> | undefined): CatalogSourceCursorState => ({
|
||||
const parsed = JSON.parse(
|
||||
raw.slice(UNIFIED_CATALOG_CURSOR_PREFIX.length),
|
||||
) as Partial<UnifiedCatalogCursorState>;
|
||||
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,
|
||||
@@ -365,6 +430,7 @@ function parsePackagePublishBody(body: unknown) {
|
||||
family: "skill" | "code-plugin" | "bundle-plugin";
|
||||
version: string;
|
||||
changelog: string;
|
||||
manualOverrideReason?: string;
|
||||
channel?: "official" | "community" | "private";
|
||||
tags?: string[];
|
||||
source?: Record<string, unknown>;
|
||||
@@ -385,6 +451,7 @@ function parsePackagePublishBody(body: unknown) {
|
||||
family: parsed.family,
|
||||
version: parsed.version,
|
||||
changelog: parsed.changelog,
|
||||
manualOverrideReason: parsed.manualOverrideReason?.trim() || undefined,
|
||||
channel: parsed.channel ?? undefined,
|
||||
tags: parsed.tags?.filter(Boolean) ?? undefined,
|
||||
source: parsed.source ?? undefined,
|
||||
@@ -416,7 +483,9 @@ async function parseMultipartPackagePublish(ctx: ActionCtx, request: Request) {
|
||||
}
|
||||
const buffer = new Uint8Array(await entry.arrayBuffer());
|
||||
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||
const sha256 = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
const sha256 = Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const storageId = await ctx.storage.store(entry);
|
||||
files.push({
|
||||
path: entry.name,
|
||||
@@ -529,7 +598,10 @@ async function listPackages(
|
||||
]);
|
||||
|
||||
if (!packageCandidate && !skillCandidate) break;
|
||||
if (!skillCandidate || (packageCandidate && compareCatalogItems(packageCandidate, skillCandidate) <= 0)) {
|
||||
if (
|
||||
!skillCandidate ||
|
||||
(packageCandidate && compareCatalogItems(packageCandidate, skillCandidate) <= 0)
|
||||
) {
|
||||
items.push(packageCandidate!);
|
||||
packageSource.index += 1;
|
||||
} else {
|
||||
@@ -597,7 +669,7 @@ export async function publishPackageV1Handler(ctx: ActionCtx, request: Request)
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
|
||||
const auth = await requirePackagePublishAuthOrResponse(ctx, request, rate.headers);
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
try {
|
||||
@@ -605,16 +677,222 @@ export async function publishPackageV1Handler(ctx: ActionCtx, request: Request)
|
||||
const payload = contentType.includes("multipart/form-data")
|
||||
? await parseMultipartPackagePublish(ctx, request)
|
||||
: parsePackagePublishBody(await request.json());
|
||||
const result = await runActionRef(ctx, internalRefs.packages.publishPackageForUserInternal, {
|
||||
actorUserId: auth.userId,
|
||||
payload,
|
||||
});
|
||||
const result =
|
||||
auth.auth.kind === "user"
|
||||
? await runActionRef(ctx, internalRefs.packages.publishPackageForUserInternal, {
|
||||
actorUserId: auth.auth.userId,
|
||||
payload,
|
||||
})
|
||||
: await runActionRef(ctx, internalRefs.packages.publishPackageForTrustedPublisherInternal, {
|
||||
publishTokenId: auth.auth.publishToken._id,
|
||||
payload,
|
||||
});
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return text(error instanceof Error ? error.message : "Publish failed", 400, rate.headers);
|
||||
}
|
||||
}
|
||||
|
||||
async function getPackageAndTrustedPublisherByName(ctx: ActionCtx, packageName: string) {
|
||||
const pkg = await runQueryRef<Doc<"packages"> | null>(
|
||||
ctx,
|
||||
internalRefs.packages.getPackageByNameInternal,
|
||||
{
|
||||
name: packageName,
|
||||
},
|
||||
);
|
||||
if (!pkg || pkg.softDeletedAt) return { pkg: null, trustedPublisher: null };
|
||||
const trustedPublisher = await runQueryRef<PackageTrustedPublisherLike | null>(
|
||||
ctx,
|
||||
internalRefs.packages.getTrustedPublisherByPackageIdInternal,
|
||||
{ packageId: pkg._id },
|
||||
);
|
||||
return { pkg, trustedPublisher };
|
||||
}
|
||||
|
||||
export async function mintPublishTokenV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const rate = await applyRateLimit(ctx, request, "write");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
const parsedBody = await request.json().catch(() => null);
|
||||
if (!parsedBody) return text("Invalid JSON", 400, rate.headers);
|
||||
|
||||
try {
|
||||
const payload = parseArk(
|
||||
PublishTokenMintRequestSchema,
|
||||
parsedBody,
|
||||
"Publish token mint payload",
|
||||
) as {
|
||||
packageName: string;
|
||||
version: string;
|
||||
githubOidcToken: string;
|
||||
};
|
||||
const { pkg, trustedPublisher } = await getPackageAndTrustedPublisherByName(
|
||||
ctx,
|
||||
payload.packageName,
|
||||
);
|
||||
if (!pkg) return text("Package not found", 404, rate.headers);
|
||||
if (!trustedPublisher) {
|
||||
return text("Trusted publisher config is not set for this package", 403, rate.headers);
|
||||
}
|
||||
|
||||
try {
|
||||
const verified = await verifyGitHubActionsTrustedPublishJwt(payload.githubOidcToken, {
|
||||
repository: trustedPublisher.repository,
|
||||
repositoryId: trustedPublisher.repositoryId,
|
||||
repositoryOwner: trustedPublisher.repositoryOwner,
|
||||
repositoryOwnerId: trustedPublisher.repositoryOwnerId,
|
||||
workflowFilename: trustedPublisher.workflowFilename,
|
||||
...(trustedPublisher.environment ? { environment: trustedPublisher.environment } : {}),
|
||||
});
|
||||
const { token, prefix } = generateToken();
|
||||
const tokenHash = await hashToken(token);
|
||||
const expiresAt = Date.now() + 15 * 60_000;
|
||||
|
||||
await ctx.runMutation(
|
||||
internalRefs.packagePublishTokens.createInternal as never,
|
||||
{
|
||||
packageId: pkg._id,
|
||||
version: payload.version,
|
||||
prefix,
|
||||
tokenHash,
|
||||
provider: "github-actions",
|
||||
repository: verified.repository,
|
||||
repositoryId: verified.repositoryId,
|
||||
repositoryOwner: verified.repositoryOwner,
|
||||
repositoryOwnerId: verified.repositoryOwnerId,
|
||||
workflowFilename: verified.workflowFilename,
|
||||
...(trustedPublisher.environment ? { environment: trustedPublisher.environment } : {}),
|
||||
runId: verified.runId,
|
||||
runAttempt: verified.runAttempt,
|
||||
sha: verified.sha,
|
||||
ref: verified.ref,
|
||||
...(verified.refType ? { refType: verified.refType } : {}),
|
||||
...(verified.actor ? { actor: verified.actor } : {}),
|
||||
...(verified.actorId ? { actorId: verified.actorId } : {}),
|
||||
expiresAt,
|
||||
} as never,
|
||||
);
|
||||
await ctx.runMutation(
|
||||
internalRefs.packages.insertAuditLogInternal as never,
|
||||
{
|
||||
actorUserId: pkg.ownerUserId,
|
||||
action: "package.publish_token.mint",
|
||||
targetType: "package",
|
||||
targetId: String(pkg._id),
|
||||
metadata: {
|
||||
version: payload.version,
|
||||
repository: verified.repository,
|
||||
workflowFilename: verified.workflowFilename,
|
||||
...(verified.environment ? { environment: verified.environment } : {}),
|
||||
runId: verified.runId,
|
||||
runAttempt: verified.runAttempt,
|
||||
sha: verified.sha,
|
||||
ref: verified.ref,
|
||||
decision: "allowed",
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
return json({ token, expiresAt }, 200, rate.headers);
|
||||
} catch (error) {
|
||||
await ctx.runMutation(
|
||||
internalRefs.packages.insertAuditLogInternal as never,
|
||||
{
|
||||
actorUserId: pkg.ownerUserId,
|
||||
action: "package.publish_token.mint_rejected",
|
||||
targetType: "package",
|
||||
targetId: String(pkg._id),
|
||||
metadata: {
|
||||
version: payload.version,
|
||||
repository: trustedPublisher.repository,
|
||||
workflowFilename: trustedPublisher.workflowFilename,
|
||||
...(trustedPublisher.environment ? { environment: trustedPublisher.environment } : {}),
|
||||
decision: "rejected",
|
||||
reason: error instanceof Error ? error.message : "Token verification failed",
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
return text(error instanceof Error ? error.message : "Token mint failed", 400, rate.headers);
|
||||
}
|
||||
}
|
||||
|
||||
export async function packagesPostRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/packages/");
|
||||
if (segments[1] !== "trusted-publisher" || segments.length !== 2) {
|
||||
return text("Not found", 404);
|
||||
}
|
||||
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 body = parseArk(
|
||||
PackageTrustedPublisherUpsertRequestSchema,
|
||||
await request.json(),
|
||||
"Trusted publisher payload",
|
||||
) as {
|
||||
repository: string;
|
||||
workflowFilename: string;
|
||||
environment?: string;
|
||||
};
|
||||
const repositoryIdentity = await fetchGitHubRepositoryIdentity(body.repository);
|
||||
const trustedPublisher = await runMutationRef<PackageTrustedPublisherLike | null>(
|
||||
ctx,
|
||||
internalRefs.packages.setTrustedPublisherForUserInternal,
|
||||
{
|
||||
actorUserId: auth.userId,
|
||||
packageName: segments[0]!,
|
||||
repository: repositoryIdentity.repository,
|
||||
repositoryId: repositoryIdentity.repositoryId,
|
||||
repositoryOwner: repositoryIdentity.repositoryOwner,
|
||||
repositoryOwnerId: repositoryIdentity.repositoryOwnerId,
|
||||
workflowFilename: body.workflowFilename,
|
||||
...(body.environment ? { environment: body.environment } : {}),
|
||||
},
|
||||
);
|
||||
return json(
|
||||
{ trustedPublisher: toPublicTrustedPublisher(trustedPublisher) },
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
} catch (error) {
|
||||
return text(
|
||||
error instanceof Error ? error.message : "Trusted publisher update failed",
|
||||
400,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function packagesDeleteRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/packages/");
|
||||
if (segments[1] !== "trusted-publisher" || segments.length !== 2) {
|
||||
return text("Not found", 404);
|
||||
}
|
||||
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 {
|
||||
await runMutationRef(ctx, internalRefs.packages.deleteTrustedPublisherForUserInternal, {
|
||||
actorUserId: auth.userId,
|
||||
packageName: segments[0]!,
|
||||
});
|
||||
return json({ ok: true }, 200, rate.headers);
|
||||
} catch (error) {
|
||||
return text(
|
||||
error instanceof Error ? error.message : "Trusted publisher delete failed",
|
||||
400,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getReleaseForRequest(
|
||||
ctx: ActionCtx,
|
||||
pkg: Pick<PublicPackageDocLike, "_id" | "tags" | "latestReleaseId">,
|
||||
@@ -656,9 +934,7 @@ async function getReleaseForRequest(
|
||||
function isReadmeVariantPath(path: string) {
|
||||
const normalized = path.trim().toLowerCase();
|
||||
return (
|
||||
normalized === "readme.md" ||
|
||||
normalized === "readme.mdx" ||
|
||||
normalized === "readme.markdown"
|
||||
normalized === "readme.md" || normalized === "readme.mdx" || normalized === "readme.markdown"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -698,13 +974,11 @@ function resolvePackageFilePath(release: ReleaseLike, requestedPath: string) {
|
||||
}
|
||||
|
||||
async function getSkillDetailForRequest(ctx: ActionCtx, slug: string) {
|
||||
return (await runQueryRef(ctx, apiRefs.skills.getBySlug, { slug })) as
|
||||
| {
|
||||
skill: SkillPackageDocLike | null;
|
||||
latestVersion: SkillVersionLike | null;
|
||||
owner: { handle?: string; displayName?: string; image?: string } | null;
|
||||
}
|
||||
| null;
|
||||
return (await runQueryRef(ctx, apiRefs.skills.getBySlug, { slug })) as {
|
||||
skill: SkillPackageDocLike | null;
|
||||
latestVersion: SkillVersionLike | null;
|
||||
owner: { handle?: string; displayName?: string; image?: string } | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
async function getSkillVersionForRequest(
|
||||
@@ -769,25 +1043,33 @@ async function searchPackages(
|
||||
|
||||
let results: CatalogSearchEntry[];
|
||||
if (family === "skill") {
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
});
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(
|
||||
ctx,
|
||||
apiRefs.skills.searchPackageCatalogPublic,
|
||||
{
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
},
|
||||
);
|
||||
} else if (family || !includeSkills) {
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
|
||||
query: queryText,
|
||||
limit,
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
});
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(
|
||||
ctx,
|
||||
internalRefs.packages.searchForViewerInternal,
|
||||
{
|
||||
query: queryText,
|
||||
limit,
|
||||
family,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const [packageResults, skillResults] = await Promise.all([
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
|
||||
@@ -840,20 +1122,14 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
|
||||
const packageName = segments[0] ?? "";
|
||||
const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
|
||||
const detail = (await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.packages.getByNameForViewerInternal,
|
||||
{
|
||||
name: packageName,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
},
|
||||
)) as
|
||||
| {
|
||||
package: PublicPackageDocLike | null;
|
||||
latestRelease: ReleaseLike | null;
|
||||
owner: { _id: Id<"users">; handle?: string; displayName?: string; image?: string } | null;
|
||||
}
|
||||
| null;
|
||||
const detail = (await runQueryRef(ctx, internalRefs.packages.getByNameForViewerInternal, {
|
||||
name: packageName,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
})) as {
|
||||
package: PublicPackageDocLike | null;
|
||||
latestRelease: ReleaseLike | null;
|
||||
owner: { _id: Id<"users">; handle?: string; displayName?: string; image?: string } | null;
|
||||
} | null;
|
||||
const skillDetail = detail?.package ? null : await getSkillDetailForRequest(ctx, packageName);
|
||||
if (!detail?.package && !skillDetail?.skill) return text("Package not found", 404, rate.headers);
|
||||
const packageDetail = detail?.package ? detail : null;
|
||||
@@ -873,23 +1149,44 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
return json({
|
||||
package: {
|
||||
...publicPackage!,
|
||||
tags: await resolvePackageTags(ctx, publicPackage!.tags),
|
||||
return json(
|
||||
{
|
||||
package: {
|
||||
...publicPackage!,
|
||||
tags: await resolvePackageTags(ctx, publicPackage!.tags),
|
||||
},
|
||||
owner: packageOwner
|
||||
? {
|
||||
handle: packageOwner.handle ?? null,
|
||||
displayName: packageOwner.displayName ?? null,
|
||||
image: packageOwner.image ?? null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
owner: packageOwner
|
||||
? {
|
||||
handle: packageOwner.handle ?? null,
|
||||
displayName: packageOwner.displayName ?? null,
|
||||
image: packageOwner.image ?? null,
|
||||
}
|
||||
: null,
|
||||
}, 200, rate.headers);
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
|
||||
if (segments[1] === "trusted-publisher" && segments.length === 2) {
|
||||
if (!publicPackage) return text("Not found", 404, rate.headers);
|
||||
const trustedPublisher = await runQueryRef<PackageTrustedPublisherLike | null>(
|
||||
ctx,
|
||||
internalRefs.packages.getTrustedPublisherByPackageIdInternal,
|
||||
{ packageId: publicPackage._id },
|
||||
);
|
||||
return json(
|
||||
{ trustedPublisher: toPublicTrustedPublisher(trustedPublisher) },
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
|
||||
if (segments[1] === "versions" && segments.length === 2) {
|
||||
const limit = Math.max(1, Math.min(toOptionalNumber(new URL(request.url).searchParams.get("limit")) ?? 25, 100));
|
||||
const limit = Math.max(
|
||||
1,
|
||||
Math.min(toOptionalNumber(new URL(request.url).searchParams.get("limit")) ?? 25, 100),
|
||||
);
|
||||
const cursor = new URL(request.url).searchParams.get("cursor");
|
||||
if (skillDetail?.skill) {
|
||||
const result = (await runQueryRef(ctx, apiRefs.skills.listVersionsPage, {
|
||||
@@ -901,15 +1198,19 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
nextCursor: string | null;
|
||||
};
|
||||
const tags = await resolveSkillTags(ctx, skillDetail.skill.tags);
|
||||
return json({
|
||||
items: result.items.map((version) => ({
|
||||
version: version.version,
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
distTags: skillVersionTags(tags, version.version),
|
||||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
}, 200, rate.headers);
|
||||
return json(
|
||||
{
|
||||
items: result.items.map((version) => ({
|
||||
version: version.version,
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
distTags: skillVersionTags(tags, version.version),
|
||||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
const result = await runQueryRef<{
|
||||
page: ReleaseLike[];
|
||||
@@ -920,47 +1221,59 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
paginationOpts: { cursor, numItems: limit },
|
||||
});
|
||||
return json({
|
||||
items: result.page.map((release: ReleaseLike) => ({
|
||||
version: release.version,
|
||||
createdAt: release.createdAt,
|
||||
changelog: release.changelog,
|
||||
distTags: release.distTags ?? [],
|
||||
})),
|
||||
nextCursor: result.isDone ? null : result.continueCursor,
|
||||
}, 200, rate.headers);
|
||||
return json(
|
||||
{
|
||||
items: result.page.map((release: ReleaseLike) => ({
|
||||
version: release.version,
|
||||
createdAt: release.createdAt,
|
||||
changelog: release.changelog,
|
||||
distTags: release.distTags ?? [],
|
||||
})),
|
||||
nextCursor: result.isDone ? null : result.continueCursor,
|
||||
},
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
|
||||
if (segments[1] === "versions" && segments[2]) {
|
||||
if (skillDetail?.skill) {
|
||||
const version = (await runQueryRef(ctx, internalRefs.skills.getVersionBySkillAndVersionInternal, {
|
||||
skillId: skillDetail.skill._id,
|
||||
version: segments[2],
|
||||
})) as SkillVersionLike | null;
|
||||
const version = (await runQueryRef(
|
||||
ctx,
|
||||
internalRefs.skills.getVersionBySkillAndVersionInternal,
|
||||
{
|
||||
skillId: skillDetail.skill._id,
|
||||
version: segments[2],
|
||||
},
|
||||
)) as SkillVersionLike | null;
|
||||
if (!version || version.softDeletedAt) return text("Version not found", 404, rate.headers);
|
||||
const tags = await resolveSkillTags(ctx, skillDetail.skill.tags);
|
||||
return json({
|
||||
package: {
|
||||
name: skillDetail.skill.slug,
|
||||
displayName: skillDetail.skill.displayName,
|
||||
family: "skill",
|
||||
return json(
|
||||
{
|
||||
package: {
|
||||
name: skillDetail.skill.slug,
|
||||
displayName: skillDetail.skill.displayName,
|
||||
family: "skill",
|
||||
},
|
||||
version: {
|
||||
version: version.version,
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
distTags: skillVersionTags(tags, version.version),
|
||||
files: version.files.map((file) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
contentType: file.contentType,
|
||||
})),
|
||||
compatibility: null,
|
||||
capabilities: null,
|
||||
verification: null,
|
||||
},
|
||||
},
|
||||
version: {
|
||||
version: version.version,
|
||||
createdAt: version.createdAt,
|
||||
changelog: version.changelog,
|
||||
distTags: skillVersionTags(tags, version.version),
|
||||
files: version.files.map((file) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
contentType: file.contentType,
|
||||
})),
|
||||
compatibility: null,
|
||||
capabilities: null,
|
||||
verification: null,
|
||||
},
|
||||
}, 200, rate.headers);
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
const result = (await runQueryRef(
|
||||
ctx,
|
||||
@@ -972,32 +1285,36 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
},
|
||||
)) as { package: PublicPackageDocLike; version: ReleaseLike } | null;
|
||||
if (!result) return text("Version not found", 404, rate.headers);
|
||||
return json({
|
||||
package: {
|
||||
name: result.package.name,
|
||||
displayName: result.package.displayName,
|
||||
family: result.package.family,
|
||||
return json(
|
||||
{
|
||||
package: {
|
||||
name: result.package.name,
|
||||
displayName: result.package.displayName,
|
||||
family: result.package.family,
|
||||
},
|
||||
version: {
|
||||
version: result.version.version,
|
||||
createdAt: result.version.createdAt,
|
||||
changelog: result.version.changelog,
|
||||
distTags: result.version.distTags ?? [],
|
||||
files: result.version.files.map((file) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
contentType: file.contentType,
|
||||
})),
|
||||
compatibility: result.version.compatibility ?? null,
|
||||
capabilities: result.version.capabilities ?? null,
|
||||
verification: result.version.verification ?? null,
|
||||
sha256hash: result.version.sha256hash ?? null,
|
||||
vtAnalysis: result.version.vtAnalysis ?? null,
|
||||
llmAnalysis: result.version.llmAnalysis ?? null,
|
||||
staticScan: result.version.staticScan ?? null,
|
||||
},
|
||||
},
|
||||
version: {
|
||||
version: result.version.version,
|
||||
createdAt: result.version.createdAt,
|
||||
changelog: result.version.changelog,
|
||||
distTags: result.version.distTags ?? [],
|
||||
files: result.version.files.map((file) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
contentType: file.contentType,
|
||||
})),
|
||||
compatibility: result.version.compatibility ?? null,
|
||||
capabilities: result.version.capabilities ?? null,
|
||||
verification: result.version.verification ?? null,
|
||||
sha256hash: result.version.sha256hash ?? null,
|
||||
vtAnalysis: result.version.vtAnalysis ?? null,
|
||||
llmAnalysis: result.version.llmAnalysis ?? null,
|
||||
staticScan: result.version.staticScan ?? null,
|
||||
},
|
||||
}, 200, rate.headers);
|
||||
200,
|
||||
rate.headers,
|
||||
);
|
||||
}
|
||||
|
||||
if (segments[1] === "file") {
|
||||
@@ -1008,7 +1325,8 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
if (!version || version.softDeletedAt) return text("Version not found", 404, rate.headers);
|
||||
const file = resolveSkillFilePath(version, path);
|
||||
if (!file) return text("File not found", 404, rate.headers);
|
||||
if (!("storageId" in file) || !file.storageId) return text("File not found", 404, rate.headers);
|
||||
if (!("storageId" in file) || !file.storageId)
|
||||
return text("File not found", 404, rate.headers);
|
||||
if (!isTextFile(file.path, file.contentType)) {
|
||||
return text("Binary files are not served inline", 415, rate.headers);
|
||||
}
|
||||
@@ -1115,6 +1433,7 @@ type PublicPackageDocLike = {
|
||||
compatibility?: Doc<"packages">["compatibility"];
|
||||
capabilities?: Doc<"packages">["capabilities"];
|
||||
verification?: Doc<"packages">["verification"];
|
||||
stats?: { downloads: number; installs: number; stars: number; versions: number };
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CliPublishRequestSchema, parseArk } from "clawhub-schema";
|
||||
import { CliPublishRequestSchema, normalizeTextContentType, parseArk } from "clawhub-schema";
|
||||
import { internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { assertAdmin } from "../lib/access";
|
||||
import { requireApiTokenUser } from "../lib/apiTokenAuth";
|
||||
import { requireApiTokenUser, requirePackagePublishAuth } from "../lib/apiTokenAuth";
|
||||
import { corsHeaders, mergeHeaders } from "../lib/httpHeaders";
|
||||
import { getPublishFileSizeError, MAX_PUBLISH_FILE_BYTES } from "../lib/publishLimits";
|
||||
import { isMacJunkPath } from "../lib/skills";
|
||||
@@ -25,7 +25,9 @@ export function safeTextFileResponse(params: {
|
||||
size: number;
|
||||
headers?: HeadersInit;
|
||||
}) {
|
||||
const isSvg = isSvgLike(params.contentType, params.path);
|
||||
const contentType =
|
||||
normalizeTextContentType(params.path, params.contentType) ?? params.contentType;
|
||||
const isSvg = isSvgLike(contentType, params.path);
|
||||
|
||||
// For any text response that a browser might try to render, lock it down.
|
||||
// In particular, this prevents SVG <foreignObject> script execution from reading
|
||||
@@ -33,8 +35,8 @@ export function safeTextFileResponse(params: {
|
||||
const headers = mergeHeaders(
|
||||
params.headers,
|
||||
{
|
||||
"Content-Type": params.contentType
|
||||
? `${params.contentType}; charset=utf-8`
|
||||
"Content-Type": contentType
|
||||
? `${contentType}; charset=utf-8`
|
||||
: "text/plain; charset=utf-8",
|
||||
"Cache-Control": "private, max-age=60",
|
||||
ETag: params.sha256,
|
||||
@@ -101,6 +103,18 @@ export async function requireApiTokenUserOrResponse(
|
||||
}
|
||||
}
|
||||
|
||||
export async function requirePackagePublishAuthOrResponse(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
headers: HeadersInit,
|
||||
) {
|
||||
try {
|
||||
return { ok: true as const, auth: await requirePackagePublishAuth(ctx, request) };
|
||||
} catch {
|
||||
return { ok: false as const, response: text("Unauthorized", 401, headers) };
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdminOrResponse(user: Doc<"users">, headers: HeadersInit) {
|
||||
try {
|
||||
assertAdmin(user);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { api, internal } from "../_generated/api";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth";
|
||||
import { applyRateLimit, parseBearerToken } from "../lib/httpRateLimit";
|
||||
import { parseBooleanQueryParam, resolveBooleanQueryParam } from "../lib/httpUtils";
|
||||
import type { LlmEvalDimension } from "../lib/securityPrompt";
|
||||
import { publishVersionForUser } from "../skills";
|
||||
import {
|
||||
MAX_RAW_FILE_BYTES,
|
||||
@@ -81,6 +83,7 @@ type PublicSkillVersionResponse = {
|
||||
sha256hash?: string;
|
||||
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
|
||||
llmAnalysis?: Doc<"skillVersions">["llmAnalysis"];
|
||||
capabilityTags?: string[];
|
||||
};
|
||||
|
||||
type ModerationEvidence = {
|
||||
@@ -189,6 +192,7 @@ type SkillSecuritySnapshot = {
|
||||
hasScanResult: boolean;
|
||||
sha256hash: string | null;
|
||||
virustotalUrl: string | null;
|
||||
capabilityTags: string[];
|
||||
scanners: {
|
||||
vt: {
|
||||
status: string;
|
||||
@@ -204,7 +208,7 @@ type SkillSecuritySnapshot = {
|
||||
normalizedStatus: NormalizedSecurityStatus;
|
||||
confidence: string | null;
|
||||
summary: string | null;
|
||||
dimensions: NonNullable<Doc<"skillVersions">["llmAnalysis"]>["dimensions"] | null;
|
||||
dimensions: LlmEvalDimension[] | null;
|
||||
guidance: string | null;
|
||||
findings: string | null;
|
||||
model: string | null;
|
||||
@@ -260,7 +264,7 @@ function mergeSecurityStatuses(statuses: NormalizedSecurityStatus[]) {
|
||||
}
|
||||
|
||||
function hasLlmDimensionWarnings(
|
||||
dimensions: NonNullable<Doc<"skillVersions">["llmAnalysis"]>["dimensions"] | undefined,
|
||||
dimensions: LlmEvalDimension[] | undefined,
|
||||
) {
|
||||
if (!Array.isArray(dimensions)) return false;
|
||||
return dimensions.some((dimension) => {
|
||||
@@ -271,13 +275,17 @@ function hasLlmDimensionWarnings(
|
||||
}
|
||||
|
||||
function buildSkillSecuritySnapshot(
|
||||
version: Pick<PublicSkillVersionResponse, "sha256hash" | "vtAnalysis" | "llmAnalysis">,
|
||||
version: Pick<
|
||||
PublicSkillVersionResponse,
|
||||
"sha256hash" | "vtAnalysis" | "llmAnalysis" | "capabilityTags"
|
||||
>,
|
||||
): SkillSecuritySnapshot | null {
|
||||
const capabilityTags = version.capabilityTags ?? [];
|
||||
const sha256hash = version.sha256hash ?? null;
|
||||
const vt = version.vtAnalysis;
|
||||
const llm = version.llmAnalysis;
|
||||
|
||||
if (!sha256hash && !vt && !llm) return null;
|
||||
if (!sha256hash && !vt && !llm && capabilityTags.length === 0) return null;
|
||||
|
||||
const vtStatus = vt ? normalizeSecurityStatus(vt.verdict ?? vt.status) : null;
|
||||
const llmStatus = llm ? normalizeSecurityStatus(llm.verdict ?? llm.status) : null;
|
||||
@@ -305,6 +313,7 @@ function buildSkillSecuritySnapshot(
|
||||
hasScanResult,
|
||||
sha256hash,
|
||||
virustotalUrl: sha256hash ? `https://www.virustotal.com/gui/file/${sha256hash}` : null,
|
||||
capabilityTags,
|
||||
scanners: {
|
||||
vt: vt
|
||||
? {
|
||||
@@ -726,7 +735,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
contentType: file.contentType ?? null,
|
||||
contentType: normalizeTextContentType(file.path, file.contentType) ?? null,
|
||||
})),
|
||||
security: security ?? undefined,
|
||||
},
|
||||
|
||||
@@ -22,7 +22,10 @@ export async function starsPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
skillId: skill._id,
|
||||
});
|
||||
return json(result, 200, rate.headers);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Skill not found") {
|
||||
return text("Skill not found", 404, rate.headers);
|
||||
}
|
||||
return text("Unauthorized", 401, rate.headers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,9 +236,9 @@ async function handleAdminEnsurePublisher(
|
||||
const handle = typeof payload.handle === "string" ? payload.handle.trim().toLowerCase() : "";
|
||||
if (!handle) return text("Missing handle", 400, headers);
|
||||
|
||||
const displayName = typeof payload.displayName === "string" ? payload.displayName.trim() : undefined;
|
||||
const trusted =
|
||||
typeof payload.trusted === "boolean" ? payload.trusted : true;
|
||||
const displayName =
|
||||
typeof payload.displayName === "string" ? payload.displayName.trim() : undefined;
|
||||
const trusted = typeof payload.trusted === "boolean" ? payload.trusted : true;
|
||||
|
||||
try {
|
||||
const result = await ctx.runMutation(internal.publishers.ensureOrgPublisherHandleInternal, {
|
||||
|
||||
@@ -36,6 +36,17 @@ describe("access.requireUser", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when auth resolves to an invalid user id", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:broken" as never);
|
||||
const dbGet = vi.fn().mockRejectedValue(new Error("Table mismatch"));
|
||||
|
||||
await expect(
|
||||
requireUser({
|
||||
db: { get: dbGet },
|
||||
} as never),
|
||||
).rejects.toThrow("User not found");
|
||||
});
|
||||
|
||||
it("returns auth user when active", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:2" as never);
|
||||
const user = { _id: "users:2", role: "user" };
|
||||
@@ -77,6 +88,17 @@ describe("access.requireUserFromAction", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when action auth resolves to an invalid user id", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:broken" as never);
|
||||
const runQuery = vi.fn().mockRejectedValue(new Error("Table mismatch"));
|
||||
|
||||
await expect(
|
||||
requireUserFromAction({
|
||||
runQuery,
|
||||
} as never),
|
||||
).rejects.toThrow("User not found");
|
||||
});
|
||||
|
||||
it("returns active user from action query", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:9" as never);
|
||||
const user = { _id: "users:9", role: "admin" };
|
||||
|
||||
+40
-2
@@ -5,10 +5,43 @@ import type { ActionCtx, MutationCtx, QueryCtx } from "../_generated/server";
|
||||
|
||||
export type Role = "admin" | "moderator" | "user";
|
||||
|
||||
export async function getOptionalActiveAuthUserId(
|
||||
ctx: MutationCtx | QueryCtx,
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
try {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return undefined;
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return userId;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOptionalActiveAuthUserIdFromAction(
|
||||
ctx: ActionCtx,
|
||||
): Promise<Id<"users"> | undefined> {
|
||||
try {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return undefined;
|
||||
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return undefined;
|
||||
return userId;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireUser(ctx: MutationCtx | QueryCtx) {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
const user = await ctx.db.get(userId);
|
||||
let user: Doc<"users"> | null;
|
||||
try {
|
||||
user = await ctx.db.get(userId);
|
||||
} catch {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
|
||||
return { userId, user };
|
||||
}
|
||||
@@ -18,7 +51,12 @@ export async function requireUserFromAction(
|
||||
): Promise<{ userId: Id<"users">; user: Doc<"users"> }> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
const user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
|
||||
let user: Doc<"users"> | null;
|
||||
try {
|
||||
user = await ctx.runQuery(internal.users.getByIdInternal, { userId });
|
||||
} catch {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new Error("User not found");
|
||||
return { userId, user: user as Doc<"users"> };
|
||||
}
|
||||
|
||||
+81
-10
@@ -5,6 +5,29 @@ import type { ActionCtx } from "../_generated/server";
|
||||
import { hashToken } from "./tokens";
|
||||
|
||||
type TokenAuthResult = { user: Doc<"users">; userId: Doc<"users">["_id"] };
|
||||
type ApiTokenDoc = Doc<"apiTokens">;
|
||||
type PackagePublishTokenAuthResult = {
|
||||
kind: "github-actions";
|
||||
publishToken: Doc<"packagePublishTokens">;
|
||||
};
|
||||
type PackagePublishTokenDoc = Doc<"packagePublishTokens">;
|
||||
type UserPackagePublishAuthResult = {
|
||||
kind: "user";
|
||||
user: Doc<"users">;
|
||||
userId: Doc<"users">["_id"];
|
||||
};
|
||||
|
||||
const internalRefs = internal as unknown as {
|
||||
tokens: {
|
||||
getByHashInternal: unknown;
|
||||
getUserForTokenInternal: unknown;
|
||||
touchInternal: unknown;
|
||||
};
|
||||
packagePublishTokens: {
|
||||
getByHashInternal: unknown;
|
||||
touchInternal: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export async function requireApiTokenUser(
|
||||
ctx: ActionCtx,
|
||||
@@ -15,15 +38,26 @@ export async function requireApiTokenUser(
|
||||
if (!token) throw new ConvexError("Unauthorized");
|
||||
|
||||
const tokenHash = await hashToken(token);
|
||||
const apiToken = await ctx.runQuery(internal.tokens.getByHashInternal, { tokenHash });
|
||||
const apiToken = (await ctx.runQuery(
|
||||
internalRefs.tokens.getByHashInternal as never,
|
||||
{
|
||||
tokenHash,
|
||||
} as never,
|
||||
)) as ApiTokenDoc | null;
|
||||
if (!apiToken || apiToken.revokedAt) throw new ConvexError("Unauthorized");
|
||||
|
||||
const user = await ctx.runQuery(internal.tokens.getUserForTokenInternal, {
|
||||
tokenId: apiToken._id,
|
||||
});
|
||||
const user = (await ctx.runQuery(
|
||||
internalRefs.tokens.getUserForTokenInternal as never,
|
||||
{
|
||||
tokenId: apiToken._id,
|
||||
} as never,
|
||||
)) as Doc<"users"> | null;
|
||||
if (!user || user.deletedAt || user.deactivatedAt) throw new ConvexError("Unauthorized");
|
||||
|
||||
await ctx.runMutation(internal.tokens.touchInternal, { tokenId: apiToken._id });
|
||||
await ctx.runMutation(
|
||||
internalRefs.tokens.touchInternal as never,
|
||||
{ tokenId: apiToken._id } as never,
|
||||
);
|
||||
return { user, userId: user._id };
|
||||
}
|
||||
|
||||
@@ -36,18 +70,55 @@ export async function getOptionalApiTokenUserId(
|
||||
if (!token) return null;
|
||||
|
||||
const tokenHash = await hashToken(token);
|
||||
const apiToken = await ctx.runQuery(internal.tokens.getByHashInternal, { tokenHash });
|
||||
const apiToken = (await ctx.runQuery(
|
||||
internalRefs.tokens.getByHashInternal as never,
|
||||
{
|
||||
tokenHash,
|
||||
} as never,
|
||||
)) as ApiTokenDoc | null;
|
||||
if (!apiToken || apiToken.revokedAt) return null;
|
||||
|
||||
const user = await ctx.runQuery(internal.tokens.getUserForTokenInternal, {
|
||||
tokenId: apiToken._id,
|
||||
});
|
||||
const user = (await ctx.runQuery(
|
||||
internalRefs.tokens.getUserForTokenInternal as never,
|
||||
{
|
||||
tokenId: apiToken._id,
|
||||
} as never,
|
||||
)) as Doc<"users"> | null;
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return null;
|
||||
|
||||
return user._id;
|
||||
}
|
||||
|
||||
function parseBearerToken(header: string | null) {
|
||||
export async function requirePackagePublishAuth(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
): Promise<UserPackagePublishAuthResult | PackagePublishTokenAuthResult> {
|
||||
const header = request.headers.get("authorization") ?? request.headers.get("Authorization");
|
||||
const token = parseBearerToken(header);
|
||||
if (!token) throw new ConvexError("Unauthorized");
|
||||
|
||||
const tokenHash = await hashToken(token);
|
||||
const publishToken = (await ctx.runQuery(
|
||||
internalRefs.packagePublishTokens.getByHashInternal as never,
|
||||
{
|
||||
tokenHash,
|
||||
} as never,
|
||||
)) as PackagePublishTokenDoc | null;
|
||||
if (publishToken && !publishToken.revokedAt && publishToken.expiresAt > Date.now()) {
|
||||
await ctx.runMutation(
|
||||
internalRefs.packagePublishTokens.touchInternal as never,
|
||||
{
|
||||
tokenId: publishToken._id,
|
||||
} as never,
|
||||
);
|
||||
return { kind: "github-actions", publishToken };
|
||||
}
|
||||
|
||||
const auth = await requireApiTokenUser(ctx, request);
|
||||
return { kind: "user", user: auth.user, userId: auth.userId };
|
||||
}
|
||||
|
||||
export function parseBearerToken(header: string | null) {
|
||||
if (!header) return null;
|
||||
const trimmed = header.trim();
|
||||
if (!trimmed.toLowerCase().startsWith("bearer ")) return null;
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
extractWorkflowFilenameFromWorkflowRef,
|
||||
verifyGitHubActionsTrustedPublishJwt,
|
||||
type TrustedGitHubActionsPublisher,
|
||||
} from "./githubActionsOidc";
|
||||
|
||||
const trustedPublisher: TrustedGitHubActionsPublisher = {
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "123456",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "7890",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-plugin-release",
|
||||
};
|
||||
const trustedPublisherWithoutEnvironment: TrustedGitHubActionsPublisher = {
|
||||
...trustedPublisher,
|
||||
environment: undefined,
|
||||
};
|
||||
const signingKeyPairPromise = crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true,
|
||||
["sign", "verify"],
|
||||
);
|
||||
|
||||
describe("extractWorkflowFilenameFromWorkflowRef", () => {
|
||||
it("extracts the workflow filename from workflow_ref", () => {
|
||||
expect(
|
||||
extractWorkflowFilenameFromWorkflowRef(
|
||||
"openclaw/openclaw/.github/workflows/plugin-clawhub-release.yml@refs/heads/main",
|
||||
"openclaw/openclaw",
|
||||
),
|
||||
).toBe("plugin-clawhub-release.yml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyGitHubActionsTrustedPublishJwt", () => {
|
||||
it("accepts a valid GitHub Actions token", async () => {
|
||||
const { token, jwks } = await createSignedToken({
|
||||
repository: trustedPublisher.repository,
|
||||
repository_id: trustedPublisher.repositoryId,
|
||||
repository_owner: trustedPublisher.repositoryOwner,
|
||||
repository_owner_id: trustedPublisher.repositoryOwnerId,
|
||||
workflow_ref:
|
||||
"openclaw/openclaw/.github/workflows/plugin-clawhub-release.yml@refs/heads/main",
|
||||
runner_environment: "github-hosted",
|
||||
environment: trustedPublisher.environment,
|
||||
event_name: "workflow_dispatch",
|
||||
workflow: "Plugin ClawHub Release",
|
||||
sha: "deadbeef",
|
||||
ref: "refs/heads/main",
|
||||
ref_type: "branch",
|
||||
actor: "onur",
|
||||
actor_id: "42",
|
||||
run_id: "100",
|
||||
run_attempt: "2",
|
||||
iss: "https://token.actions.githubusercontent.com",
|
||||
aud: "clawhub",
|
||||
exp: Math.floor(Date.now() / 1000) + 300,
|
||||
iat: Math.floor(Date.now() / 1000) - 5,
|
||||
});
|
||||
|
||||
const identity = await verifyGitHubActionsTrustedPublishJwt(token, trustedPublisher, {
|
||||
fetchImpl: async () =>
|
||||
new Response(JSON.stringify({ keys: [jwks] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(identity).toMatchObject({
|
||||
repository: trustedPublisher.repository,
|
||||
repositoryId: trustedPublisher.repositoryId,
|
||||
repositoryOwner: trustedPublisher.repositoryOwner,
|
||||
repositoryOwnerId: trustedPublisher.repositoryOwnerId,
|
||||
workflowFilename: trustedPublisher.workflowFilename,
|
||||
environment: trustedPublisher.environment,
|
||||
runId: "100",
|
||||
runAttempt: "2",
|
||||
sha: "deadbeef",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a valid GitHub Actions token when no environment is pinned", async () => {
|
||||
const { token, jwks } = await createSignedToken({
|
||||
repository: trustedPublisher.repository,
|
||||
repository_id: trustedPublisher.repositoryId,
|
||||
repository_owner: trustedPublisher.repositoryOwner,
|
||||
repository_owner_id: trustedPublisher.repositoryOwnerId,
|
||||
workflow_ref:
|
||||
"openclaw/openclaw/.github/workflows/plugin-clawhub-release.yml@refs/heads/main",
|
||||
runner_environment: "github-hosted",
|
||||
event_name: "workflow_dispatch",
|
||||
workflow: "Plugin ClawHub Release",
|
||||
sha: "deadbeef",
|
||||
ref: "refs/heads/main",
|
||||
ref_type: "branch",
|
||||
actor: "onur",
|
||||
actor_id: "42",
|
||||
run_id: "100",
|
||||
run_attempt: "2",
|
||||
iss: "https://token.actions.githubusercontent.com",
|
||||
aud: "clawhub",
|
||||
exp: Math.floor(Date.now() / 1000) + 300,
|
||||
iat: Math.floor(Date.now() / 1000) - 5,
|
||||
});
|
||||
|
||||
const identity = await verifyGitHubActionsTrustedPublishJwt(
|
||||
token,
|
||||
trustedPublisherWithoutEnvironment,
|
||||
{
|
||||
fetchImpl: async () =>
|
||||
new Response(JSON.stringify({ keys: [jwks] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(identity).toMatchObject({
|
||||
repository: trustedPublisher.repository,
|
||||
repositoryId: trustedPublisher.repositoryId,
|
||||
repositoryOwner: trustedPublisher.repositoryOwner,
|
||||
repositoryOwnerId: trustedPublisher.repositoryOwnerId,
|
||||
workflowFilename: trustedPublisher.workflowFilename,
|
||||
runId: "100",
|
||||
runAttempt: "2",
|
||||
sha: "deadbeef",
|
||||
});
|
||||
expect(identity.environment).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects reusable workflow tokens", async () => {
|
||||
const { token, jwks } = await createSignedToken({
|
||||
repository: trustedPublisher.repository,
|
||||
repository_id: trustedPublisher.repositoryId,
|
||||
repository_owner: trustedPublisher.repositoryOwner,
|
||||
repository_owner_id: trustedPublisher.repositoryOwnerId,
|
||||
workflow_ref:
|
||||
"openclaw/openclaw/.github/workflows/plugin-clawhub-release.yml@refs/heads/main",
|
||||
job_workflow_ref:
|
||||
"openclaw/shared/.github/workflows/reusable-plugin-release.yml@refs/heads/main",
|
||||
runner_environment: "github-hosted",
|
||||
environment: trustedPublisher.environment,
|
||||
event_name: "workflow_dispatch",
|
||||
workflow: "Plugin ClawHub Release",
|
||||
sha: "deadbeef",
|
||||
ref: "refs/heads/main",
|
||||
run_id: "100",
|
||||
run_attempt: "1",
|
||||
iss: "https://token.actions.githubusercontent.com",
|
||||
aud: "clawhub",
|
||||
exp: Math.floor(Date.now() / 1000) + 300,
|
||||
iat: Math.floor(Date.now() / 1000) - 5,
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGitHubActionsTrustedPublishJwt(token, trustedPublisher, {
|
||||
fetchImpl: async () =>
|
||||
new Response(JSON.stringify({ keys: [jwks] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("Only the official ClawHub reusable workflow is supported");
|
||||
});
|
||||
|
||||
it("accepts the official ClawHub reusable workflow", async () => {
|
||||
const { token, jwks } = await createSignedToken({
|
||||
repository: trustedPublisher.repository,
|
||||
repository_id: trustedPublisher.repositoryId,
|
||||
repository_owner: trustedPublisher.repositoryOwner,
|
||||
repository_owner_id: trustedPublisher.repositoryOwnerId,
|
||||
workflow_ref:
|
||||
"openclaw/openclaw/.github/workflows/plugin-clawhub-release.yml@refs/heads/main",
|
||||
job_workflow_ref: "openclaw/clawhub/.github/workflows/package-publish.yml@refs/heads/main",
|
||||
runner_environment: "github-hosted",
|
||||
environment: trustedPublisher.environment,
|
||||
event_name: "workflow_dispatch",
|
||||
workflow: "Plugin ClawHub Release",
|
||||
sha: "deadbeef",
|
||||
ref: "refs/heads/main",
|
||||
run_id: "100",
|
||||
run_attempt: "1",
|
||||
iss: "https://token.actions.githubusercontent.com",
|
||||
aud: "clawhub",
|
||||
exp: Math.floor(Date.now() / 1000) + 300,
|
||||
iat: Math.floor(Date.now() / 1000) - 5,
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGitHubActionsTrustedPublishJwt(token, trustedPublisher, {
|
||||
fetchImpl: async () =>
|
||||
new Response(JSON.stringify({ keys: [jwks] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
repository: trustedPublisher.repository,
|
||||
workflowFilename: trustedPublisher.workflowFilename,
|
||||
jobWorkflowRef: "openclaw/clawhub/.github/workflows/package-publish.yml@refs/heads/main",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects environment mismatches", async () => {
|
||||
const { token, jwks } = await createSignedToken({
|
||||
repository: trustedPublisher.repository,
|
||||
repository_id: trustedPublisher.repositoryId,
|
||||
repository_owner: trustedPublisher.repositoryOwner,
|
||||
repository_owner_id: trustedPublisher.repositoryOwnerId,
|
||||
workflow_ref:
|
||||
"openclaw/openclaw/.github/workflows/plugin-clawhub-release.yml@refs/heads/main",
|
||||
runner_environment: "github-hosted",
|
||||
environment: "other-environment",
|
||||
event_name: "workflow_dispatch",
|
||||
workflow: "Plugin ClawHub Release",
|
||||
sha: "deadbeef",
|
||||
ref: "refs/heads/main",
|
||||
run_id: "100",
|
||||
run_attempt: "1",
|
||||
iss: "https://token.actions.githubusercontent.com",
|
||||
aud: "clawhub",
|
||||
exp: Math.floor(Date.now() / 1000) + 300,
|
||||
iat: Math.floor(Date.now() / 1000) - 5,
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGitHubActionsTrustedPublishJwt(token, trustedPublisher, {
|
||||
fetchImpl: async () =>
|
||||
new Response(JSON.stringify({ keys: [jwks] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("GitHub OIDC environment mismatch");
|
||||
});
|
||||
|
||||
it("refreshes JWKS on signing-key cache misses", async () => {
|
||||
const now = Date.now() + 10 * 60_000;
|
||||
const { token, jwks } = await createSignedToken(
|
||||
{
|
||||
repository: trustedPublisher.repository,
|
||||
repository_id: trustedPublisher.repositoryId,
|
||||
repository_owner: trustedPublisher.repositoryOwner,
|
||||
repository_owner_id: trustedPublisher.repositoryOwnerId,
|
||||
workflow_ref:
|
||||
"openclaw/openclaw/.github/workflows/plugin-clawhub-release.yml@refs/heads/main",
|
||||
runner_environment: "github-hosted",
|
||||
environment: trustedPublisher.environment,
|
||||
event_name: "workflow_dispatch",
|
||||
workflow: "Plugin ClawHub Release",
|
||||
sha: "deadbeef",
|
||||
ref: "refs/heads/main",
|
||||
ref_type: "branch",
|
||||
actor: "onur",
|
||||
actor_id: "42",
|
||||
run_id: "100",
|
||||
run_attempt: "2",
|
||||
iss: "https://token.actions.githubusercontent.com",
|
||||
aud: "clawhub",
|
||||
exp: Math.floor(now / 1000) + 300,
|
||||
iat: Math.floor(now / 1000) - 5,
|
||||
},
|
||||
"rotated-key",
|
||||
);
|
||||
const staleJwk = { ...jwks, kid: "stale-key" };
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ keys: [staleJwk] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ keys: [jwks] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
verifyGitHubActionsTrustedPublishJwt(token, trustedPublisher, {
|
||||
fetchImpl: fetchMock,
|
||||
now: () => now,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
repository: trustedPublisher.repository,
|
||||
workflowFilename: trustedPublisher.workflowFilename,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
async function createSignedToken(payload: Record<string, unknown>, kid = "test-key") {
|
||||
const keyPair = await signingKeyPairPromise;
|
||||
const header = { alg: "RS256", kid, typ: "JWT" };
|
||||
const encodedHeader = base64UrlEncodeJson(header);
|
||||
const encodedPayload = base64UrlEncodeJson(payload);
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = new Uint8Array(
|
||||
await crypto.subtle.sign(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
keyPair.privateKey,
|
||||
new TextEncoder().encode(signingInput),
|
||||
),
|
||||
);
|
||||
const publicJwk = (await crypto.subtle.exportKey("jwk", keyPair.publicKey)) as JsonWebKey & {
|
||||
kid?: string;
|
||||
};
|
||||
publicJwk.kid = kid;
|
||||
return {
|
||||
token: `${signingInput}.${base64UrlEncodeBytes(signature)}`,
|
||||
jwks: publicJwk,
|
||||
};
|
||||
}
|
||||
|
||||
function base64UrlEncodeJson(value: unknown) {
|
||||
return base64UrlEncodeBytes(new TextEncoder().encode(JSON.stringify(value)));
|
||||
}
|
||||
|
||||
function base64UrlEncodeBytes(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
type JwtHeader = {
|
||||
alg?: unknown;
|
||||
kid?: unknown;
|
||||
typ?: unknown;
|
||||
};
|
||||
|
||||
type JwtPayload = Record<string, unknown>;
|
||||
|
||||
type JwkSet = {
|
||||
keys?: Array<JsonWebKey & { kid?: string; alg?: string; use?: string; kty?: string }>;
|
||||
};
|
||||
|
||||
export type TrustedGitHubActionsPublisher = {
|
||||
repository: string;
|
||||
repositoryId: string;
|
||||
repositoryOwner: string;
|
||||
repositoryOwnerId: string;
|
||||
workflowFilename: string;
|
||||
environment?: string;
|
||||
};
|
||||
|
||||
export type VerifiedGitHubActionsIdentity = {
|
||||
repository: string;
|
||||
repositoryId: string;
|
||||
repositoryOwner: string;
|
||||
repositoryOwnerId: string;
|
||||
workflowFilename: string;
|
||||
workflowName: string;
|
||||
workflowRef: string;
|
||||
jobWorkflowRef?: string;
|
||||
environment?: string;
|
||||
runnerEnvironment: string;
|
||||
eventName: string;
|
||||
sha: string;
|
||||
ref: string;
|
||||
refType?: string;
|
||||
actor?: string;
|
||||
actorId?: string;
|
||||
runId: string;
|
||||
runAttempt: string;
|
||||
};
|
||||
|
||||
type VerifyGitHubActionsOidcOptions = {
|
||||
fetchImpl?: typeof fetch;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
type GitHubRepositoryIdentity = {
|
||||
repository: string;
|
||||
repositoryId: string;
|
||||
repositoryOwner: string;
|
||||
repositoryOwnerId: string;
|
||||
};
|
||||
|
||||
type ParsedWorkflowRef = {
|
||||
repository: string;
|
||||
workflowFilename: string;
|
||||
};
|
||||
|
||||
const GITHUB_ACTIONS_ISSUER = "https://token.actions.githubusercontent.com";
|
||||
const GITHUB_ACTIONS_JWKS_URL = `${GITHUB_ACTIONS_ISSUER}/.well-known/jwks`;
|
||||
const TRUSTED_AUDIENCE = "clawhub";
|
||||
const CLOCK_SKEW_MS = 60_000;
|
||||
const JWKS_CACHE_TTL_MS = 5 * 60_000;
|
||||
const OFFICIAL_REUSABLE_WORKFLOW_REPOSITORY = "openclaw/clawhub";
|
||||
const OFFICIAL_REUSABLE_WORKFLOW_FILENAME = "package-publish.yml";
|
||||
|
||||
let cachedJwks: { value: JwkSet; fetchedAt: number } | null = null;
|
||||
|
||||
export async function verifyGitHubActionsTrustedPublishJwt(
|
||||
jwt: string,
|
||||
trustedPublisher: TrustedGitHubActionsPublisher,
|
||||
options: VerifyGitHubActionsOidcOptions = {},
|
||||
): Promise<VerifiedGitHubActionsIdentity> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const now = (options.now ?? Date.now)();
|
||||
const { signingInput, signature, header, payload } = decodeJwt(jwt);
|
||||
|
||||
if (header.alg !== "RS256") {
|
||||
throw new Error(
|
||||
`Unsupported GitHub OIDC signing algorithm: ${formatClaimValue(header.alg ?? "<missing>")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const keyId = requireString(header.kid, "kid");
|
||||
let jwks = await fetchGitHubActionsJwks(fetchImpl, now);
|
||||
let jwk = jwks.keys?.find((entry) => entry.kid === keyId);
|
||||
if (!jwk) {
|
||||
jwks = await fetchGitHubActionsJwks(fetchImpl, now, true);
|
||||
jwk = jwks.keys?.find((entry) => entry.kid === keyId);
|
||||
}
|
||||
if (!jwk) throw new Error(`Unknown GitHub OIDC signing key: ${keyId}`);
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
jwk,
|
||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
const verified = await crypto.subtle.verify(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
key,
|
||||
signature,
|
||||
new TextEncoder().encode(signingInput),
|
||||
);
|
||||
if (!verified) throw new Error("Invalid GitHub OIDC signature");
|
||||
|
||||
const issuer = requireString(payload.iss, "iss");
|
||||
if (issuer !== GITHUB_ACTIONS_ISSUER) {
|
||||
throw new Error(`Unexpected GitHub OIDC issuer: ${issuer}`);
|
||||
}
|
||||
if (!claimContainsAudience(payload.aud, TRUSTED_AUDIENCE)) {
|
||||
throw new Error(`Unexpected GitHub OIDC audience: ${formatAudience(payload.aud)}`);
|
||||
}
|
||||
assertTokenTimeWindow(payload, now);
|
||||
|
||||
const repository = requireString(payload.repository, "repository");
|
||||
const repositoryId = requireClaimString(payload.repository_id, "repository_id");
|
||||
const repositoryOwner = requireString(payload.repository_owner, "repository_owner");
|
||||
const repositoryOwnerId = requireClaimString(payload.repository_owner_id, "repository_owner_id");
|
||||
const workflowRef = requireString(payload.workflow_ref, "workflow_ref");
|
||||
const workflow = parseWorkflowRef(workflowRef, repository);
|
||||
const jobWorkflowRef = optionalString(payload.job_workflow_ref);
|
||||
const runnerEnvironment = requireString(payload.runner_environment, "runner_environment");
|
||||
const environment = optionalString(payload.environment);
|
||||
const eventName = requireString(payload.event_name, "event_name");
|
||||
const workflowName = requireString(payload.workflow, "workflow");
|
||||
const sha = requireString(payload.sha, "sha");
|
||||
const ref = requireString(payload.ref, "ref");
|
||||
const runId = requireClaimString(payload.run_id, "run_id");
|
||||
const runAttempt = requireClaimString(payload.run_attempt, "run_attempt");
|
||||
const refType = optionalString(payload.ref_type);
|
||||
const actor = optionalString(payload.actor);
|
||||
const actorId = optionalStringValue(payload.actor_id);
|
||||
|
||||
if (repository !== trustedPublisher.repository) {
|
||||
throw new Error(
|
||||
`GitHub OIDC repository mismatch: expected ${trustedPublisher.repository}, got ${repository}`,
|
||||
);
|
||||
}
|
||||
if (repositoryId !== trustedPublisher.repositoryId) {
|
||||
throw new Error(
|
||||
`GitHub OIDC repository_id mismatch: expected ${trustedPublisher.repositoryId}, got ${repositoryId}`,
|
||||
);
|
||||
}
|
||||
if (repositoryOwner !== trustedPublisher.repositoryOwner) {
|
||||
throw new Error(
|
||||
`GitHub OIDC repository_owner mismatch: expected ${trustedPublisher.repositoryOwner}, got ${repositoryOwner}`,
|
||||
);
|
||||
}
|
||||
if (repositoryOwnerId !== trustedPublisher.repositoryOwnerId) {
|
||||
throw new Error(
|
||||
`GitHub OIDC repository_owner_id mismatch: expected ${trustedPublisher.repositoryOwnerId}, got ${repositoryOwnerId}`,
|
||||
);
|
||||
}
|
||||
if (workflow.workflowFilename !== trustedPublisher.workflowFilename) {
|
||||
throw new Error(
|
||||
`GitHub OIDC workflow mismatch: expected ${trustedPublisher.workflowFilename}, got ${workflow.workflowFilename}`,
|
||||
);
|
||||
}
|
||||
if (jobWorkflowRef) {
|
||||
const reusableWorkflow = parseWorkflowRef(jobWorkflowRef);
|
||||
const usesOfficialReusableWorkflow =
|
||||
reusableWorkflow.repository === OFFICIAL_REUSABLE_WORKFLOW_REPOSITORY &&
|
||||
reusableWorkflow.workflowFilename === OFFICIAL_REUSABLE_WORKFLOW_FILENAME;
|
||||
if (!usesOfficialReusableWorkflow) {
|
||||
throw new Error(
|
||||
"Only the official ClawHub reusable workflow is supported for trusted publishing",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (runnerEnvironment !== "github-hosted") {
|
||||
throw new Error(
|
||||
`Only GitHub-hosted runners may mint trusted publish tokens, got ${runnerEnvironment}`,
|
||||
);
|
||||
}
|
||||
// v1 keeps secretless publishing behind a manual entry point. Environment
|
||||
// pinning is optional, but if configured it must match exactly.
|
||||
if (eventName !== "workflow_dispatch") {
|
||||
throw new Error(`Trusted publishing requires workflow_dispatch, got ${eventName}`);
|
||||
}
|
||||
if (trustedPublisher.environment && environment !== trustedPublisher.environment) {
|
||||
throw new Error(
|
||||
`GitHub OIDC environment mismatch: expected ${trustedPublisher.environment}, got ${formatClaimValue(environment ?? "<missing>")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
repository,
|
||||
repositoryId,
|
||||
repositoryOwner,
|
||||
repositoryOwnerId,
|
||||
workflowFilename: workflow.workflowFilename,
|
||||
workflowName,
|
||||
workflowRef,
|
||||
...(jobWorkflowRef ? { jobWorkflowRef } : {}),
|
||||
...(environment ? { environment } : {}),
|
||||
runnerEnvironment,
|
||||
eventName,
|
||||
sha,
|
||||
ref,
|
||||
...(refType ? { refType } : {}),
|
||||
...(actor ? { actor } : {}),
|
||||
...(actorId ? { actorId } : {}),
|
||||
runId,
|
||||
runAttempt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGitHubRepositoryIdentity(
|
||||
repository: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<GitHubRepositoryIdentity> {
|
||||
const normalizedRepository = normalizeGitHubRepository(repository);
|
||||
if (!normalizedRepository) {
|
||||
throw new Error(`Invalid GitHub repository: ${repository}`);
|
||||
}
|
||||
const response = await fetchImpl(`https://api.github.com/repos/${normalizedRepository}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "clawhub/package-trusted-publisher",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub repository lookup failed for ${normalizedRepository}: ${response.status}`,
|
||||
);
|
||||
}
|
||||
const body = (await response.json()) as {
|
||||
id?: unknown;
|
||||
full_name?: unknown;
|
||||
owner?: { login?: unknown; id?: unknown };
|
||||
};
|
||||
const resolvedRepository = requireString(body.full_name, "full_name");
|
||||
const ownerLogin = requireString(body.owner?.login, "owner.login");
|
||||
return {
|
||||
repository: resolvedRepository,
|
||||
repositoryId: requireClaimString(body.id, "id"),
|
||||
repositoryOwner: ownerLogin,
|
||||
repositoryOwnerId: requireClaimString(body.owner?.id, "owner.id"),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeGitHubRepository(repository: string) {
|
||||
const trimmed = repository
|
||||
.trim()
|
||||
.replace(/^https?:\/\/github\.com\//i, "")
|
||||
.replace(/\.git$/i, "");
|
||||
const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(trimmed);
|
||||
if (!match) return null;
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
export function extractWorkflowFilenameFromWorkflowRef(
|
||||
workflowRef: string,
|
||||
expectedRepository?: string,
|
||||
) {
|
||||
return parseWorkflowRef(workflowRef, expectedRepository).workflowFilename;
|
||||
}
|
||||
|
||||
function parseWorkflowRef(workflowRef: string, expectedRepository?: string): ParsedWorkflowRef {
|
||||
const match = /^([^/]+\/[^/]+)\/\.github\/workflows\/([^@/]+)@.+$/.exec(workflowRef.trim());
|
||||
if (!match?.[1] || !match[2]) {
|
||||
throw new Error(`Invalid GitHub workflow_ref claim: ${workflowRef}`);
|
||||
}
|
||||
if (expectedRepository && match[1] !== expectedRepository) {
|
||||
throw new Error(
|
||||
`GitHub workflow_ref repository mismatch: expected ${expectedRepository}, got ${match[1]}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
repository: match[1],
|
||||
workflowFilename: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
function decodeJwt(jwt: string) {
|
||||
const parts = jwt.trim().split(".");
|
||||
if (parts.length !== 3) throw new Error("Invalid GitHub OIDC token format");
|
||||
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
||||
const header = parseJsonSegment<JwtHeader>(encodedHeader, "header");
|
||||
const payload = parseJsonSegment<JwtPayload>(encodedPayload, "payload");
|
||||
return {
|
||||
header,
|
||||
payload,
|
||||
signingInput: `${encodedHeader}.${encodedPayload}`,
|
||||
signature: base64UrlToBytes(encodedSignature),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonSegment<T>(segment: string, label: string) {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment))) as T;
|
||||
} catch {
|
||||
throw new Error(`Invalid GitHub OIDC ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value: string) {
|
||||
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function fetchGitHubActionsJwks(fetchImpl: typeof fetch, now: number, forceRefresh = false) {
|
||||
if (!forceRefresh && cachedJwks && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
|
||||
return cachedJwks.value;
|
||||
}
|
||||
const response = await fetchImpl(GITHUB_ACTIONS_JWKS_URL, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "clawhub/github-actions-oidc",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch GitHub OIDC JWKS: ${response.status}`);
|
||||
}
|
||||
const jwks = (await response.json()) as JwkSet;
|
||||
cachedJwks = { value: jwks, fetchedAt: now };
|
||||
return jwks;
|
||||
}
|
||||
|
||||
function claimContainsAudience(audience: unknown, expected: string) {
|
||||
if (typeof audience === "string") return audience === expected;
|
||||
if (!Array.isArray(audience)) return false;
|
||||
return audience.includes(expected);
|
||||
}
|
||||
|
||||
function formatAudience(audience: unknown) {
|
||||
if (typeof audience === "string") return audience;
|
||||
if (Array.isArray(audience)) return audience.join(", ");
|
||||
return formatClaimValue(audience ?? "<missing>");
|
||||
}
|
||||
|
||||
function formatClaimValue(value: unknown) {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function assertTokenTimeWindow(payload: JwtPayload, now: number) {
|
||||
const expiresAt = requireNumericClaim(payload.exp, "exp") * 1000;
|
||||
if (now - CLOCK_SKEW_MS >= expiresAt) {
|
||||
throw new Error("GitHub OIDC token has expired");
|
||||
}
|
||||
const notBefore =
|
||||
payload.nbf === undefined ? undefined : requireNumericClaim(payload.nbf, "nbf") * 1000;
|
||||
if (typeof notBefore === "number" && now + CLOCK_SKEW_MS < notBefore) {
|
||||
throw new Error("GitHub OIDC token is not active yet");
|
||||
}
|
||||
}
|
||||
|
||||
function requireClaimString(value: unknown, label: string) {
|
||||
const normalized = optionalStringValue(value);
|
||||
if (!normalized) throw new Error(`Missing GitHub OIDC claim: ${label}`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, label: string) {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`Missing GitHub OIDC claim: ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function optionalStringValue(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
return optionalString(value);
|
||||
}
|
||||
|
||||
function requireNumericClaim(value: unknown, label: string) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
throw new Error(`Missing GitHub OIDC claim: ${label}`);
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
base64UrlToBytes,
|
||||
claimContainsAudience,
|
||||
};
|
||||
@@ -92,6 +92,173 @@ describe("moderationEngine", () => {
|
||||
expect(result.status).toBe("suspicious");
|
||||
});
|
||||
|
||||
it("flags raw user placeholders embedded in generated Python source within markdown", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "word-document-organizer",
|
||||
displayName: "Word Document Organizer",
|
||||
summary: "Organize and restyle Word documents",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 512 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Generate a Python helper like this:",
|
||||
"```python",
|
||||
'doc_path = "${document_path}"',
|
||||
'output_path = "${output_path}" if "${output_path}" else doc_path',
|
||||
'template = "${style_template}"',
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.generated_source_template_injection");
|
||||
expect(result.status).toBe("suspicious");
|
||||
});
|
||||
|
||||
it("does not flag ordinary placeholder usage outside generated source assignments", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-docs",
|
||||
displayName: "API Docs",
|
||||
summary: "Shows users how to call an API",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Use this request template:",
|
||||
"```bash",
|
||||
'curl "https://example.com/search?q=${query}"',
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).not.toContain("suspicious.generated_source_template_injection");
|
||||
expect(result.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("flags hardcoded connection_id UUIDs in markdown examples", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Use this payload:",
|
||||
"```json",
|
||||
'{"connection_id": "21fd90f9-5935-43cd-b6c8-bde9d915ca80"}',
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
|
||||
expect(result.status).toBe("suspicious");
|
||||
expect(
|
||||
result.findings.find((finding) => finding.message.includes("connection_id"))?.message,
|
||||
).toContain("connection_id");
|
||||
});
|
||||
|
||||
it("flags hardcoded Google Sheets spreadsheet IDs in markdown examples", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Call the Sheets bridge like this:",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/122BS1sFN2RKL8AOUQjkLdubzOwgqzPT64KfZ2rvYI4M/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
|
||||
expect(result.status).toBe("suspicious");
|
||||
expect(
|
||||
result.findings.find((finding) => finding.message.includes("spreadsheet ID"))?.message,
|
||||
).toContain("spreadsheet ID");
|
||||
});
|
||||
|
||||
it("does not flag placeholder resource identifiers in markdown examples", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 256 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Use placeholders in public docs:",
|
||||
"```json",
|
||||
'{"connection_id": "YOUR_CONNECTION_ID"}',
|
||||
"```",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/YOUR_SPREADSHEET_ID/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).not.toContain("suspicious.exposed_resource_identifier");
|
||||
expect(result.status).toBe("clean");
|
||||
});
|
||||
|
||||
it("flags a real spreadsheet ID even when a placeholder URL appears first", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "api-gateway",
|
||||
displayName: "API Gateway",
|
||||
summary: "Route API calls through an authenticated gateway",
|
||||
frontmatter: {},
|
||||
metadata: {},
|
||||
files: [{ path: "SKILL.md", size: 512 }],
|
||||
fileContents: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
content: [
|
||||
"Placeholder example first:",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/YOUR_SPREADSHEET_ID/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
"Real leaked URL later:",
|
||||
"```python",
|
||||
"req = urllib.request.Request('https://gateway.maton.ai/google-sheets/v4/spreadsheets/122BS1sFN2RKL8AOUQjkLdubzOwgqzPT64KfZ2rvYI4M/values/Sheet1!A1:B2')",
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.reasonCodes).toContain("suspicious.exposed_resource_identifier");
|
||||
expect(
|
||||
result.findings.find((finding) => finding.message.includes("spreadsheet ID"))?.line,
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it("blocks obfuscated terminal install payload prompts in markdown", () => {
|
||||
const result = runStaticModerationScan({
|
||||
slug: "evil-installer",
|
||||
|
||||
@@ -50,6 +50,14 @@ const CODE_EXTENSION = /\.(js|ts|mjs|cjs|mts|cts|jsx|tsx|py|sh|bash|zsh|rb|go)$/
|
||||
const STANDARD_PORTS = new Set([80, 443, 8080, 8443, 3000]);
|
||||
const RAW_IP_URL_PATTERN = /https?:\/\/\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?(?:\/|["'])/i;
|
||||
const INSTALL_PACKAGE_PATTERN = /installer-package\s*:\s*https?:\/\/[^\s"'`]+/i;
|
||||
const GENERATED_SOURCE_PLACEHOLDER_PATTERN =
|
||||
/^\s*[A-Za-z_][A-Za-z0-9_]*\s*=.*["']\$\{[A-Za-z_][A-Za-z0-9_-]*\}["']/m;
|
||||
const GENERATED_SOURCE_CONTEXT_PATTERN =
|
||||
/```(?:python|py|javascript|js|typescript|ts|shell|bash|sh)\b|cat\s*(?:>|>>)?\s*[^`\n]*\.(?:py|js|ts|sh)\b|python3?\b|node\b/i;
|
||||
const HARDCODED_CONNECTION_ID_PATTERN =
|
||||
/["']connection_id["']\s*:\s*["'][0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}["']/i;
|
||||
const GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN =
|
||||
/https?:\/\/[^\s"'`]*\/spreadsheets\/([A-Za-z0-9_-]{20,})\/[^\s"'`]*/i;
|
||||
|
||||
function hasMaliciousInstallPrompt(content: string) {
|
||||
const hasTerminalInstruction =
|
||||
@@ -75,6 +83,10 @@ function truncateEvidence(evidence: string, maxLen = 160) {
|
||||
return `${evidence.slice(0, maxLen)}...`;
|
||||
}
|
||||
|
||||
function looksLikePlaceholderIdentifier(identifier: string) {
|
||||
return /^[A-Z0-9_]+$/.test(identifier) || /(your|example|placeholder)/i.test(identifier);
|
||||
}
|
||||
|
||||
function addFinding(
|
||||
findings: ModerationFinding[],
|
||||
finding: Omit<ModerationFinding, "evidence"> & { evidence: string },
|
||||
@@ -92,6 +104,14 @@ function findFirstLine(content: string, pattern: RegExp) {
|
||||
return { line: 1, text: lines[0] ?? "" };
|
||||
}
|
||||
|
||||
function findLineAtIndex(content: string, index: number) {
|
||||
const line = content.slice(0, index).split("\n").length;
|
||||
const lineStart = content.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
|
||||
const nextNewline = content.indexOf("\n", index);
|
||||
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
|
||||
return { line, text: content.slice(lineStart, lineEnd) };
|
||||
}
|
||||
|
||||
function scanCodeFile(path: string, content: string, findings: ModerationFinding[]) {
|
||||
if (!CODE_EXTENSION.test(path)) return;
|
||||
|
||||
@@ -227,6 +247,53 @@ function scanMarkdownFile(path: string, content: string, findings: ModerationFin
|
||||
evidence: match.text,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
GENERATED_SOURCE_PLACEHOLDER_PATTERN.test(content) &&
|
||||
GENERATED_SOURCE_CONTEXT_PATTERN.test(content)
|
||||
) {
|
||||
const match = findFirstLine(content, GENERATED_SOURCE_PLACEHOLDER_PATTERN);
|
||||
addFinding(findings, {
|
||||
code: REASON_CODES.GENERATED_SOURCE_TEMPLATE,
|
||||
severity: "critical",
|
||||
file: path,
|
||||
line: match.line,
|
||||
message: "User-controlled placeholder is embedded directly into generated source code.",
|
||||
evidence: match.text,
|
||||
});
|
||||
}
|
||||
|
||||
if (HARDCODED_CONNECTION_ID_PATTERN.test(content)) {
|
||||
const match = findFirstLine(content, HARDCODED_CONNECTION_ID_PATTERN);
|
||||
addFinding(findings, {
|
||||
code: REASON_CODES.EXPOSED_RESOURCE_IDENTIFIER,
|
||||
severity: "critical",
|
||||
file: path,
|
||||
line: match.line,
|
||||
message: "Example code exposes a concrete connection_id instead of a placeholder.",
|
||||
evidence: match.text,
|
||||
});
|
||||
}
|
||||
|
||||
const spreadsheetUrlPattern = new RegExp(
|
||||
GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN.source,
|
||||
`${GOOGLE_SHEETS_SPREADSHEET_URL_PATTERN.flags.replaceAll("g", "")}g`,
|
||||
);
|
||||
for (const spreadsheetUrlMatch of content.matchAll(spreadsheetUrlPattern)) {
|
||||
const spreadsheetId = spreadsheetUrlMatch[1];
|
||||
if (!spreadsheetId || looksLikePlaceholderIdentifier(spreadsheetId)) continue;
|
||||
|
||||
const match = findLineAtIndex(content, spreadsheetUrlMatch.index ?? 0);
|
||||
addFinding(findings, {
|
||||
code: REASON_CODES.EXPOSED_RESOURCE_IDENTIFIER,
|
||||
severity: "critical",
|
||||
file: path,
|
||||
line: match.line,
|
||||
message: "Example code exposes a concrete Google Sheets spreadsheet ID instead of a placeholder.",
|
||||
evidence: match.text,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function scanManifestFile(path: string, content: string, findings: ModerationFinding[]) {
|
||||
|
||||
@@ -12,11 +12,13 @@ export type ModerationFinding = {
|
||||
evidence: string;
|
||||
};
|
||||
|
||||
export const MODERATION_ENGINE_VERSION = "v2.2.0";
|
||||
export const MODERATION_ENGINE_VERSION = "v2.4.0";
|
||||
|
||||
export const REASON_CODES = {
|
||||
DANGEROUS_EXEC: "suspicious.dangerous_exec",
|
||||
DYNAMIC_CODE: "suspicious.dynamic_code_execution",
|
||||
GENERATED_SOURCE_TEMPLATE: "suspicious.generated_source_template_injection",
|
||||
EXPOSED_RESOURCE_IDENTIFIER: "suspicious.exposed_resource_identifier",
|
||||
CREDENTIAL_HARVEST: "suspicious.env_credential_access",
|
||||
EXFILTRATION: "suspicious.potential_exfiltration",
|
||||
OBFUSCATED_CODE: "suspicious.obfuscated_code",
|
||||
|
||||
@@ -139,9 +139,9 @@ describe("packageRegistry", () => {
|
||||
|
||||
it("validates package name consistency and summary extraction", () => {
|
||||
ensurePluginNameMatchesPackage("demo-plugin", { name: "demo-plugin" });
|
||||
expect(() =>
|
||||
ensurePluginNameMatchesPackage("demo-plugin", { name: "other-plugin" }),
|
||||
).toThrow("must match published package name");
|
||||
expect(() => ensurePluginNameMatchesPackage("demo-plugin", { name: "other-plugin" })).toThrow(
|
||||
"must match published package name",
|
||||
);
|
||||
|
||||
expect(
|
||||
summarizePackageForSearch({
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
listMissingOpenClawExternalCodePluginFieldPaths,
|
||||
normalizeOpenClawExternalPluginCompatibility,
|
||||
} from "clawhub-schema";
|
||||
import type {
|
||||
BundlePublishMetadata,
|
||||
PackageCapabilitySummary,
|
||||
@@ -84,7 +88,10 @@ export function normalizePublishFiles(files: PublishFile[]) {
|
||||
return normalized.map((file) => ({ ...file, path: file.path as string }));
|
||||
}
|
||||
|
||||
export function assertPackageVersion(family: "code-plugin" | "bundle-plugin" | "skill", version: string) {
|
||||
export function assertPackageVersion(
|
||||
family: "code-plugin" | "bundle-plugin" | "skill",
|
||||
version: string,
|
||||
) {
|
||||
const trimmed = version.trim();
|
||||
if (!trimmed) throw new ConvexError("Version required");
|
||||
if (family === "code-plugin" && !semver.valid(trimmed)) {
|
||||
@@ -125,9 +132,15 @@ function parseJsonFile(text: string, label: string): JsonRecord {
|
||||
}
|
||||
}
|
||||
|
||||
function deriveSummary(params: { packageName: string; packageJson?: JsonRecord; readmeText?: string | null }) {
|
||||
function deriveSummary(params: {
|
||||
packageName: string;
|
||||
packageJson?: JsonRecord;
|
||||
readmeText?: string | null;
|
||||
}) {
|
||||
const directDescription =
|
||||
typeof params.packageJson?.description === "string" ? params.packageJson.description.trim() : "";
|
||||
typeof params.packageJson?.description === "string"
|
||||
? params.packageJson.description.trim()
|
||||
: "";
|
||||
if (directDescription) return directDescription;
|
||||
const readme = params.readmeText?.trim() ?? "";
|
||||
if (!readme) return params.packageName;
|
||||
@@ -165,41 +178,10 @@ function buildVerification(source: SourceInfo | undefined): PackageVerificationS
|
||||
};
|
||||
}
|
||||
|
||||
function extractOpenClawBlock(packageJson: JsonRecord | undefined) {
|
||||
if (!packageJson) return {};
|
||||
const openclaw = isRecord(packageJson.openclaw) ? packageJson.openclaw : undefined;
|
||||
return {
|
||||
openclaw,
|
||||
compat: isRecord(openclaw?.compat) ? openclaw.compat : undefined,
|
||||
build: isRecord(openclaw?.build) ? openclaw.build : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function extractCompatibility(packageJson: JsonRecord | undefined): PackageCompatibility | undefined {
|
||||
const { openclaw, compat, build } = extractOpenClawBlock(packageJson);
|
||||
const install = isRecord(openclaw?.install) ? openclaw.install : undefined;
|
||||
const version =
|
||||
typeof packageJson?.version === "string" ? packageJson.version.trim() : undefined;
|
||||
const minHostVersion =
|
||||
typeof install?.minHostVersion === "string" ? install.minHostVersion.trim() : undefined;
|
||||
const compatibility: PackageCompatibility = {};
|
||||
if (typeof compat?.pluginApi === "string") {
|
||||
compatibility.pluginApiRange = compat.pluginApi.trim();
|
||||
}
|
||||
if (typeof compat?.minGatewayVersion === "string") {
|
||||
compatibility.minGatewayVersion = compat.minGatewayVersion.trim();
|
||||
} else if (minHostVersion) {
|
||||
compatibility.minGatewayVersion = minHostVersion;
|
||||
}
|
||||
if (typeof build?.openclawVersion === "string") {
|
||||
compatibility.builtWithOpenClawVersion = build.openclawVersion.trim();
|
||||
} else if (version) {
|
||||
compatibility.builtWithOpenClawVersion = version;
|
||||
}
|
||||
if (typeof build?.pluginSdkVersion === "string") {
|
||||
compatibility.pluginSdkVersion = build.pluginSdkVersion.trim();
|
||||
}
|
||||
return Object.keys(compatibility).length > 0 ? compatibility : undefined;
|
||||
function extractCompatibility(
|
||||
packageJson: JsonRecord | undefined,
|
||||
): PackageCompatibility | undefined {
|
||||
return normalizeOpenClawExternalPluginCompatibility(packageJson);
|
||||
}
|
||||
|
||||
export function extractCodePluginArtifacts(params: {
|
||||
@@ -212,7 +194,7 @@ export function extractCodePluginArtifacts(params: {
|
||||
throw new ConvexError("Code plugins must include source repo and commit metadata");
|
||||
}
|
||||
|
||||
const { openclaw } = extractOpenClawBlock(params.packageJson);
|
||||
const openclaw = isRecord(params.packageJson.openclaw) ? params.packageJson.openclaw : undefined;
|
||||
const extensions = normalizeStringList(openclaw?.extensions);
|
||||
if (extensions.length === 0) {
|
||||
throw new ConvexError("package.json must declare openclaw.extensions");
|
||||
@@ -223,11 +205,9 @@ export function extractCodePluginArtifacts(params: {
|
||||
if (!runtimeId) throw new ConvexError("openclaw.plugin.json must declare an id");
|
||||
|
||||
const compatibility = extractCompatibility(params.packageJson);
|
||||
if (!compatibility?.pluginApiRange) {
|
||||
throw new ConvexError("package.json openclaw.compat.pluginApi is required");
|
||||
}
|
||||
if (!compatibility.builtWithOpenClawVersion) {
|
||||
throw new ConvexError("package.json openclaw.build.openclawVersion is required");
|
||||
const missingOpenClawFields = listMissingOpenClawExternalCodePluginFieldPaths(params.packageJson);
|
||||
if (missingOpenClawFields.length > 0) {
|
||||
throw new ConvexError(`package.json ${missingOpenClawFields[0]} is required`);
|
||||
}
|
||||
|
||||
const channels = uniq([
|
||||
@@ -269,7 +249,9 @@ export function extractCodePluginArtifacts(params: {
|
||||
executesCode: true,
|
||||
runtimeId,
|
||||
pluginKind:
|
||||
typeof params.pluginManifest.kind === "string" ? params.pluginManifest.kind.trim() : undefined,
|
||||
typeof params.pluginManifest.kind === "string"
|
||||
? params.pluginManifest.kind.trim()
|
||||
: undefined,
|
||||
channels,
|
||||
providers,
|
||||
hooks,
|
||||
@@ -311,7 +293,7 @@ export function extractBundlePluginArtifacts(params: {
|
||||
bundleMetadata?: BundlePublishMetadata;
|
||||
source?: SourceInfo;
|
||||
}) {
|
||||
const { openclaw } = extractOpenClawBlock(params.packageJson);
|
||||
const openclaw = isRecord(params.packageJson?.openclaw) ? params.packageJson.openclaw : undefined;
|
||||
const manifest = params.bundleManifest;
|
||||
const runtimeId =
|
||||
(typeof manifest?.id === "string" && manifest.id.trim()) ||
|
||||
@@ -365,7 +347,9 @@ export function ensurePluginNameMatchesPackage(packageName: string, packageJson:
|
||||
const normalizedDeclared = normalizePackageName(declaredName);
|
||||
const normalizedExpected = normalizePackageName(packageName);
|
||||
if (normalizedDeclared !== normalizedExpected) {
|
||||
throw new ConvexError(`package.json name must match published package name (${normalizedExpected})`);
|
||||
throw new ConvexError(
|
||||
`package.json name must match published package name (${normalizedExpected})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys
|
||||
return Object.fromEntries(keys.map((key) => [key, obj[key]])) as Pick<T, K>;
|
||||
}
|
||||
|
||||
type SharedPackageKey = Extract<keyof Doc<"packages">, keyof Doc<"packageSearchDigest">>;
|
||||
|
||||
const SHARED_KEYS = [
|
||||
"name",
|
||||
"normalizedName",
|
||||
@@ -22,7 +24,7 @@ const SHARED_KEYS = [
|
||||
"softDeletedAt",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
] as const satisfies readonly (keyof Doc<"packages"> & keyof Doc<"packageSearchDigest">)[];
|
||||
] as const satisfies readonly SharedPackageKey[];
|
||||
|
||||
const CAPABILITY_SHARED_KEYS = [
|
||||
"packageId",
|
||||
@@ -145,10 +147,7 @@ export async function deletePackageSearchDigests(
|
||||
function hasDigestChanged<
|
||||
TExisting extends Record<string, unknown>,
|
||||
TFields extends Record<string, unknown>,
|
||||
>(
|
||||
existing: TExisting,
|
||||
fields: TFields,
|
||||
): boolean {
|
||||
>(existing: TExisting, fields: TFields): boolean {
|
||||
for (const key of Object.keys(fields)) {
|
||||
const oldValue = (existing as Record<string, unknown>)[key];
|
||||
const newValue = (fields as Record<string, unknown>)[key];
|
||||
|
||||
@@ -38,4 +38,13 @@ describe("packageSecurity", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats suspicious static scans as suspicious even when verification is clean", () => {
|
||||
expect(
|
||||
resolvePackageReleaseScanStatus({
|
||||
staticScan: { status: "suspicious" },
|
||||
verification: { scanStatus: "clean" },
|
||||
} as never),
|
||||
).toBe("suspicious");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,12 +25,15 @@ export function resolvePackageReleaseScanStatus(
|
||||
): Exclude<PackageScanStatus, undefined> {
|
||||
const staticStatus = normalizePackageScanStatus(release.staticScan?.status);
|
||||
if (staticStatus === "malicious") return "malicious";
|
||||
if (staticStatus === "suspicious") return "suspicious";
|
||||
|
||||
const vtStatus = normalizePackageScanStatus(release.vtAnalysis?.status);
|
||||
if (vtStatus === "malicious") return "malicious";
|
||||
if (vtStatus === "suspicious") return "suspicious";
|
||||
|
||||
const verificationStatus = normalizePackageScanStatus(release.verification?.scanStatus);
|
||||
if (verificationStatus === "malicious") return "malicious";
|
||||
if (verificationStatus === "suspicious") return "suspicious";
|
||||
|
||||
if (vtStatus) return vtStatus;
|
||||
if (verificationStatus && verificationStatus !== "not-run") return verificationStatus;
|
||||
@@ -49,7 +52,8 @@ export function getPackageDownloadSecurityBlock(release: PackageReleaseSecurityL
|
||||
if (scanStatus === "malicious") {
|
||||
return {
|
||||
status: 403,
|
||||
message: "Blocked: this package release has been flagged as malicious and cannot be downloaded.",
|
||||
message:
|
||||
"Blocked: this package release has been flagged as malicious and cannot be downloaded.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export type PublicSkill = Pick<
|
||||
| "forkOf"
|
||||
| "latestVersionId"
|
||||
| "tags"
|
||||
| "capabilityTags"
|
||||
| "badges"
|
||||
| "stats"
|
||||
| "createdAt"
|
||||
@@ -50,6 +51,7 @@ export type HydratableSkill = Pick<
|
||||
| "latestVersionId"
|
||||
| "latestVersionSummary"
|
||||
| "tags"
|
||||
| "capabilityTags"
|
||||
| "badges"
|
||||
| "stats"
|
||||
| "statsDownloads"
|
||||
@@ -141,6 +143,7 @@ export function toPublicSkill(skill: HydratableSkill | null | undefined): Public
|
||||
forkOf: skill.forkOf,
|
||||
latestVersionId: skill.latestVersionId,
|
||||
tags: skill.tags,
|
||||
capabilityTags: skill.capabilityTags,
|
||||
badges: skill.badges,
|
||||
stats,
|
||||
createdAt: skill.createdAt,
|
||||
|
||||
+45
-18
@@ -18,8 +18,7 @@ function derivePersonalPublisherHandle(user: Doc<"users">) {
|
||||
const emailLocalPart = user.email?.split("@")[0];
|
||||
const userIdSuffix = String(user._id).split(":").pop();
|
||||
return (
|
||||
normalizePublisherHandle(user.handle ?? user.name ?? emailLocalPart ?? userIdSuffix) ??
|
||||
"user"
|
||||
normalizePublisherHandle(user.handle ?? user.name ?? emailLocalPart ?? userIdSuffix) ?? "user"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +26,8 @@ function synthesizePersonalPublisher(user: Doc<"users">): Doc<"publishers"> {
|
||||
const handle = derivePersonalPublisherHandle(user);
|
||||
const now = user.updatedAt ?? user.createdAt ?? user._creationTime;
|
||||
return {
|
||||
_id: (user.personalPublisherId ?? (`publishers:${handle}` as Id<"publishers">)) as Id<"publishers">,
|
||||
_id: (user.personalPublisherId ??
|
||||
(`publishers:${handle}` as Id<"publishers">)) as Id<"publishers">,
|
||||
_creationTime: user._creationTime,
|
||||
kind: "user",
|
||||
handle,
|
||||
@@ -43,10 +43,7 @@ function synthesizePersonalPublisher(user: Doc<"users">): Doc<"publishers"> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPersonalPublisherForUserOrFallback(
|
||||
ctx: DbCtx,
|
||||
user: Doc<"users">,
|
||||
) {
|
||||
export async function getPersonalPublisherForUserOrFallback(ctx: DbCtx, user: Doc<"users">) {
|
||||
if (user.personalPublisherId) {
|
||||
const publisher = await ctx.db.get(user.personalPublisherId);
|
||||
if (isPublisherActive(publisher)) return publisher;
|
||||
@@ -80,10 +77,7 @@ export function isPublisherRoleAllowed(role: PublisherRole, allowed: PublisherRo
|
||||
return allowed.some((candidate) => ranks[role] >= ranks[candidate]);
|
||||
}
|
||||
|
||||
export async function getPublisherByHandle(
|
||||
ctx: DbCtx,
|
||||
handle: string | undefined | null,
|
||||
) {
|
||||
export async function getPublisherByHandle(ctx: DbCtx, handle: string | undefined | null) {
|
||||
const normalized = normalizePublisherHandle(handle);
|
||||
if (!normalized) return null;
|
||||
try {
|
||||
@@ -97,10 +91,42 @@ export async function getPublisherByHandle(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPersonalPublisherForUser(
|
||||
export async function getUserByHandleOrPersonalPublisher(
|
||||
ctx: DbCtx,
|
||||
userId: Id<"users">,
|
||||
handle: string | undefined | null,
|
||||
) {
|
||||
const normalized = normalizePublisherHandle(handle);
|
||||
if (!normalized) return null;
|
||||
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", normalized))
|
||||
.unique();
|
||||
if (user) return user;
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, normalized);
|
||||
if (
|
||||
!publisher ||
|
||||
!isPublisherActive(publisher) ||
|
||||
publisher.kind !== "user" ||
|
||||
!publisher.linkedUserId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await ctx.db.get(publisher.linkedUserId);
|
||||
}
|
||||
|
||||
export async function getActiveUserByHandleOrPersonalPublisher(
|
||||
ctx: DbCtx,
|
||||
handle: string | undefined | null,
|
||||
) {
|
||||
const user = await getUserByHandleOrPersonalPublisher(ctx, handle);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getPersonalPublisherForUser(ctx: DbCtx, userId: Id<"users">) {
|
||||
try {
|
||||
return await ctx.db
|
||||
.query("publishers")
|
||||
@@ -119,10 +145,9 @@ export async function ensurePersonalPublisherForUser(
|
||||
const handle = derivePersonalPublisherHandle(user);
|
||||
let existing: Doc<"publishers"> | null = null;
|
||||
try {
|
||||
existing =
|
||||
user.personalPublisherId
|
||||
? await ctx.db.get(user.personalPublisherId)
|
||||
: await getPersonalPublisherForUser(ctx, user._id);
|
||||
existing = user.personalPublisherId
|
||||
? await ctx.db.get(user.personalPublisherId)
|
||||
: await getPersonalPublisherForUser(ctx, user._id);
|
||||
} catch (error) {
|
||||
if (!isMissingPublisherTableError(error)) throw error;
|
||||
return synthesizePersonalPublisher(user);
|
||||
@@ -210,7 +235,9 @@ export async function ensurePersonalPublisherForUser(
|
||||
|
||||
const existingMember = await ctx.db
|
||||
.query("publisherMembers")
|
||||
.withIndex("by_publisher_user", (q) => q.eq("publisherId", publisherId).eq("userId", user._id))
|
||||
.withIndex("by_publisher_user", (q) =>
|
||||
q.eq("publisherId", publisherId).eq("userId", user._id),
|
||||
)
|
||||
.unique();
|
||||
if (!existingMember) {
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
|
||||
@@ -47,4 +47,55 @@ describe("searchText", () => {
|
||||
it("normalize uses lowercase", () => {
|
||||
expect(__test.normalize("AbC")).toBe("abc");
|
||||
});
|
||||
|
||||
// CJK (Chinese, Japanese, Korean) support tests
|
||||
describe("CJK tokenization", () => {
|
||||
it("tokenizes Chinese text using Intl.Segmenter", () => {
|
||||
const tokens = tokenize("中文搜索");
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
expect(tokens).toContain("中文");
|
||||
expect(tokens).toContain("搜索");
|
||||
});
|
||||
|
||||
it("tokenizes mixed Chinese and English text", () => {
|
||||
const tokens = tokenize("React 组件开发");
|
||||
expect(tokens).toContain("react");
|
||||
expect(tokens.some((t) => t.includes("组") || t.includes("件"))).toBe(true);
|
||||
});
|
||||
|
||||
it("matches Chinese query tokens against Chinese skill names", () => {
|
||||
const queryTokens = tokenize("翻译");
|
||||
const skillName = "AI翻译助手";
|
||||
expect(matchesExactTokens(queryTokens, [skillName])).toBe(true);
|
||||
});
|
||||
|
||||
it("matches partial Chinese words", () => {
|
||||
const queryTokens = tokenize("助手");
|
||||
const skillName = "AI翻译助手";
|
||||
expect(matchesExactTokens(queryTokens, [skillName])).toBe(true);
|
||||
});
|
||||
|
||||
it("handles Japanese text", () => {
|
||||
const tokens = tokenize("こんにちは世界");
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles Korean text", () => {
|
||||
const tokens = tokenize("안녕하세요");
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns empty array for empty or whitespace-only input", () => {
|
||||
expect(tokenize("")).toEqual([]);
|
||||
expect(tokenize(" ")).toEqual([]);
|
||||
expect(tokenize("!!!")).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects CJK language correctly", () => {
|
||||
expect(__test.detectCJKLanguage("中文")).toBe("zh");
|
||||
expect(__test.detectCJKLanguage("こんにちは")).toBe("ja");
|
||||
expect(__test.detectCJKLanguage("안녕하세요")).toBe("ko");
|
||||
expect(__test.detectCJKLanguage("hello")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+130
-3
@@ -1,12 +1,135 @@
|
||||
const WORD_RE = /[a-z0-9]+/g;
|
||||
const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf\u3041-\u3096\u30a1-\u30fa\uac00-\ud7af]/;
|
||||
|
||||
const hasSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl;
|
||||
|
||||
let zhSegmenter: Intl.Segmenter | null = null;
|
||||
let jaSegmenter: Intl.Segmenter | null = null;
|
||||
let koSegmenter: Intl.Segmenter | null = null;
|
||||
|
||||
function getZhSegmenter(): Intl.Segmenter {
|
||||
if (!zhSegmenter) {
|
||||
zhSegmenter = new Intl.Segmenter("zh-CN", { granularity: "word" });
|
||||
}
|
||||
return zhSegmenter;
|
||||
}
|
||||
|
||||
function getJaSegmenter(): Intl.Segmenter {
|
||||
if (!jaSegmenter) {
|
||||
jaSegmenter = new Intl.Segmenter("ja", { granularity: "word" });
|
||||
}
|
||||
return jaSegmenter;
|
||||
}
|
||||
|
||||
function getKoSegmenter(): Intl.Segmenter {
|
||||
if (!koSegmenter) {
|
||||
koSegmenter = new Intl.Segmenter("ko", { granularity: "word" });
|
||||
}
|
||||
return koSegmenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: split CJK text into individual characters.
|
||||
* Used when Intl.Segmenter is unavailable (e.g. stripped V8 runtime).
|
||||
*/
|
||||
function segmentCJKByChar(text: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
for (const ch of text) {
|
||||
if (CJK_RE.test(ch)) {
|
||||
tokens.push(ch);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function normalize(value: string) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the primary CJK language in a text
|
||||
* Returns 'zh' for Chinese, 'ja' for Japanese, 'ko' for Korean, or null
|
||||
*/
|
||||
function detectCJKLanguage(text: string): "zh" | "ja" | "ko" | null {
|
||||
const chineseCount = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
|
||||
const hiraganaCount = (text.match(/[\u3040-\u309f]/g) || []).length;
|
||||
const katakanaCount = (text.match(/[\u30a0-\u30ff]/g) || []).length;
|
||||
const hangulCount = (text.match(/[\uac00-\ud7af]/g) || []).length;
|
||||
if (hiraganaCount + katakanaCount > 0) {
|
||||
return "ja";
|
||||
}
|
||||
if (hangulCount > 0) {
|
||||
return "ko";
|
||||
}
|
||||
if (chineseCount > 0) {
|
||||
return "zh";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segment CJK text using Intl.Segmenter, falling back to character-level
|
||||
* tokenization when the API is unavailable.
|
||||
*/
|
||||
function segmentCJK(text: string): string[] {
|
||||
if (!hasSegmenter) return segmentCJKByChar(text);
|
||||
|
||||
const lang = detectCJKLanguage(text);
|
||||
if (!lang) return [];
|
||||
|
||||
let segmenter: Intl.Segmenter;
|
||||
switch (lang) {
|
||||
case "ja":
|
||||
segmenter = getJaSegmenter();
|
||||
break;
|
||||
case "ko":
|
||||
segmenter = getKoSegmenter();
|
||||
break;
|
||||
default:
|
||||
segmenter = getZhSegmenter();
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
for (const { segment, isWordLike } of segmenter.segment(text)) {
|
||||
const trimmed = segment.trim();
|
||||
if (trimmed && isWordLike) {
|
||||
segments.push(trimmed);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize text for search, supporting both English and CJK languages
|
||||
*
|
||||
* For English: uses word boundaries (whitespace, punctuation)
|
||||
* For CJK: uses Intl.Segmenter for proper word segmentation
|
||||
*/
|
||||
export function tokenize(value: string): string[] {
|
||||
if (!value) return [];
|
||||
return normalize(value).match(WORD_RE) ?? [];
|
||||
|
||||
const normalized = normalize(value);
|
||||
|
||||
if (!CJK_RE.test(normalized)) {
|
||||
return normalized.match(/[a-z0-9]+/g) ?? [];
|
||||
}
|
||||
|
||||
const tokens: string[] = [];
|
||||
|
||||
const parts = normalized.split(/([^\u4e00-\u9fff\u3400-\u4dbf\u3041-\u3096\u30a1-\u30fa\uac00-\ud7af]+)/g);
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part.trim()) continue;
|
||||
|
||||
if (CJK_RE.test(part)) {
|
||||
const cjkTokens = segmentCJK(part);
|
||||
tokens.push(...cjkTokens);
|
||||
} else {
|
||||
const asciiTokens = part.match(/[a-z0-9]+/g) ?? [];
|
||||
tokens.push(...asciiTokens);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function matchesExactTokens(
|
||||
@@ -24,4 +147,8 @@ export function matchesExactTokens(
|
||||
);
|
||||
}
|
||||
|
||||
export const __test = { normalize, tokenize, matchesExactTokens };
|
||||
export const __test = {
|
||||
normalize,
|
||||
detectCJKLanguage,
|
||||
segmentCJKByChar,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveSkillCapabilityTags } from "./skillCapabilityTags";
|
||||
|
||||
describe("deriveSkillCapabilityTags", () => {
|
||||
it("detects wallet, payment, and transaction authority from crypto skills", () => {
|
||||
const tags = deriveSkillCapabilityTags({
|
||||
slug: "paytoll",
|
||||
displayName: "PayToll",
|
||||
summary: "DeFi tools paid with x402 micro-payments.",
|
||||
frontmatter: {
|
||||
"requires.env": ["PRIVATE_KEY"],
|
||||
},
|
||||
readmeText:
|
||||
"Payment is the auth. Each tool call costs USDC. The wallet private key signs EIP-712 payment authorizations.",
|
||||
fileContents: [
|
||||
{
|
||||
path: "src/executor.ts",
|
||||
content:
|
||||
"walletClient.sendTransaction({}); if (result.type === 'approval_required') { log('Sending approval transaction...'); }",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(tags).toEqual([
|
||||
"crypto",
|
||||
"requires-wallet",
|
||||
"can-make-purchases",
|
||||
"can-sign-transactions",
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects OAuth-backed external posting behavior", () => {
|
||||
const tags = deriveSkillCapabilityTags({
|
||||
slug: "social-poster",
|
||||
displayName: "Social Poster",
|
||||
frontmatter: {},
|
||||
readmeText:
|
||||
"Post a tweet for the user. Requires an OAuth 2.0 access token with tweet.write scope.",
|
||||
fileContents: [],
|
||||
});
|
||||
|
||||
expect(tags).toEqual(["requires-oauth-token", "posts-externally"]);
|
||||
});
|
||||
|
||||
it("does not treat generic broadcast wording as a crypto transaction signal", () => {
|
||||
const tags = deriveSkillCapabilityTags({
|
||||
slug: "notify-bot",
|
||||
displayName: "Notify Bot",
|
||||
frontmatter: {},
|
||||
readmeText: "Broadcast notifications to Slack and email when incidents are opened.",
|
||||
fileContents: [],
|
||||
});
|
||||
|
||||
expect(tags).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
export const SKILL_CAPABILITY_TAGS = [
|
||||
"crypto",
|
||||
"requires-wallet",
|
||||
"can-make-purchases",
|
||||
"can-sign-transactions",
|
||||
"requires-oauth-token",
|
||||
"posts-externally",
|
||||
] as const;
|
||||
|
||||
export type SkillCapabilityTag = (typeof SKILL_CAPABILITY_TAGS)[number];
|
||||
|
||||
function safeJson(value: unknown) {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(parts: Array<string | undefined>) {
|
||||
return parts
|
||||
.filter((part): part is string => typeof part === "string" && part.trim().length > 0)
|
||||
.join("\n")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function matches(text: string, patterns: RegExp[]) {
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
const CRYPTO_PATTERNS = [
|
||||
/\bcrypto\b/,
|
||||
/\bblockchain\b/,
|
||||
/\bdefi\b/,
|
||||
/\bon-?chain\b/,
|
||||
/\bwallet\b/,
|
||||
/\bprivate key\b/,
|
||||
/\berc20\b/,
|
||||
/\busdc\b/,
|
||||
/\beth(?:ereum)?\b/,
|
||||
/\bbase network\b/,
|
||||
/\barbitrum\b/,
|
||||
/\boptimism\b/,
|
||||
/\bpolygon\b/,
|
||||
/\bavalanche\b/,
|
||||
/\bsolana\b/,
|
||||
/\baave\b/,
|
||||
/\btoken balance\b/,
|
||||
/\bswap\b/,
|
||||
/\bbridge\b/,
|
||||
/\bliquidity\b/,
|
||||
/\bens\b/,
|
||||
/\bx402\b/,
|
||||
] satisfies RegExp[];
|
||||
|
||||
const WALLET_PATTERNS = [
|
||||
/\bprivate[_ -]?key\b/,
|
||||
/\bwallet\b/,
|
||||
/\bmnemonic\b/,
|
||||
/\bseed phrase\b/,
|
||||
/\bconfigured wallet\b/,
|
||||
/\bsigner\b/,
|
||||
/\beip-712\b/,
|
||||
] satisfies RegExp[];
|
||||
|
||||
const PURCHASE_PATTERNS = [
|
||||
/\bpay(?:ment|ments)?\b/,
|
||||
/\bpaid automatically\b/,
|
||||
/\bpay per call\b/,
|
||||
/\bmicro-?payments?\b/,
|
||||
/\bpayment required\b/,
|
||||
/\bcosts? \$\d/,
|
||||
/\bcharged?\b/,
|
||||
/\bpurchase\b/,
|
||||
/\bbuy(?:\s+(?:credits?|tokens?|coins?|nft|subscription|plan))\b/,
|
||||
/\bpayment checkout\b/,
|
||||
/\bone-?click checkout\b/,
|
||||
] satisfies RegExp[];
|
||||
|
||||
const TRANSACTION_PATTERNS = [
|
||||
/\bsign(?:ing)? (?:and )?(?:submit|send|broadcast)? ?transactions?\b/,
|
||||
/\bsendtransaction\b/,
|
||||
/\bapproval_required\b/,
|
||||
/\bon-?chain (?:tx|transaction)\b/,
|
||||
/\bexecute(?:s|d)? transaction\b/,
|
||||
/\bbroadcast (?:transaction|tx)\b/,
|
||||
/\btransaction broadcast\b/,
|
||||
/\bwalletclient\.sendtransaction\b/,
|
||||
] satisfies RegExp[];
|
||||
|
||||
const OAUTH_PATTERNS = [
|
||||
/\boauth(?: 2\.0)?\b/,
|
||||
/\baccess token\b/,
|
||||
/\brefresh token\b/,
|
||||
/\bbearer token\b/,
|
||||
/\btweet\.write\b/,
|
||||
] satisfies RegExp[];
|
||||
|
||||
const EXTERNAL_POST_PATTERNS = [
|
||||
/\bpost(?: a| this)? tweet\b/,
|
||||
/\breply to (?:this )?tweet\b/,
|
||||
/\bquote tweet\b/,
|
||||
/\bpost to (?:x|twitter)\b/,
|
||||
/\btwitter-post\b/,
|
||||
/\bpublish post\b/,
|
||||
] satisfies RegExp[];
|
||||
|
||||
export function deriveSkillCapabilityTags(params: {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary?: string;
|
||||
frontmatter?: Record<string, unknown>;
|
||||
readmeText: string;
|
||||
fileContents?: Array<{ path: string; content: string }>;
|
||||
}): SkillCapabilityTag[] {
|
||||
const text = normalizeText([
|
||||
params.slug,
|
||||
params.displayName,
|
||||
params.summary,
|
||||
safeJson(params.frontmatter),
|
||||
params.readmeText,
|
||||
...(params.fileContents ?? []).map((file) => `${file.path}\n${file.content}`),
|
||||
]);
|
||||
|
||||
const tags = new Set<SkillCapabilityTag>();
|
||||
|
||||
const isCrypto = matches(text, CRYPTO_PATTERNS);
|
||||
const requiresWallet = matches(text, WALLET_PATTERNS);
|
||||
const canMakePurchases = matches(text, PURCHASE_PATTERNS);
|
||||
const canSignTransactions = matches(text, TRANSACTION_PATTERNS);
|
||||
const requiresOauthToken = matches(text, OAUTH_PATTERNS);
|
||||
const postsExternally = matches(text, EXTERNAL_POST_PATTERNS);
|
||||
|
||||
if (isCrypto) tags.add("crypto");
|
||||
if (requiresWallet) tags.add("requires-wallet");
|
||||
if (canMakePurchases) tags.add("can-make-purchases");
|
||||
if (canSignTransactions) tags.add("can-sign-transactions");
|
||||
if (requiresOauthToken) tags.add("requires-oauth-token");
|
||||
if (postsExternally) tags.add("posts-externally");
|
||||
|
||||
if (canSignTransactions || canMakePurchases) {
|
||||
tags.add("crypto");
|
||||
}
|
||||
if (canSignTransactions) {
|
||||
tags.add("requires-wallet");
|
||||
}
|
||||
|
||||
return SKILL_CAPABILITY_TAGS.filter((tag) => tags.has(tag));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import semver from "semver";
|
||||
import { api, internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
@@ -8,6 +9,13 @@ import { generateChangelogForPublish } from "./changelog";
|
||||
import { generateEmbedding } from "./embeddings";
|
||||
import { requireGitHubAccountAge } from "./githubAccount";
|
||||
import type { PublicUser } from "./public";
|
||||
import {
|
||||
findOversizedPublishFile,
|
||||
getPublishFileSizeError,
|
||||
getPublishTotalSizeError,
|
||||
MAX_PUBLISH_TOTAL_BYTES,
|
||||
} from "./publishLimits";
|
||||
import { deriveSkillCapabilityTags } from "./skillCapabilityTags";
|
||||
import {
|
||||
computeQualitySignals,
|
||||
evaluateQuality,
|
||||
@@ -29,12 +37,6 @@ import {
|
||||
import { generateSkillSummary } from "./skillSummary";
|
||||
import { runStaticPublishScan } from "./staticPublishScan";
|
||||
import type { WebhookSkillPayload } from "./webhooks";
|
||||
import {
|
||||
findOversizedPublishFile,
|
||||
getPublishFileSizeError,
|
||||
getPublishTotalSizeError,
|
||||
MAX_PUBLISH_TOTAL_BYTES,
|
||||
} from "./publishLimits";
|
||||
|
||||
const MAX_FILES_FOR_EMBEDDING = 40;
|
||||
const QUALITY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -112,6 +114,7 @@ export async function publishVersionForUser(
|
||||
const sanitizedFiles = args.files.map((file) => ({
|
||||
...file,
|
||||
path: sanitizePath(file.path),
|
||||
contentType: normalizeTextContentType(file.path, file.contentType),
|
||||
}));
|
||||
if (sanitizedFiles.some((file) => !file.path)) {
|
||||
throw new ConvexError("Invalid file paths");
|
||||
@@ -246,6 +249,14 @@ export async function publishVersionForUser(
|
||||
readme: readmeText,
|
||||
otherFiles,
|
||||
});
|
||||
const capabilityTags = deriveSkillCapabilityTags({
|
||||
slug,
|
||||
displayName,
|
||||
summary,
|
||||
frontmatter,
|
||||
readmeText,
|
||||
fileContents,
|
||||
});
|
||||
|
||||
const fingerprintPromise = hashSkillFiles(
|
||||
publishFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
||||
@@ -298,6 +309,7 @@ export async function publishVersionForUser(
|
||||
clawdis,
|
||||
license: PLATFORM_SKILL_LICENSE,
|
||||
},
|
||||
capabilityTags,
|
||||
summary,
|
||||
staticScan,
|
||||
embedding,
|
||||
|
||||
@@ -6,6 +6,8 @@ function pick<T extends Record<string, unknown>, K extends keyof T>(obj: T, keys
|
||||
return Object.fromEntries(keys.map((k) => [k, obj[k]])) as Pick<T, K>;
|
||||
}
|
||||
|
||||
type SharedSkillKey = Extract<keyof Doc<"skills">, keyof Doc<"skillSearchDigest">>;
|
||||
|
||||
/**
|
||||
* Fields shared 1:1 between `skills` and `skillSearchDigest` (same name,
|
||||
* same type). Used by both `extractDigestFields` and `digestToHydratableSkill`
|
||||
@@ -22,6 +24,7 @@ const SHARED_KEYS = [
|
||||
"latestVersionId",
|
||||
"latestVersionSummary",
|
||||
"tags",
|
||||
"capabilityTags",
|
||||
"badges",
|
||||
"stats",
|
||||
"statsDownloads",
|
||||
@@ -34,7 +37,7 @@ const SHARED_KEYS = [
|
||||
"moderationReason",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
] as const satisfies readonly (keyof Doc<"skills"> & keyof Doc<"skillSearchDigest">)[];
|
||||
] as const satisfies readonly SharedSkillKey[];
|
||||
|
||||
/** Fields stored in the skillSearchDigest table. */
|
||||
export type SkillSearchDigestFields = Pick<Doc<"skills">, (typeof SHARED_KEYS)[number]> & {
|
||||
@@ -121,8 +124,7 @@ export function digestToOwnerInfo(
|
||||
// Empty string means backfilled but owner has no handle.
|
||||
// Use userId as fallback handle, matching the live getOwnerInfo path.
|
||||
const handle = digest.ownerHandle || undefined;
|
||||
const fallbackHandle =
|
||||
handle ?? String(digest.ownerPublisherId ?? digest.ownerUserId);
|
||||
const fallbackHandle = handle ?? String(digest.ownerPublisherId ?? digest.ownerUserId);
|
||||
const resolvedHandle = handle ?? fallbackHandle;
|
||||
// Determine if we have real profile data (deactivated/deleted owners have
|
||||
// all profile fields undefined, while handle-less visible owners still have
|
||||
|
||||
@@ -150,7 +150,10 @@ describe("skillZip", () => {
|
||||
]);
|
||||
const unzipped = unzipSync(zip);
|
||||
|
||||
expect(Object.keys(unzipped).sort()).toEqual(["package/dist/index.js", "package/package.json"]);
|
||||
expect(Object.keys(unzipped).sort()).toEqual([
|
||||
"package/dist/index.js",
|
||||
"package/package.json",
|
||||
]);
|
||||
expect(unzipped["_meta.json"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ConvexError } from "convex/values";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import semver from "semver";
|
||||
import { internal } from "../_generated/api";
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
@@ -101,7 +102,11 @@ export async function publishSoulVersionForUser(
|
||||
const sanitizedFiles = args.files.map((file) => {
|
||||
const path = sanitizePath(file.path);
|
||||
if (!path) throw new ConvexError("Invalid file paths");
|
||||
return { ...file, path };
|
||||
return {
|
||||
...file,
|
||||
path,
|
||||
contentType: normalizeTextContentType(file.path, file.contentType),
|
||||
};
|
||||
});
|
||||
const publishFiles = sanitizedFiles.filter((file) => !isMacJunkPath(file.path));
|
||||
if (publishFiles.some((file) => !isTextFile(file.path, file.contentType ?? undefined))) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { MutationCtx } from "../_generated/server";
|
||||
|
||||
function getSkillContribution(skill: Doc<"skills">) {
|
||||
if (skill.softDeletedAt) {
|
||||
return { publishedSkills: 0, totalStars: 0, totalDownloads: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
publishedSkills: 1,
|
||||
totalStars: skill.stats?.stars ?? 0,
|
||||
totalDownloads: skill.stats?.downloads ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function patchUserStats(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
userId: Id<"users">,
|
||||
delta: { publishedSkills: number; totalStars: number; totalDownloads: number },
|
||||
) {
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) return;
|
||||
|
||||
await ctx.db.patch(userId, {
|
||||
publishedSkills: Math.max(0, (user.publishedSkills ?? 0) + delta.publishedSkills),
|
||||
totalStars: Math.max(0, (user.totalStars ?? 0) + delta.totalStars),
|
||||
totalDownloads: Math.max(0, (user.totalDownloads ?? 0) + delta.totalDownloads),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adjustUserSkillStatsForSkillChange(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
previousSkill: Doc<"skills"> | null | undefined,
|
||||
nextSkill: Doc<"skills"> | null | undefined,
|
||||
) {
|
||||
if (!previousSkill && !nextSkill) return;
|
||||
|
||||
const prevOwnerId = previousSkill?.ownerUserId ?? null;
|
||||
const nextOwnerId = nextSkill?.ownerUserId ?? null;
|
||||
const prevContribution = previousSkill ? getSkillContribution(previousSkill) : null;
|
||||
const nextContribution = nextSkill ? getSkillContribution(nextSkill) : null;
|
||||
|
||||
if (prevOwnerId && prevOwnerId === nextOwnerId) {
|
||||
await patchUserStats(ctx, prevOwnerId, {
|
||||
publishedSkills: (nextContribution?.publishedSkills ?? 0) - (prevContribution?.publishedSkills ?? 0),
|
||||
totalStars: (nextContribution?.totalStars ?? 0) - (prevContribution?.totalStars ?? 0),
|
||||
totalDownloads: (nextContribution?.totalDownloads ?? 0) - (prevContribution?.totalDownloads ?? 0),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (prevOwnerId) {
|
||||
await patchUserStats(ctx, prevOwnerId, {
|
||||
publishedSkills: -(prevContribution?.publishedSkills ?? 0),
|
||||
totalStars: -(prevContribution?.totalStars ?? 0),
|
||||
totalDownloads: -(prevContribution?.totalDownloads ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
if (nextOwnerId) {
|
||||
await patchUserStats(ctx, nextOwnerId, {
|
||||
publishedSkills: nextContribution?.publishedSkills ?? 0,
|
||||
totalStars: nextContribution?.totalStars ?? 0,
|
||||
totalDownloads: nextContribution?.totalDownloads ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -323,7 +323,10 @@ export const evaluatePackageReleaseWithLlm = internalAction({
|
||||
const content = await blob.text();
|
||||
fileContents.push({ path: f.path, content });
|
||||
const lower = f.path.toLowerCase();
|
||||
if (!readmeContent && (lower === "readme.md" || lower === "readme.mdx" || lower === "readme.markdown")) {
|
||||
if (
|
||||
!readmeContent &&
|
||||
(lower === "readme.md" || lower === "readme.mdx" || lower === "readme.markdown")
|
||||
) {
|
||||
readmeContent = content;
|
||||
}
|
||||
} catch {
|
||||
@@ -332,8 +335,11 @@ export const evaluatePackageReleaseWithLlm = internalAction({
|
||||
}
|
||||
|
||||
if (!readmeContent) {
|
||||
const packageJsonText = fileContents.find((entry) => entry.path.toLowerCase() === "package.json")?.content;
|
||||
readmeContent = packageJsonText ?? `# ${pkg.displayName}\n\n${release.summary ?? pkg.summary ?? pkg.name}`;
|
||||
const packageJsonText = fileContents.find(
|
||||
(entry) => entry.path.toLowerCase() === "package.json",
|
||||
)?.content;
|
||||
readmeContent =
|
||||
packageJsonText ?? `# ${pkg.displayName}\n\n${release.summary ?? pkg.summary ?? pkg.name}`;
|
||||
}
|
||||
|
||||
const allContent = [readmeContent, ...fileContents.map((f) => f.content)].join("\n");
|
||||
|
||||
+208
-1
@@ -5,13 +5,14 @@ import type { ActionCtx } from "./_generated/server";
|
||||
import { action, internalAction, internalMutation, internalQuery } from "./functions";
|
||||
import { assertRole, requireUserFromAction } from "./lib/access";
|
||||
import { buildSkillSummaryBackfillPatch, type ParsedSkillData } from "./lib/skillBackfill";
|
||||
import { deriveSkillCapabilityTags } from "./lib/skillCapabilityTags";
|
||||
import {
|
||||
computeQualitySignals,
|
||||
evaluateQuality,
|
||||
getTrustTier,
|
||||
type TrustTier,
|
||||
} from "./lib/skillQuality";
|
||||
import { hashSkillFiles } from "./lib/skills";
|
||||
import { hashSkillFiles, isTextFile } from "./lib/skills";
|
||||
import { computeIsSuspicious } from "./lib/skillSafety";
|
||||
import { extractDigestFields } from "./lib/skillSearchDigest";
|
||||
import { generateSkillSummary } from "./lib/skillSummary";
|
||||
@@ -22,6 +23,7 @@ const DEFAULT_MAX_BATCHES = 20;
|
||||
const MAX_MAX_BATCHES = 200;
|
||||
const DEFAULT_EMPTY_SKILL_MAX_README_BYTES = 8000;
|
||||
const DEFAULT_EMPTY_SKILL_NOMINATION_THRESHOLD = 3;
|
||||
const DEFAULT_CAPABILITY_BACKFILL_DELAY_MS = 500;
|
||||
const PLATFORM_SKILL_LICENSE = "MIT-0" as const;
|
||||
|
||||
type BackfillStats = {
|
||||
@@ -319,6 +321,211 @@ export const continueSkillSummaryBackfillJobInternal = internalAction({
|
||||
},
|
||||
});
|
||||
|
||||
type CapabilityBackfillStats = {
|
||||
skillsScanned: number;
|
||||
skillsPatched: number;
|
||||
versionsPatched: number;
|
||||
missingVersions: number;
|
||||
missingStorageBlob: number;
|
||||
};
|
||||
|
||||
type CapabilityBackfillResult = {
|
||||
ok: true;
|
||||
stats: CapabilityBackfillStats;
|
||||
cursor: string | null;
|
||||
isDone: boolean;
|
||||
};
|
||||
|
||||
export const applySkillCapabilityTagsInternal = internalMutation({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
capabilityTags: v.array(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const version = await ctx.db.get(args.versionId);
|
||||
if (!version) return { ok: false as const, reason: "missing_version" as const };
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) return { ok: false as const, reason: "missing_skill" as const };
|
||||
|
||||
const normalizedTags = [...new Set(args.capabilityTags)];
|
||||
let versionPatched = false;
|
||||
let skillPatched = false;
|
||||
|
||||
if (JSON.stringify(version.capabilityTags ?? []) !== JSON.stringify(normalizedTags)) {
|
||||
await ctx.db.patch(version._id, {
|
||||
capabilityTags: normalizedTags.length ? normalizedTags : undefined,
|
||||
});
|
||||
versionPatched = true;
|
||||
}
|
||||
|
||||
if (
|
||||
skill.latestVersionId === version._id &&
|
||||
JSON.stringify(skill.capabilityTags ?? []) !== JSON.stringify(normalizedTags)
|
||||
) {
|
||||
await ctx.db.patch(skill._id, {
|
||||
capabilityTags: normalizedTags.length ? normalizedTags : undefined,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
skillPatched = true;
|
||||
}
|
||||
|
||||
return { ok: true as const, versionPatched, skillPatched };
|
||||
},
|
||||
});
|
||||
|
||||
export async function backfillSkillCapabilityTagsInternalHandler(
|
||||
ctx: ActionCtx,
|
||||
args: {
|
||||
dryRun?: boolean;
|
||||
cursor?: string;
|
||||
batchSize?: number;
|
||||
maxBatches?: number;
|
||||
delayMs?: number;
|
||||
},
|
||||
): Promise<CapabilityBackfillResult> {
|
||||
const dryRun = Boolean(args.dryRun);
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
|
||||
const maxBatches = dryRun
|
||||
? clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES)
|
||||
: 1;
|
||||
|
||||
const stats: CapabilityBackfillStats = {
|
||||
skillsScanned: 0,
|
||||
skillsPatched: 0,
|
||||
versionsPatched: 0,
|
||||
missingVersions: 0,
|
||||
missingStorageBlob: 0,
|
||||
};
|
||||
|
||||
let cursor = args.cursor ?? null;
|
||||
let isDone = false;
|
||||
|
||||
for (let batchIndex = 0; batchIndex < maxBatches; batchIndex += 1) {
|
||||
const page = await ctx.runQuery(internal.maintenance.getSkillBackfillPageInternal, {
|
||||
cursor: cursor ?? undefined,
|
||||
batchSize,
|
||||
});
|
||||
|
||||
cursor = page.cursor;
|
||||
isDone = page.isDone;
|
||||
|
||||
for (const item of page.items) {
|
||||
if (item.kind !== "ok") {
|
||||
if (item.kind === "missingVersionDoc" || item.kind === "missingLatestVersion") {
|
||||
stats.missingVersions += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.skillsScanned += 1;
|
||||
|
||||
const version = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
|
||||
versionId: item.versionId,
|
||||
})) as Doc<"skillVersions"> | null;
|
||||
if (!version) {
|
||||
stats.missingVersions += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const readmeBlob = await ctx.storage.get(item.readmeStorageId);
|
||||
if (!readmeBlob) {
|
||||
stats.missingStorageBlob += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const readmeText = await readmeBlob.text();
|
||||
const fileContents: Array<{ path: string; content: string }> = [];
|
||||
let hasMissingTextBlob = false;
|
||||
for (const file of version.files) {
|
||||
const lower = file.path.toLowerCase();
|
||||
if (lower === "skill.md" || lower === "skills.md") continue;
|
||||
if (!isTextFile(file.path, file.contentType ?? undefined)) continue;
|
||||
const blob = await ctx.storage.get(file.storageId);
|
||||
if (!blob) {
|
||||
stats.missingStorageBlob += 1;
|
||||
hasMissingTextBlob = true;
|
||||
break;
|
||||
}
|
||||
fileContents.push({ path: file.path, content: await blob.text() });
|
||||
}
|
||||
|
||||
if (hasMissingTextBlob) continue;
|
||||
|
||||
const capabilityTags = deriveSkillCapabilityTags({
|
||||
slug: item.skillSlug,
|
||||
displayName: item.skillDisplayName,
|
||||
summary: item.skillSummary ?? undefined,
|
||||
frontmatter: item.versionParsed?.frontmatter,
|
||||
readmeText,
|
||||
fileContents,
|
||||
});
|
||||
|
||||
if (dryRun) continue;
|
||||
|
||||
const result = await ctx.runMutation(internal.maintenance.applySkillCapabilityTagsInternal, {
|
||||
skillId: item.skillId,
|
||||
versionId: item.versionId,
|
||||
capabilityTags,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
if (result.skillPatched) stats.skillsPatched += 1;
|
||||
if (result.versionPatched) stats.versionsPatched += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDone) break;
|
||||
}
|
||||
|
||||
return { ok: true, stats, cursor, isDone };
|
||||
}
|
||||
|
||||
export const backfillSkillCapabilityTagsInternal = internalAction({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
delayMs: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<CapabilityBackfillResult> => {
|
||||
const result = await backfillSkillCapabilityTagsInternalHandler(ctx, args);
|
||||
|
||||
if (!args.dryRun && !result.isDone && result.cursor) {
|
||||
const delayMs = clampInt(args.delayMs ?? DEFAULT_CAPABILITY_BACKFILL_DELAY_MS, 0, 60_000);
|
||||
await ctx.scheduler.runAfter(
|
||||
delayMs,
|
||||
internal.maintenance.backfillSkillCapabilityTagsInternal,
|
||||
{
|
||||
dryRun: false,
|
||||
cursor: result.cursor,
|
||||
batchSize: args.batchSize,
|
||||
maxBatches: 1,
|
||||
delayMs,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
export const backfillSkillCapabilityTags: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
delayMs: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<CapabilityBackfillResult> => {
|
||||
const { user } = await requireUserFromAction(ctx);
|
||||
assertRole(user, ["admin"]);
|
||||
return ctx.runAction(internal.maintenance.backfillSkillCapabilityTagsInternal, args);
|
||||
},
|
||||
});
|
||||
|
||||
type FingerprintBackfillStats = {
|
||||
versionsScanned: number;
|
||||
versionsPatched: number;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { v } from "convex/values";
|
||||
import { internalMutation, internalQuery } from "./functions";
|
||||
|
||||
export const createInternal = internalMutation({
|
||||
args: {
|
||||
packageId: v.id("packages"),
|
||||
version: v.string(),
|
||||
prefix: v.string(),
|
||||
tokenHash: v.string(),
|
||||
provider: v.literal("github-actions"),
|
||||
repository: v.string(),
|
||||
repositoryId: v.string(),
|
||||
repositoryOwner: v.string(),
|
||||
repositoryOwnerId: v.string(),
|
||||
workflowFilename: v.string(),
|
||||
environment: v.optional(v.string()),
|
||||
runId: v.string(),
|
||||
runAttempt: v.string(),
|
||||
sha: v.string(),
|
||||
ref: v.string(),
|
||||
refType: v.optional(v.string()),
|
||||
actor: v.optional(v.string()),
|
||||
actorId: v.optional(v.string()),
|
||||
expiresAt: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
return await ctx.db.insert("packagePublishTokens", {
|
||||
...args,
|
||||
createdAt: now,
|
||||
lastUsedAt: undefined,
|
||||
revokedAt: undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const getByHashInternal = internalQuery({
|
||||
args: { tokenHash: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db
|
||||
.query("packagePublishTokens")
|
||||
.withIndex("by_hash", (q) => q.eq("tokenHash", args.tokenHash))
|
||||
.unique();
|
||||
},
|
||||
});
|
||||
|
||||
export const getByIdInternal = internalQuery({
|
||||
args: { tokenId: v.id("packagePublishTokens") },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.get(args.tokenId);
|
||||
},
|
||||
});
|
||||
|
||||
export const touchInternal = internalMutation({
|
||||
args: { tokenId: v.id("packagePublishTokens") },
|
||||
handler: async (ctx, args) => {
|
||||
const token = await ctx.db.get(args.tokenId);
|
||||
if (!token || token.revokedAt || token.expiresAt <= Date.now()) return;
|
||||
await ctx.db.patch(token._id, { lastUsedAt: Date.now() });
|
||||
},
|
||||
});
|
||||
|
||||
export const revokeInternal = internalMutation({
|
||||
args: { tokenId: v.id("packagePublishTokens") },
|
||||
handler: async (ctx, args) => {
|
||||
const token = await ctx.db.get(args.tokenId);
|
||||
if (!token || token.revokedAt) return;
|
||||
await ctx.db.patch(token._id, { revokedAt: Date.now() });
|
||||
},
|
||||
});
|
||||
+353
-23
@@ -8,6 +8,7 @@ import {
|
||||
getByName,
|
||||
list,
|
||||
publishPackage,
|
||||
publishPackageForTrustedPublisherInternal,
|
||||
publishPackageForUserInternal,
|
||||
getVersionByName,
|
||||
insertReleaseInternal,
|
||||
@@ -176,6 +177,15 @@ const publishPackageForUserInternalHandler = (
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
const publishPackageForTrustedPublisherInternalHandler = (
|
||||
publishPackageForTrustedPublisherInternal as unknown as WrappedHandler<
|
||||
{
|
||||
publishTokenId: string;
|
||||
payload: unknown;
|
||||
},
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
const getPackageReleaseScanBackfillBatchInternalHandler = (
|
||||
getPackageReleaseScanBackfillBatchInternal as unknown as WrappedHandler<
|
||||
{
|
||||
@@ -321,8 +331,8 @@ function makeDigestCtx(options: {
|
||||
const pageByTable = new Map<
|
||||
string,
|
||||
Map<
|
||||
string | null,
|
||||
{ page: Array<Record<string, unknown>>; isDone: boolean; continueCursor: string }
|
||||
string | null,
|
||||
{ page: Array<Record<string, unknown>>; isDone: boolean; continueCursor: string }
|
||||
>
|
||||
>();
|
||||
const indexNames: string[] = [];
|
||||
@@ -420,7 +430,8 @@ function makeDigestCtx(options: {
|
||||
indexName === "by_name"
|
||||
? matchedValue
|
||||
? String(pkg.normalizedName) === matchedValue
|
||||
: String(pkg.normalizedName) >= lowerBound && String(pkg.normalizedName) < upperBound
|
||||
: String(pkg.normalizedName) >= lowerBound &&
|
||||
String(pkg.normalizedName) < upperBound
|
||||
: matchedValue
|
||||
? String(pkg.runtimeId) === matchedValue
|
||||
: String(pkg.runtimeId) >= lowerBound && String(pkg.runtimeId) < upperBound,
|
||||
@@ -487,12 +498,17 @@ function makeDigestCtx(options: {
|
||||
lt: () => queryBuilder,
|
||||
};
|
||||
builder?.(queryBuilder);
|
||||
const match = (options.exactDigests ?? []).find((digest) => digest.packageId === packageId);
|
||||
const match = (options.exactDigests ?? []).find(
|
||||
(digest) => digest.packageId === packageId,
|
||||
);
|
||||
return {
|
||||
unique: vi.fn().mockResolvedValue(match ?? null),
|
||||
};
|
||||
}
|
||||
if (indexName === "by_active_normalized_name" || indexName === "by_active_runtime_id") {
|
||||
if (
|
||||
indexName === "by_active_normalized_name" ||
|
||||
indexName === "by_active_runtime_id"
|
||||
) {
|
||||
let lowerBound = "";
|
||||
let upperBound = "";
|
||||
const queryBuilder = {
|
||||
@@ -541,9 +557,7 @@ function makeInsertReleaseCtx(
|
||||
recordsById: Record<string, Record<string, unknown>> = {},
|
||||
) {
|
||||
const patch = vi.fn();
|
||||
const insert = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("packageReleases:new");
|
||||
const insert = vi.fn().mockResolvedValueOnce("packageReleases:new");
|
||||
return {
|
||||
patch,
|
||||
insert,
|
||||
@@ -610,7 +624,9 @@ function makePackageCtx(options: {
|
||||
ctx: {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (pkg && id === pkg.ownerUserId) return { _id: id, handle: "owner" };
|
||||
if (typeof id === "string" && id.startsWith("users:")) {
|
||||
return { _id: id, handle: id.split(":").pop() ?? "user" };
|
||||
}
|
||||
if (ownerPublisher && pkg && id === pkg.ownerPublisherId) return ownerPublisher;
|
||||
if (pkg && id === pkg.latestReleaseId) return latestRelease;
|
||||
return null;
|
||||
@@ -810,10 +826,7 @@ describe("packages public queries", () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("secret-plugin", { channel: "private" }),
|
||||
makeDigest("public-plugin"),
|
||||
],
|
||||
page: [makeDigest("secret-plugin", { channel: "private" }), makeDigest("public-plugin")],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
@@ -1434,10 +1447,11 @@ describe("packages public queries", () => {
|
||||
continueCursor: "",
|
||||
});
|
||||
await expect(
|
||||
getVersionByNameHandler(
|
||||
ctx,
|
||||
{ name: "demo-plugin", version: "1.0.0", viewerUserId: "users:owner" } as never,
|
||||
),
|
||||
getVersionByNameHandler(ctx, {
|
||||
name: "demo-plugin",
|
||||
version: "1.0.0",
|
||||
viewerUserId: "users:owner",
|
||||
} as never),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
@@ -1498,6 +1512,26 @@ describe("packages public queries", () => {
|
||||
expect(detail?.package.name).toBe("demo-plugin");
|
||||
});
|
||||
|
||||
it("treats invalid auth user lookups as anonymous for public package detail", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:broken" as never);
|
||||
const { ctx } = makePackageCtx({
|
||||
pkg: makePackageDoc({ channel: "community" }),
|
||||
});
|
||||
const get = ctx.db.get as ReturnType<typeof vi.fn>;
|
||||
get.mockImplementation(async (id: string) => {
|
||||
if (id === "users:broken") throw new Error("Table mismatch");
|
||||
if (id === "users:owner") return { _id: id, handle: "owner" };
|
||||
if (id === "packageReleases:demo-1") return makeReleaseDoc();
|
||||
return null;
|
||||
});
|
||||
|
||||
const detail = await getByNameHandler(ctx, {
|
||||
name: "demo-plugin",
|
||||
});
|
||||
|
||||
expect(detail?.package.name).toBe("demo-plugin");
|
||||
});
|
||||
|
||||
it("does not expose a soft-deleted latest release as latestVersion", async () => {
|
||||
const { ctx } = makePackageCtx({
|
||||
latestRelease: makeReleaseDoc({ softDeletedAt: 10 }),
|
||||
@@ -1615,7 +1649,7 @@ describe("packages public queries", () => {
|
||||
integritySha256: "abc123",
|
||||
runtimeId: "other.plugin",
|
||||
}),
|
||||
).rejects.toThrow('runtime id changes are not allowed');
|
||||
).rejects.toThrow("runtime id changes are not allowed");
|
||||
});
|
||||
|
||||
it("promotes existing packages to official when publisher becomes trusted", async () => {
|
||||
@@ -2028,11 +2062,296 @@ describe("packages public queries", () => {
|
||||
).rejects.toThrow("Skill packages must use the skills publish flow");
|
||||
});
|
||||
|
||||
it("rejects trusted publish tokens after trusted publisher rotation or deletion", async () => {
|
||||
const ctx = {
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packagePublishTokens:1",
|
||||
packageId: "packages:demo",
|
||||
provider: "github-actions",
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-release",
|
||||
version: "1.0.0",
|
||||
sha: "abc123",
|
||||
ref: "refs/heads/main",
|
||||
runId: "100",
|
||||
runAttempt: "1",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
})
|
||||
.mockResolvedValueOnce(null),
|
||||
};
|
||||
|
||||
await expect(
|
||||
publishPackageForTrustedPublisherInternalHandler(ctx as never, {
|
||||
publishTokenId: "packagePublishTokens:1",
|
||||
payload: {
|
||||
name: "demo-plugin",
|
||||
family: "bundle-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "init",
|
||||
bundle: { hostTargets: ["desktop"] },
|
||||
files: [],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Trusted publish token no longer matches the current package trusted publisher",
|
||||
);
|
||||
});
|
||||
|
||||
it("revokes trusted publish tokens after a successful publish", async () => {
|
||||
const runMutation = vi.fn(async (_ref: unknown, args: unknown) => {
|
||||
if (
|
||||
typeof args === "object" &&
|
||||
args !== null &&
|
||||
"name" in args &&
|
||||
"version" in args &&
|
||||
"files" in args
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
packageId: "packages:demo",
|
||||
releaseId: "packageReleases:demo-2",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const trustedPublisher = {
|
||||
_id: "packageTrustedPublishers:1",
|
||||
packageId: "packages:demo",
|
||||
provider: "github-actions",
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-release",
|
||||
};
|
||||
const ctx = {
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packagePublishTokens:1",
|
||||
packageId: "packages:demo",
|
||||
provider: "github-actions",
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-release",
|
||||
version: "1.0.0",
|
||||
sha: "abc123",
|
||||
ref: "refs/heads/main",
|
||||
runId: "100",
|
||||
runAttempt: "1",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
})
|
||||
.mockResolvedValueOnce(trustedPublisher)
|
||||
.mockResolvedValueOnce(makePackageDoc({ family: "bundle-plugin" }))
|
||||
.mockResolvedValueOnce(trustedPublisher)
|
||||
.mockResolvedValueOnce(null),
|
||||
runMutation,
|
||||
scheduler: {
|
||||
runAfter: vi.fn(),
|
||||
},
|
||||
storage: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
publishPackageForTrustedPublisherInternalHandler(ctx as never, {
|
||||
publishTokenId: "packagePublishTokens:1",
|
||||
payload: {
|
||||
name: "demo-plugin",
|
||||
family: "bundle-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "init",
|
||||
bundle: { hostTargets: ["desktop"] },
|
||||
files: [],
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
packageId: "packages:demo",
|
||||
releaseId: "packageReleases:demo-2",
|
||||
});
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
tokenId: "packagePublishTokens:1",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts trusted publish tokens when no environment is pinned", async () => {
|
||||
const runMutation = vi.fn(async (_ref: unknown, args: unknown) => {
|
||||
if (
|
||||
typeof args === "object" &&
|
||||
args !== null &&
|
||||
"name" in args &&
|
||||
"version" in args &&
|
||||
"files" in args
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
packageId: "packages:demo",
|
||||
releaseId: "packageReleases:demo-2",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const trustedPublisher = {
|
||||
_id: "packageTrustedPublishers:1",
|
||||
packageId: "packages:demo",
|
||||
provider: "github-actions",
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
};
|
||||
const ctx = {
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packagePublishTokens:1",
|
||||
packageId: "packages:demo",
|
||||
provider: "github-actions",
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
version: "1.0.0",
|
||||
sha: "abc123",
|
||||
ref: "refs/heads/main",
|
||||
runId: "100",
|
||||
runAttempt: "1",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
})
|
||||
.mockResolvedValueOnce(trustedPublisher)
|
||||
.mockResolvedValueOnce(makePackageDoc({ family: "bundle-plugin" }))
|
||||
.mockResolvedValueOnce(trustedPublisher)
|
||||
.mockResolvedValueOnce(null),
|
||||
runMutation,
|
||||
scheduler: {
|
||||
runAfter: vi.fn(),
|
||||
},
|
||||
storage: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
publishPackageForTrustedPublisherInternalHandler(ctx as never, {
|
||||
publishTokenId: "packagePublishTokens:1",
|
||||
payload: {
|
||||
name: "demo-plugin",
|
||||
family: "bundle-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "init",
|
||||
bundle: { hostTargets: ["desktop"] },
|
||||
files: [],
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
packageId: "packages:demo",
|
||||
releaseId: "packageReleases:demo-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires manual override for user-auth publishes when trusted publisher config exists", async () => {
|
||||
const runMutation = vi.fn(async (_ref: unknown, args: unknown) => {
|
||||
if (
|
||||
typeof args === "object" &&
|
||||
args !== null &&
|
||||
"actorUserId" in args &&
|
||||
"minimumRole" in args
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof args === "object" &&
|
||||
args !== null &&
|
||||
"name" in args &&
|
||||
"version" in args &&
|
||||
"files" in args
|
||||
) {
|
||||
return {
|
||||
ok: true,
|
||||
packageId: "packages:demo",
|
||||
releaseId: "packageReleases:demo-2",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const trustedPublisher = {
|
||||
_id: "packageTrustedPublishers:1",
|
||||
packageId: "packages:demo",
|
||||
provider: "github-actions",
|
||||
repository: "openclaw/openclaw",
|
||||
repositoryId: "1",
|
||||
repositoryOwner: "openclaw",
|
||||
repositoryOwnerId: "2",
|
||||
workflowFilename: "plugin-clawhub-release.yml",
|
||||
environment: "clawhub-release",
|
||||
};
|
||||
const ctx = {
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(makePackageDoc({ family: "bundle-plugin" }))
|
||||
.mockResolvedValueOnce(trustedPublisher)
|
||||
.mockResolvedValueOnce({
|
||||
_id: "users:owner",
|
||||
githubCreatedAt: Date.now() - 20 * 24 * 60 * 60 * 1000,
|
||||
})
|
||||
.mockResolvedValueOnce(null),
|
||||
runMutation,
|
||||
scheduler: {
|
||||
runAfter: vi.fn(),
|
||||
},
|
||||
storage: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
publishPackageForUserInternalHandler(ctx as never, {
|
||||
actorUserId: "users:owner",
|
||||
payload: {
|
||||
name: "demo-plugin",
|
||||
family: "bundle-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "tag publish",
|
||||
bundle: { hostTargets: ["desktop"] },
|
||||
source: {
|
||||
kind: "github",
|
||||
url: "https://github.com/openclaw/openclaw",
|
||||
repo: "openclaw/openclaw",
|
||||
ref: "refs/tags/plugins-2026.4.1-beta.1",
|
||||
commit: "abc123",
|
||||
path: "extensions/discord",
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
files: [],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Manual publishes for packages with trusted publisher config require manualOverrideReason",
|
||||
);
|
||||
});
|
||||
|
||||
it("scans plugin publishes and forwards scan status to insertReleaseInternal", async () => {
|
||||
const runMutation = vi.fn(async (_ref: unknown, args: unknown) => args);
|
||||
const ctx = {
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
_id: "users:owner",
|
||||
githubCreatedAt: Date.now() - 20 * 24 * 60 * 60 * 1000,
|
||||
@@ -2057,8 +2376,14 @@ describe("packages public queries", () => {
|
||||
},
|
||||
}),
|
||||
],
|
||||
["storage:manifest", JSON.stringify({ id: "demo.plugin", tools: [{ name: "demoTool" }] })],
|
||||
["storage:code", "import { execSync } from 'node:child_process';\nexecSync('curl http://x');\n"],
|
||||
[
|
||||
"storage:manifest",
|
||||
JSON.stringify({ id: "demo.plugin", tools: [{ name: "demoTool" }] }),
|
||||
],
|
||||
[
|
||||
"storage:code",
|
||||
"import { execSync } from 'node:child_process';\nexecSync('curl http://x');\n",
|
||||
],
|
||||
]);
|
||||
const content = files.get(storageId);
|
||||
return content ? new Blob([content]) : null;
|
||||
@@ -2163,9 +2488,11 @@ describe("packages public queries", () => {
|
||||
if (table !== "packages") throw new Error(`Unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(
|
||||
makePackageDoc({ ownerUserId: "users:owner", scanStatus: "pending" }),
|
||||
),
|
||||
unique: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
makePackageDoc({ ownerUserId: "users:owner", scanStatus: "pending" }),
|
||||
),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
@@ -2191,6 +2518,9 @@ describe("packages public queries", () => {
|
||||
staticScan: { status: "clean" },
|
||||
});
|
||||
}
|
||||
if (id === "users:owner") {
|
||||
return { _id: "users:owner", handle: "owner" };
|
||||
}
|
||||
if (id === "publishers:owner") {
|
||||
return { _id: "publishers:owner", kind: "user", linkedUserId: "users:owner" };
|
||||
}
|
||||
|
||||
+784
-251
File diff suppressed because it is too large
Load Diff
+314
-89
@@ -16,9 +16,11 @@ type WrappedHandler<TArgs, TResult = unknown> = {
|
||||
};
|
||||
|
||||
const addMemberHandler = (
|
||||
addMember as unknown as WrappedHandler<
|
||||
{ publisherId: string; userHandle: string; role: "owner" | "admin" | "publisher" }
|
||||
>
|
||||
addMember as unknown as WrappedHandler<{
|
||||
publisherId: string;
|
||||
userHandle: string;
|
||||
role: "owner" | "admin" | "publisher";
|
||||
}>
|
||||
)._handler;
|
||||
|
||||
const removeMemberHandler = (
|
||||
@@ -169,6 +171,206 @@ describe("publishers membership controls", () => {
|
||||
),
|
||||
).rejects.toThrow("Publisher must have at least one owner");
|
||||
});
|
||||
|
||||
it("adds a member when the requested handle resolves via a personal publisher", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const publisherMembers: Array<Record<string, unknown>> = [
|
||||
{
|
||||
_id: "publisherMembers:owner",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:owner",
|
||||
role: "owner",
|
||||
},
|
||||
];
|
||||
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
||||
if (table === "publisherMembers") {
|
||||
const row = { _id: "publisherMembers:new", ...value };
|
||||
publisherMembers.push(row);
|
||||
return row._id;
|
||||
}
|
||||
if (table === "auditLogs") return "auditLogs:1";
|
||||
if (table === "publishers") return "publishers:jaredforreal";
|
||||
throw new Error(`unexpected insert ${table}`);
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:owner") return { _id: id };
|
||||
if (id === "users:jared") {
|
||||
return {
|
||||
_id: id,
|
||||
_creationTime: 1,
|
||||
handle: undefined,
|
||||
name: "JaredForReal",
|
||||
displayName: "Jared",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
if (id === "publishers:org") {
|
||||
return {
|
||||
_id: id,
|
||||
kind: "org",
|
||||
handle: "zai-org",
|
||||
displayName: "ZAI Org",
|
||||
};
|
||||
}
|
||||
if (id === "publishers:jaredforreal") {
|
||||
return {
|
||||
_id: id,
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
linkedUserId: "users:jared",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
indexName: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
if (indexName !== "by_publisher_user") {
|
||||
throw new Error(`unexpected index ${indexName}`);
|
||||
}
|
||||
let publisherId = "";
|
||||
let userId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "publisherId") publisherId = value;
|
||||
if (field === "userId") userId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(
|
||||
async () =>
|
||||
publisherMembers.find(
|
||||
(member) =>
|
||||
member.publisherId === publisherId && member.userId === userId,
|
||||
) ?? null,
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
indexName: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
if (indexName !== "handle") {
|
||||
throw new Error(`unexpected index ${indexName}`);
|
||||
}
|
||||
let handle = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
if (handle === "owner") return { _id: "users:owner", handle: "owner" };
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
indexName: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
if (field === "linkedUserId") linkedUserId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
if (indexName === "by_handle" && handle === "jaredforreal") {
|
||||
return {
|
||||
_id: "publishers:jaredforreal",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
linkedUserId: "users:jared",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
if (indexName === "by_linked_user" && linkedUserId === "users:jared") {
|
||||
return {
|
||||
_id: "publishers:jaredforreal",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
linkedUserId: "users:jared",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
insert,
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
addMemberHandler(
|
||||
ctx as never,
|
||||
{ publisherId: "publishers:org", userHandle: "jaredforreal", role: "admin" } as never,
|
||||
),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"publisherMembers",
|
||||
expect.objectContaining({
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:jared",
|
||||
role: "admin",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publisher bootstrap", () => {
|
||||
@@ -343,101 +545,126 @@ describe("legacy publisher migration", () => {
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
let handle = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
[...users.values()].find((user) => user.handle === handle) ?? null,
|
||||
),
|
||||
};
|
||||
}),
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
_indexName: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let handle = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(
|
||||
async () => [...users.values()].find((user) => user.handle === handle) ?? null,
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
if (field === "linkedUserId") linkedUserId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
if (handle) {
|
||||
return [...publishers.values()].find((publisher) => publisher.handle === handle) ?? null;
|
||||
}
|
||||
if (linkedUserId) {
|
||||
return (
|
||||
[...publishers.values()].find((publisher) => publisher.linkedUserId === linkedUserId) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
_indexName: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
if (field === "linkedUserId") linkedUserId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
if (handle) {
|
||||
return (
|
||||
[...publishers.values()].find((publisher) => publisher.handle === handle) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
if (linkedUserId) {
|
||||
return (
|
||||
[...publishers.values()].find(
|
||||
(publisher) => publisher.linkedUserId === linkedUserId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
let publisherId = "";
|
||||
let userId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "publisherId") publisherId = value;
|
||||
if (field === "userId") userId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
publisherMembers.find(
|
||||
(member) => member.publisherId === publisherId && member.userId === userId,
|
||||
) ?? null,
|
||||
),
|
||||
};
|
||||
}),
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
_indexName: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let publisherId = "";
|
||||
let userId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "publisherId") publisherId = value;
|
||||
if (field === "userId") userId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(
|
||||
async () =>
|
||||
publisherMembers.find(
|
||||
(member) => member.publisherId === publisherId && member.userId === userId,
|
||||
) ?? null,
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table === "packages") {
|
||||
return {
|
||||
withIndex: vi.fn((_indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
let ownerUserId = "";
|
||||
let ownerPublisherId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "ownerUserId") ownerUserId = value;
|
||||
if (field === "ownerPublisherId") ownerPublisherId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
collect: vi.fn(async () => {
|
||||
if (ownerUserId) {
|
||||
return packages.filter((pkg) => pkg.ownerUserId === ownerUserId);
|
||||
}
|
||||
if (ownerPublisherId) {
|
||||
return packages.filter((pkg) => pkg.ownerPublisherId === ownerPublisherId);
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
};
|
||||
}),
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
_indexName: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let ownerUserId = "";
|
||||
let ownerPublisherId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "ownerUserId") ownerUserId = value;
|
||||
if (field === "ownerPublisherId") ownerPublisherId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
collect: vi.fn(async () => {
|
||||
if (ownerUserId) {
|
||||
return packages.filter((pkg) => pkg.ownerUserId === ownerUserId);
|
||||
}
|
||||
if (ownerPublisherId) {
|
||||
return packages.filter((pkg) => pkg.ownerPublisherId === ownerPublisherId);
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table === "skills") {
|
||||
@@ -453,9 +680,7 @@ describe("legacy publisher migration", () => {
|
||||
const result = await migrateLegacyPublisherHandleToOrgInternalHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) =>
|
||||
users.get(id) ?? publishers.get(id) ?? null,
|
||||
),
|
||||
get: vi.fn(async (id: string) => users.get(id) ?? publishers.get(id) ?? null),
|
||||
query,
|
||||
patch,
|
||||
insert,
|
||||
|
||||
+13
-14
@@ -4,8 +4,10 @@ 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 { toPublicPublisher } from "./lib/public";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPublisherByHandle,
|
||||
getPublisherMembership,
|
||||
getPersonalPublisherForUserOrFallback,
|
||||
@@ -13,7 +15,6 @@ import {
|
||||
isPublisherRoleAllowed,
|
||||
normalizePublisherHandle,
|
||||
} from "./lib/publishers";
|
||||
import { toPublicPublisher } from "./lib/public";
|
||||
|
||||
const PUBLISHER_HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
||||
|
||||
@@ -87,10 +88,9 @@ async function migrateLegacyPublisherHandleToOrgWithActor(
|
||||
throw new ConvexError(`Legacy user "@${orgHandle}" not found`);
|
||||
}
|
||||
|
||||
const personalPublisher =
|
||||
legacyUser.personalPublisherId
|
||||
? await ctx.db.get(legacyUser.personalPublisherId)
|
||||
: await getPersonalPublisherForUser(ctx, legacyUser._id);
|
||||
const personalPublisher = legacyUser.personalPublisherId
|
||||
? await ctx.db.get(legacyUser.personalPublisherId)
|
||||
: await getPersonalPublisherForUser(ctx, legacyUser._id);
|
||||
const convertiblePublisher =
|
||||
handlePublisher?.kind === "user" && handlePublisher.linkedUserId === legacyUser._id
|
||||
? handlePublisher
|
||||
@@ -355,7 +355,9 @@ export const resolvePublishTargetForUserInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
ownerHandle: v.optional(v.string()),
|
||||
minimumRole: v.optional(v.union(v.literal("owner"), v.literal("admin"), v.literal("publisher"))),
|
||||
minimumRole: v.optional(
|
||||
v.union(v.literal("owner"), v.literal("admin"), v.literal("publisher")),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
@@ -417,9 +419,9 @@ export const listMine = query({
|
||||
if (!publicPublisher) return null;
|
||||
return {
|
||||
publisher: publicPublisher,
|
||||
role: membership.role,
|
||||
};
|
||||
}),
|
||||
role: membership.role,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const visiblePublishers = publishers.filter(
|
||||
(
|
||||
@@ -584,11 +586,8 @@ export const addMember = mutation({
|
||||
}
|
||||
const handle = normalizePublisherHandle(args.userHandle);
|
||||
if (!handle) throw new ConvexError("User handle is required");
|
||||
const targetUser = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", handle))
|
||||
.unique();
|
||||
if (!targetUser || targetUser.deletedAt || targetUser.deactivatedAt) {
|
||||
const targetUser = await getActiveUserByHandleOrPersonalPublisher(ctx, handle);
|
||||
if (!targetUser) {
|
||||
throw new ConvexError(`User "@${handle}" not found`);
|
||||
}
|
||||
await ensurePersonalPublisherForUser(ctx, targetUser);
|
||||
|
||||
+75
-8
@@ -28,6 +28,9 @@ const users = defineTable({
|
||||
githubFetchedAt: v.optional(v.number()),
|
||||
githubProfileSyncedAt: v.optional(v.number()),
|
||||
trustedPublisher: v.optional(v.boolean()),
|
||||
publishedSkills: v.optional(v.number()),
|
||||
totalStars: v.optional(v.number()),
|
||||
totalDownloads: v.optional(v.number()),
|
||||
personalPublisherId: v.optional(v.id("publishers")),
|
||||
requiresModerationAt: v.optional(v.number()),
|
||||
requiresModerationReason: v.optional(v.string()),
|
||||
@@ -40,7 +43,8 @@ const users = defineTable({
|
||||
})
|
||||
.index("email", ["email"])
|
||||
.index("phone", ["phone"])
|
||||
.index("handle", ["handle"]);
|
||||
.index("handle", ["handle"])
|
||||
.index("by_active_handle", ["deletedAt", "deactivatedAt", "handle"]);
|
||||
|
||||
const publishers = defineTable({
|
||||
kind: v.union(v.literal("user"), v.literal("org")),
|
||||
@@ -188,6 +192,23 @@ const packageVerificationValidator = v.optional(
|
||||
}),
|
||||
);
|
||||
|
||||
const packagePublishActorValidator = v.optional(
|
||||
v.union(
|
||||
v.object({
|
||||
kind: v.literal("user"),
|
||||
userId: v.id("users"),
|
||||
}),
|
||||
v.object({
|
||||
kind: v.literal("github-actions"),
|
||||
repository: v.string(),
|
||||
workflow: v.string(),
|
||||
runId: v.string(),
|
||||
runAttempt: v.string(),
|
||||
sha: v.string(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const packageScanStatusValidator = v.optional(
|
||||
v.union(
|
||||
v.literal("clean"),
|
||||
@@ -228,6 +249,7 @@ const skills = defineTable({
|
||||
}),
|
||||
),
|
||||
tags: v.record(v.string(), v.id("skillVersions")),
|
||||
capabilityTags: v.optional(v.array(v.string())),
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
badges: badgesValidator,
|
||||
moderationStatus: moderationStatusValidator,
|
||||
@@ -366,7 +388,8 @@ const souls = defineTable({
|
||||
.index("by_slug", ["slug"])
|
||||
.index("by_owner", ["ownerUserId"])
|
||||
.index("by_owner_publisher", ["ownerPublisherId"])
|
||||
.index("by_updated", ["updatedAt"]);
|
||||
.index("by_updated", ["updatedAt"])
|
||||
.index("by_active_updated", ["softDeletedAt", "updatedAt"]);
|
||||
|
||||
const skillVersions = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
@@ -425,6 +448,7 @@ const skillVersions = defineTable({
|
||||
checkedAt: v.number(),
|
||||
}),
|
||||
),
|
||||
capabilityTags: v.optional(v.array(v.string())),
|
||||
staticScan: v.optional(
|
||||
v.object({
|
||||
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
|
||||
@@ -566,6 +590,7 @@ const skillSearchDigest = defineTable({
|
||||
}),
|
||||
),
|
||||
tags: v.record(v.string(), v.id("skillVersions")),
|
||||
capabilityTags: v.optional(v.array(v.string())),
|
||||
badges: badgesValidator,
|
||||
stats: statsValidator,
|
||||
statsDownloads: v.optional(v.number()),
|
||||
@@ -719,6 +744,7 @@ const packageReleases = defineTable({
|
||||
),
|
||||
source: v.optional(v.any()),
|
||||
createdBy: v.id("users"),
|
||||
publishActor: packagePublishActorValidator,
|
||||
createdAt: v.number(),
|
||||
softDeletedAt: v.optional(v.number()),
|
||||
})
|
||||
@@ -727,6 +753,50 @@ const packageReleases = defineTable({
|
||||
.index("by_package_version", ["packageId", "version"])
|
||||
.index("by_sha256hash", ["sha256hash"]);
|
||||
|
||||
const packageTrustedPublishers = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
provider: v.literal("github-actions"),
|
||||
repository: v.string(),
|
||||
repositoryId: v.string(),
|
||||
repositoryOwner: v.string(),
|
||||
repositoryOwnerId: v.string(),
|
||||
workflowFilename: v.string(),
|
||||
environment: v.optional(v.string()),
|
||||
createdByUserId: v.id("users"),
|
||||
updatedByUserId: v.id("users"),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("by_package", ["packageId"])
|
||||
.index("by_repository", ["repository", "workflowFilename"]);
|
||||
|
||||
const packagePublishTokens = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
version: v.string(),
|
||||
prefix: v.string(),
|
||||
tokenHash: v.string(),
|
||||
provider: v.literal("github-actions"),
|
||||
repository: v.string(),
|
||||
repositoryId: v.string(),
|
||||
repositoryOwner: v.string(),
|
||||
repositoryOwnerId: v.string(),
|
||||
workflowFilename: v.string(),
|
||||
environment: v.optional(v.string()),
|
||||
runId: v.string(),
|
||||
runAttempt: v.string(),
|
||||
sha: v.string(),
|
||||
ref: v.string(),
|
||||
refType: v.optional(v.string()),
|
||||
actor: v.optional(v.string()),
|
||||
actorId: v.optional(v.string()),
|
||||
expiresAt: v.number(),
|
||||
lastUsedAt: v.optional(v.number()),
|
||||
revokedAt: v.optional(v.number()),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_hash", ["tokenHash"])
|
||||
.index("by_package", ["packageId", "version", "createdAt"]);
|
||||
|
||||
const packageSearchDigest = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
name: v.string(),
|
||||
@@ -844,12 +914,7 @@ const packageCapabilitySearchDigest = defineTable({
|
||||
"executesCode",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_active_family_tag_updated", [
|
||||
"softDeletedAt",
|
||||
"family",
|
||||
"capabilityTag",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_active_family_tag_updated", ["softDeletedAt", "family", "capabilityTag", "updatedAt"])
|
||||
.index("by_active_family_tag_executes_updated", [
|
||||
"softDeletedAt",
|
||||
"family",
|
||||
@@ -1255,6 +1320,8 @@ export default defineSchema({
|
||||
skillSlugAliases,
|
||||
packages,
|
||||
packageReleases,
|
||||
packageTrustedPublishers,
|
||||
packagePublishTokens,
|
||||
packageSearchDigest,
|
||||
packageCapabilitySearchDigest,
|
||||
souls,
|
||||
|
||||
+59
-3
@@ -240,9 +240,14 @@ describe("search helpers", () => {
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue(
|
||||
vectorEntries.map((entry, index) => ({ _id: entry.embeddingId, _score: 0.9 - index * 0.01 })),
|
||||
),
|
||||
vectorSearch: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
vectorEntries.map((entry, index) => ({
|
||||
_id: entry.embeddingId,
|
||||
_score: 0.9 - index * 0.01,
|
||||
})),
|
||||
),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "skill-downloader", limit: 10 },
|
||||
@@ -340,6 +345,55 @@ describe("search helpers", () => {
|
||||
expect(result[0].skill.slug).toBe("downloader-1");
|
||||
});
|
||||
|
||||
it("filters vector search results by capability tag", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
embeddingId: "skillEmbeddings:crypto",
|
||||
skill: makePublicSkill({
|
||||
id: "skills:crypto",
|
||||
slug: "wallet-helper",
|
||||
displayName: "Wallet Helper",
|
||||
capabilityTags: ["crypto", "requires-wallet"],
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
},
|
||||
{
|
||||
embeddingId: "skillEmbeddings:oauth",
|
||||
skill: makePublicSkill({
|
||||
id: "skills:oauth",
|
||||
slug: "x-poster",
|
||||
displayName: "X Poster",
|
||||
capabilityTags: ["requires-oauth-token", "posts-externally"],
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([
|
||||
{ _id: "skillEmbeddings:crypto", _score: 0.9 },
|
||||
{ _id: "skillEmbeddings:oauth", _score: 0.8 },
|
||||
]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "helper", limit: 10, capabilityTag: "crypto" },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].skill.slug).toBe("wallet-helper");
|
||||
});
|
||||
|
||||
it("deduplicates exact slug injection against vector exact matches", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
@@ -819,6 +873,7 @@ function makePublicSkill(params: {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
downloads?: number;
|
||||
capabilityTags?: string[];
|
||||
}) {
|
||||
return {
|
||||
_id: params.id,
|
||||
@@ -831,6 +886,7 @@ function makePublicSkill(params: {
|
||||
forkOf: undefined,
|
||||
latestVersionId: "skillVersions:1",
|
||||
tags: {},
|
||||
capabilityTags: params.capabilityTags,
|
||||
badges: {},
|
||||
stats: {
|
||||
downloads: params.downloads ?? 0,
|
||||
|
||||
+32
-12
@@ -7,6 +7,7 @@ import { isSkillHighlighted } from "./lib/badges";
|
||||
import { generateEmbedding } from "./lib/embeddings";
|
||||
import type { HydratableSkill, PublicPublisher } from "./lib/public";
|
||||
import { toPublicPublisher, toPublicSkill, toPublicSoul } from "./lib/public";
|
||||
import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags";
|
||||
import { getOwnerPublisher } from "./lib/publishers";
|
||||
import { matchesExactTokens, tokenize } from "./lib/searchText";
|
||||
import { isSkillSuspicious } from "./lib/skillSafety";
|
||||
@@ -51,6 +52,7 @@ const NAME_EXACT_BOOST = 1.1;
|
||||
const NAME_PREFIX_BOOST = 0.6;
|
||||
const POPULARITY_WEIGHT = 0.08;
|
||||
const FALLBACK_SCAN_LIMIT = 500;
|
||||
const SKILL_CAPABILITY_TAG_SET = new Set<string>(SKILL_CAPABILITY_TAGS);
|
||||
|
||||
function getNextCandidateLimit(current: number, max: number) {
|
||||
const next = Math.min(current * 2, max);
|
||||
@@ -120,27 +122,38 @@ function isSlugLikeQuery(query: string) {
|
||||
return /^[a-z0-9][a-z0-9-]*$/.test(query.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function matchesCapabilityTag(
|
||||
skill: Pick<HydratableSkill, "capabilityTags">,
|
||||
capabilityTag?: string,
|
||||
) {
|
||||
if (!capabilityTag) return true;
|
||||
return (skill.capabilityTags ?? []).includes(capabilityTag);
|
||||
}
|
||||
|
||||
export const searchSkills: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
query: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SearchResult[]> => {
|
||||
const query = args.query.trim();
|
||||
if (!query) return [];
|
||||
if (args.capabilityTag && !SKILL_CAPABILITY_TAG_SET.has(args.capabilityTag)) return [];
|
||||
const queryTokens = tokenize(query);
|
||||
if (queryTokens.length === 0) return [];
|
||||
const rawExactSlugMatch =
|
||||
isSlugLikeQuery(query)
|
||||
? ((await ctx.runQuery(internal.search.getExactSkillSlugMatch, {
|
||||
slug: query.toLowerCase(),
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
||||
})) as SkillSearchEntry | null)
|
||||
: null;
|
||||
const rawExactSlugMatch = isSlugLikeQuery(query)
|
||||
? ((await ctx.runQuery(internal.search.getExactSkillSlugMatch, {
|
||||
slug: query.toLowerCase(),
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
||||
})) as SkillSearchEntry | null)
|
||||
: null;
|
||||
const exactSlugMatch =
|
||||
rawExactSlugMatch && (!args.highlightedOnly || isSkillHighlighted(rawExactSlugMatch.skill))
|
||||
rawExactSlugMatch &&
|
||||
(!args.highlightedOnly || isSkillHighlighted(rawExactSlugMatch.skill)) &&
|
||||
matchesCapabilityTag(rawExactSlugMatch.skill, args.capabilityTag)
|
||||
? rawExactSlugMatch
|
||||
: null;
|
||||
let vector: number[];
|
||||
@@ -186,9 +199,11 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
|
||||
// Skills already have badges from their docs (via toPublicSkill).
|
||||
// No need for a separate badge table lookup.
|
||||
const filtered = args.highlightedOnly
|
||||
? hydrated.filter((entry) => isSkillHighlighted(entry.skill))
|
||||
: hydrated;
|
||||
const filtered = hydrated.filter(
|
||||
(entry) =>
|
||||
(!args.highlightedOnly || isSkillHighlighted(entry.skill)) &&
|
||||
matchesCapabilityTag(entry.skill, args.capabilityTag),
|
||||
);
|
||||
|
||||
exactMatches = filtered.filter((entry) =>
|
||||
matchesExactTokens(queryTokens, [
|
||||
@@ -220,6 +235,7 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
limit: Math.min(Math.max(limit * 4, 200), FALLBACK_SCAN_LIMIT),
|
||||
highlightedOnly: args.highlightedOnly,
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
||||
capabilityTag: args.capabilityTag,
|
||||
skipExactSlugLookup: true,
|
||||
})) as SkillSearchEntry[]);
|
||||
const mergedMatches = mergeUniqueBySkillId(primaryMatches, fallbackMatches);
|
||||
@@ -331,9 +347,11 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
limit: v.optional(v.number()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
skipExactSlugLookup: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
||||
if (args.capabilityTag && !SKILL_CAPABILITY_TAG_SET.has(args.capabilityTag)) return [];
|
||||
const limit = Math.min(Math.max(args.limit ?? 200, 10), FALLBACK_SCAN_LIMIT);
|
||||
const seenSkillIds = new Set<Id<"skills">>();
|
||||
const candidates: HydratableSkill[] = [];
|
||||
@@ -353,7 +371,8 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
if (
|
||||
exactSlugSkill &&
|
||||
!exactSlugSkill.softDeletedAt &&
|
||||
(!args.nonSuspiciousOnly || !isSkillSuspicious(exactSlugSkill))
|
||||
(!args.nonSuspiciousOnly || !isSkillSuspicious(exactSlugSkill)) &&
|
||||
matchesCapabilityTag(exactSlugSkill, args.capabilityTag)
|
||||
) {
|
||||
seenSkillIds.add(exactSlugSkill._id);
|
||||
candidates.push(exactSlugSkill);
|
||||
@@ -371,6 +390,7 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
if (seenSkillIds.has(digest.skillId)) continue;
|
||||
const skill = digestToHydratableSkill(digest);
|
||||
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) continue;
|
||||
if (!matchesCapabilityTag(skill, args.capabilityTag)) continue;
|
||||
seenSkillIds.add(digest.skillId);
|
||||
candidates.push(skill);
|
||||
// Pre-resolve owner from digest to avoid users table reads.
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { internalMutation } from "./functions";
|
||||
|
||||
const DEMO_SKILLS = [
|
||||
{
|
||||
slug: "mcp-github",
|
||||
displayName: "MCP GitHub",
|
||||
summary: "Full GitHub API integration via MCP — issues, PRs, repos, code search, and actions.",
|
||||
downloads: 14200,
|
||||
stars: 342,
|
||||
installs: 8100,
|
||||
},
|
||||
{
|
||||
slug: "claude-memory",
|
||||
displayName: "Claude Memory",
|
||||
summary: "Persistent memory layer for Claude — stores context across conversations with vector recall.",
|
||||
downloads: 11800,
|
||||
stars: 287,
|
||||
installs: 6400,
|
||||
},
|
||||
{
|
||||
slug: "web-scraper-pro",
|
||||
displayName: "Web Scraper Pro",
|
||||
summary: "Intelligent web scraping with automatic pagination, JS rendering, and structured data extraction.",
|
||||
downloads: 9400,
|
||||
stars: 198,
|
||||
installs: 5200,
|
||||
},
|
||||
{
|
||||
slug: "sql-analyst",
|
||||
displayName: "SQL Analyst",
|
||||
summary: "Natural language to SQL with schema introspection, query optimization, and result visualization.",
|
||||
downloads: 8700,
|
||||
stars: 221,
|
||||
installs: 4800,
|
||||
},
|
||||
{
|
||||
slug: "pytest-agent",
|
||||
displayName: "Pytest Agent",
|
||||
summary: "Automated test generation and execution for Python — coverage analysis, mutation testing, fixtures.",
|
||||
downloads: 7200,
|
||||
stars: 156,
|
||||
installs: 3900,
|
||||
},
|
||||
{
|
||||
slug: "docker-compose-helper",
|
||||
displayName: "Docker Compose Helper",
|
||||
summary: "Generate, validate, and debug Docker Compose configurations with multi-service orchestration.",
|
||||
downloads: 6800,
|
||||
stars: 134,
|
||||
installs: 3600,
|
||||
},
|
||||
{
|
||||
slug: "api-docs-generator",
|
||||
displayName: "API Docs Generator",
|
||||
summary: "Auto-generate OpenAPI specs and beautiful documentation from any codebase or endpoint.",
|
||||
downloads: 5900,
|
||||
stars: 178,
|
||||
installs: 3100,
|
||||
},
|
||||
{
|
||||
slug: "slack-bot-builder",
|
||||
displayName: "Slack Bot Builder",
|
||||
summary: "Build and deploy Slack bots with natural language — slash commands, modals, and event handlers.",
|
||||
downloads: 5400,
|
||||
stars: 112,
|
||||
installs: 2800,
|
||||
},
|
||||
{
|
||||
slug: "terraform-assistant",
|
||||
displayName: "Terraform Assistant",
|
||||
summary: "Infrastructure as code helper — plan reviews, drift detection, module generation for AWS/GCP/Azure.",
|
||||
downloads: 4800,
|
||||
stars: 145,
|
||||
installs: 2400,
|
||||
},
|
||||
{
|
||||
slug: "regex-wizard",
|
||||
displayName: "Regex Wizard",
|
||||
summary: "Natural language to regex with live testing, explanation, and edge case generation.",
|
||||
downloads: 4200,
|
||||
stars: 89,
|
||||
installs: 2100,
|
||||
},
|
||||
{
|
||||
slug: "git-history-explorer",
|
||||
displayName: "Git History Explorer",
|
||||
summary: "Semantic search through git history — find commits by intent, trace code evolution, blame analysis.",
|
||||
downloads: 3900,
|
||||
stars: 102,
|
||||
installs: 1900,
|
||||
},
|
||||
{
|
||||
slug: "cron-scheduler",
|
||||
displayName: "Cron Scheduler",
|
||||
summary: "Natural language to cron expressions with timezone handling, overlap protection, and monitoring.",
|
||||
downloads: 3400,
|
||||
stars: 67,
|
||||
installs: 1600,
|
||||
},
|
||||
{
|
||||
slug: "jwt-debugger",
|
||||
displayName: "JWT Debugger",
|
||||
summary: "Decode, verify, and generate JWTs with visual payload inspection and expiry tracking.",
|
||||
downloads: 3100,
|
||||
stars: 78,
|
||||
installs: 1400,
|
||||
},
|
||||
{
|
||||
slug: "graphql-builder",
|
||||
displayName: "GraphQL Builder",
|
||||
summary: "Schema-first GraphQL development — type generation, resolver scaffolding, and playground integration.",
|
||||
downloads: 2800,
|
||||
stars: 94,
|
||||
installs: 1200,
|
||||
},
|
||||
{
|
||||
slug: "security-scanner",
|
||||
displayName: "Security Scanner",
|
||||
summary: "OWASP-aware security scanning for codebases — dependency audit, secret detection, SAST patterns.",
|
||||
downloads: 2500,
|
||||
stars: 156,
|
||||
installs: 1100,
|
||||
},
|
||||
{
|
||||
slug: "markdown-slides",
|
||||
displayName: "Markdown Slides",
|
||||
summary: "Turn markdown into presentation decks with themes, speaker notes, and PDF export.",
|
||||
downloads: 2200,
|
||||
stars: 45,
|
||||
installs: 900,
|
||||
},
|
||||
{
|
||||
slug: "env-manager",
|
||||
displayName: "Env Manager",
|
||||
summary: "Environment variable management across projects — sync .env files, validate schemas, rotate secrets.",
|
||||
downloads: 1800,
|
||||
stars: 56,
|
||||
installs: 800,
|
||||
},
|
||||
{
|
||||
slug: "csv-transform",
|
||||
displayName: "CSV Transform",
|
||||
summary: "Powerful CSV/TSV manipulation — column transforms, joins, pivots, and format conversion.",
|
||||
downloads: 1500,
|
||||
stars: 34,
|
||||
installs: 600,
|
||||
},
|
||||
{
|
||||
slug: "ssh-config-manager",
|
||||
displayName: "SSH Config Manager",
|
||||
summary: "Manage SSH configs, keys, and tunnels with natural language — jump hosts, port forwarding, agent setup.",
|
||||
downloads: 1200,
|
||||
stars: 42,
|
||||
installs: 500,
|
||||
},
|
||||
{
|
||||
slug: "changelog-writer",
|
||||
displayName: "Changelog Writer",
|
||||
summary: "Generate changelogs from git history with conventional commit parsing and release note formatting.",
|
||||
downloads: 980,
|
||||
stars: 28,
|
||||
installs: 400,
|
||||
},
|
||||
];
|
||||
|
||||
const DEMO_OWNERS = [
|
||||
{ handle: "anthropic", displayName: "Anthropic", highlighted: true },
|
||||
{ handle: "openai-labs", displayName: "OpenAI Labs", highlighted: false },
|
||||
{ handle: "devtools-co", displayName: "DevTools Co", highlighted: false },
|
||||
{ handle: "securityfirst", displayName: "SecurityFirst", highlighted: true },
|
||||
{ handle: "dataflow", displayName: "DataFlow", highlighted: false },
|
||||
];
|
||||
|
||||
export const seedDemoSkills = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
// Check if we already seeded
|
||||
const existingSkill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", "mcp-github"))
|
||||
.first();
|
||||
if (existingSkill) {
|
||||
return { seeded: false, reason: "already seeded" };
|
||||
}
|
||||
|
||||
// Create a seed user
|
||||
const seedUserId = await ctx.db.insert("users", {
|
||||
name: "ClawHub Demo",
|
||||
displayName: "ClawHub Demo",
|
||||
handle: "clawhub-demo",
|
||||
image: undefined,
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
// Create publisher accounts
|
||||
const publisherIds: string[] = [];
|
||||
for (const owner of DEMO_OWNERS) {
|
||||
const pubId = await ctx.db.insert("publishers", {
|
||||
kind: "org",
|
||||
handle: owner.handle,
|
||||
displayName: owner.displayName,
|
||||
linkedUserId: seedUserId,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
publisherIds.push(pubId);
|
||||
|
||||
// Add membership
|
||||
await ctx.db.insert("publisherMembers", {
|
||||
publisherId: pubId as Id<"publishers">,
|
||||
userId: seedUserId,
|
||||
role: "owner",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const DAY = 86400000;
|
||||
let totalPublishedSkills = 0;
|
||||
let totalStars = 0;
|
||||
let totalDownloads = 0;
|
||||
|
||||
for (let i = 0; i < DEMO_SKILLS.length; i++) {
|
||||
const s = DEMO_SKILLS[i];
|
||||
const ownerIdx = i % publisherIds.length;
|
||||
const createdDaysAgo = Math.floor(Math.random() * 90) + 7;
|
||||
const updatedDaysAgo = Math.floor(Math.random() * createdDaysAgo);
|
||||
const createdAt = now - createdDaysAgo * DAY;
|
||||
const updatedAt = now - updatedDaysAgo * DAY;
|
||||
const version = `${Math.floor(Math.random() * 3) + 1}.${Math.floor(Math.random() * 10)}.${Math.floor(Math.random() * 20)}`;
|
||||
|
||||
const isHighlighted = i < 6;
|
||||
const badges = isHighlighted
|
||||
? { highlighted: { byUserId: seedUserId, at: now } }
|
||||
: undefined;
|
||||
|
||||
const numVersions = Math.floor(Math.random() * 8) + 1;
|
||||
const numComments = Math.floor(Math.random() * 15);
|
||||
|
||||
// Create skill first (without latestVersionId)
|
||||
const skillId = await ctx.db.insert("skills", {
|
||||
slug: s.slug,
|
||||
displayName: s.displayName,
|
||||
summary: s.summary,
|
||||
ownerUserId: seedUserId,
|
||||
ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
|
||||
tags: {},
|
||||
badges,
|
||||
moderationStatus: "active",
|
||||
moderationVerdict: "clean",
|
||||
stats: {
|
||||
downloads: s.downloads,
|
||||
installsCurrent: Math.floor(s.installs * 0.3),
|
||||
installsAllTime: s.installs,
|
||||
stars: s.stars,
|
||||
versions: numVersions,
|
||||
comments: numComments,
|
||||
},
|
||||
statsDownloads: s.downloads,
|
||||
statsStars: s.stars,
|
||||
statsInstallsCurrent: Math.floor(s.installs * 0.3),
|
||||
statsInstallsAllTime: s.installs,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
// Create skillBadges entry for highlighted skills
|
||||
if (isHighlighted) {
|
||||
await ctx.db.insert("skillBadges", {
|
||||
skillId,
|
||||
kind: "highlighted",
|
||||
byUserId: seedUserId,
|
||||
at: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Now create version with real skillId
|
||||
const versionId = await ctx.db.insert("skillVersions", {
|
||||
skillId,
|
||||
version,
|
||||
changelog: `Release ${version} — improvements and bug fixes.`,
|
||||
files: [],
|
||||
parsed: { frontmatter: {} },
|
||||
createdBy: seedUserId,
|
||||
createdAt: updatedAt,
|
||||
});
|
||||
|
||||
// Patch skill with version info
|
||||
await ctx.db.patch(skillId, {
|
||||
latestVersionId: versionId,
|
||||
latestVersionSummary: {
|
||||
version,
|
||||
createdAt: updatedAt,
|
||||
changelog: `Release ${version}`,
|
||||
},
|
||||
tags: { latest: versionId },
|
||||
});
|
||||
|
||||
totalPublishedSkills += 1;
|
||||
totalStars += s.stars;
|
||||
totalDownloads += s.downloads;
|
||||
|
||||
// Create digest for search
|
||||
await ctx.db.insert("skillSearchDigest", {
|
||||
skillId,
|
||||
slug: s.slug,
|
||||
displayName: s.displayName,
|
||||
summary: s.summary,
|
||||
ownerUserId: seedUserId,
|
||||
ownerPublisherId: publisherIds[ownerIdx] as Id<"publishers">,
|
||||
ownerHandle: DEMO_OWNERS[ownerIdx].handle,
|
||||
ownerName: DEMO_OWNERS[ownerIdx].displayName,
|
||||
ownerDisplayName: DEMO_OWNERS[ownerIdx].displayName,
|
||||
ownerImage: undefined,
|
||||
latestVersionId: versionId,
|
||||
latestVersionSummary: {
|
||||
version,
|
||||
createdAt: updatedAt,
|
||||
changelog: `Release ${version}`,
|
||||
},
|
||||
tags: { latest: versionId },
|
||||
badges,
|
||||
stats: {
|
||||
downloads: s.downloads,
|
||||
installsCurrent: Math.floor(s.installs * 0.3),
|
||||
installsAllTime: s.installs,
|
||||
stars: s.stars,
|
||||
versions: numVersions,
|
||||
comments: numComments,
|
||||
},
|
||||
versions: numVersions,
|
||||
comments: numComments,
|
||||
moderationReason: undefined,
|
||||
isSuspicious: false,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.patch(seedUserId, {
|
||||
publishedSkills: totalPublishedSkills,
|
||||
totalStars,
|
||||
totalDownloads,
|
||||
});
|
||||
|
||||
return { seeded: true, count: DEMO_SKILLS.length };
|
||||
},
|
||||
});
|
||||
|
||||
// Repair globalStats count to match actual seeded data
|
||||
export const repairGlobalStats = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
// Count active digests — push filter server-side
|
||||
const digests = await ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
|
||||
.filter((q) => q.eq(q.field("moderationStatus"), "active"))
|
||||
.collect();
|
||||
|
||||
const count = digests.length;
|
||||
|
||||
// Update globalStats
|
||||
const stats = await ctx.db
|
||||
.query("globalStats")
|
||||
.filter((q) => q.eq(q.field("key"), "default"))
|
||||
.first();
|
||||
|
||||
if (stats) {
|
||||
await ctx.db.patch(stats._id, { activeSkillsCount: count, updatedAt: Date.now() });
|
||||
} else {
|
||||
await ctx.db.insert("globalStats", {
|
||||
key: "default",
|
||||
activeSkillsCount: count,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return { count };
|
||||
},
|
||||
});
|
||||
|
||||
// Repair function to add missing skillBadges for already-seeded data
|
||||
export const repairHighlightedBadges = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const highlightedSlugs = DEMO_SKILLS.slice(0, 6).map((s) => s.slug);
|
||||
let fixed = 0;
|
||||
|
||||
for (const slug of highlightedSlugs) {
|
||||
const skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.first();
|
||||
if (!skill) continue;
|
||||
|
||||
// Check if badge already exists
|
||||
const existing = await ctx.db
|
||||
.query("skillBadges")
|
||||
.withIndex("by_skill_kind", (q) =>
|
||||
q.eq("skillId", skill._id).eq("kind", "highlighted"),
|
||||
)
|
||||
.first();
|
||||
if (existing) continue;
|
||||
|
||||
await ctx.db.insert("skillBadges", {
|
||||
skillId: skill._id,
|
||||
kind: "highlighted",
|
||||
byUserId: skill.ownerUserId,
|
||||
at: Date.now(),
|
||||
});
|
||||
fixed++;
|
||||
}
|
||||
|
||||
return { fixed };
|
||||
},
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery } from "./functions";
|
||||
import { applySkillStatDeltas, bumpDailySkillStats } from "./lib/skillStats";
|
||||
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
|
||||
|
||||
/**
|
||||
* Event types that affect skill stats:
|
||||
@@ -259,6 +260,7 @@ export const processSkillStatEventsInternal = internalMutation({
|
||||
// Don't update `updatedAt` — stat changes shouldn't move the
|
||||
// skill's position in the by_active_updated index.
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ...patch });
|
||||
}
|
||||
|
||||
// NOTE: Daily stats (skillDailyStats) are written by the 15-minute
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("skillTransfers", () => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
first: async () => ({ _id: "users:2", handle: "alice", displayName: "Alice" }),
|
||||
unique: async () => ({ _id: "users:2", handle: "alice", displayName: "Alice" }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -102,6 +102,242 @@ describe("skillTransfers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("requestTransferInternal resolves recipient via personal publisher handle", async () => {
|
||||
const insert = vi.fn(async (table: string) => {
|
||||
if (table === "skillOwnershipTransfers") return "skillOwnershipTransfers:new";
|
||||
return "auditLogs:1";
|
||||
});
|
||||
|
||||
const result = (await requestTransferInternalHandler(
|
||||
{
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:1") return { _id: "users:1", handle: "owner" };
|
||||
if (id === "users:2") {
|
||||
return {
|
||||
_id: "users:2",
|
||||
handle: undefined,
|
||||
name: "Alice",
|
||||
displayName: "Alice",
|
||||
};
|
||||
}
|
||||
if (id === "skills:1") {
|
||||
return {
|
||||
_id: "skills:1",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
ownerUserId: "users:1",
|
||||
};
|
||||
}
|
||||
if (id === "publishers:alice") {
|
||||
return {
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
displayName: "Alice",
|
||||
linkedUserId: "users:2",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
displayName: "Alice",
|
||||
linkedUserId: "users:2",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillOwnershipTransfers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
collect: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch: vi.fn(async () => {}),
|
||||
insert,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
actorUserId: "users:1",
|
||||
skillId: "skills:1",
|
||||
toUserHandle: "@alice",
|
||||
} as never,
|
||||
)) as { ok: boolean; transferId: string };
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
transferId: "skillOwnershipTransfers:new",
|
||||
toUserHandle: "alice",
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"skillOwnershipTransfers",
|
||||
expect.objectContaining({
|
||||
toUserId: "users:2",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("acceptTransferInternal updates skill and alias ownership to the recipient publisher", async () => {
|
||||
const patch = vi.fn(async () => {});
|
||||
const insert = vi.fn(async () => "auditLogs:1");
|
||||
const newPublisher = {
|
||||
_id: "publishers:alice",
|
||||
handle: "alice",
|
||||
displayName: "Alice",
|
||||
linkedUserId: "users:2",
|
||||
trustedPublisher: false,
|
||||
};
|
||||
const existingMember = {
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:alice",
|
||||
userId: "users:2",
|
||||
role: "owner",
|
||||
};
|
||||
const aliases = [
|
||||
{
|
||||
_id: "skillSlugAliases:1",
|
||||
slug: "demo-old",
|
||||
skillId: "skills:1",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:owner",
|
||||
},
|
||||
{
|
||||
_id: "skillSlugAliases:2",
|
||||
slug: "demo-legacy",
|
||||
skillId: "skills:1",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:owner",
|
||||
},
|
||||
];
|
||||
|
||||
const result = (await acceptTransferInternalHandler(
|
||||
{
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:2") {
|
||||
return {
|
||||
_id: "users:2",
|
||||
handle: "alice",
|
||||
personalPublisherId: "publishers:alice",
|
||||
trustedPublisher: false,
|
||||
};
|
||||
}
|
||||
if (id === "skillOwnershipTransfers:1") {
|
||||
return {
|
||||
_id: "skillOwnershipTransfers:1",
|
||||
skillId: "skills:1",
|
||||
fromUserId: "users:1",
|
||||
toUserId: "users:2",
|
||||
status: "pending",
|
||||
requestedAt: Date.now() - 1_000,
|
||||
expiresAt: Date.now() + 10_000,
|
||||
};
|
||||
}
|
||||
if (id === "skills:1") {
|
||||
return {
|
||||
_id: "skills:1",
|
||||
slug: "demo",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:owner",
|
||||
};
|
||||
}
|
||||
if (id === "publishers:alice") {
|
||||
return newPublisher;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "skillSlugAliases") {
|
||||
return {
|
||||
withIndex: (indexName: string) => {
|
||||
expect(indexName).toBe("by_skill");
|
||||
return {
|
||||
collect: async () => aliases,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (indexName: string) => {
|
||||
expect(indexName).toBe("by_handle");
|
||||
return {
|
||||
unique: async () => newPublisher,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: (indexName: string) => {
|
||||
expect(indexName).toBe("by_publisher_user");
|
||||
return {
|
||||
unique: async () => existingMember,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
insert,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
actorUserId: "users:2",
|
||||
transferId: "skillOwnershipTransfers:1",
|
||||
} as never,
|
||||
)) as { ok: boolean; skillSlug: string };
|
||||
|
||||
expect(result).toEqual({ ok: true, skillSlug: "demo" });
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:2",
|
||||
ownerPublisherId: "publishers:alice",
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillSlugAliases:1",
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:2",
|
||||
ownerPublisherId: "publishers:alice",
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillSlugAliases:2",
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:2",
|
||||
ownerPublisherId: "publishers:alice",
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillOwnershipTransfers:1",
|
||||
expect.objectContaining({ status: "accepted" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("acceptTransferInternal cancels stale transfer when ownership changed", async () => {
|
||||
const patch = vi.fn(async () => {});
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { v } from "convex/values";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { internalMutation, internalQuery } from "./functions";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type TransferDoc = Doc<"skillOwnershipTransfers">;
|
||||
@@ -111,11 +115,8 @@ export const requestTransferInternal = internalMutation({
|
||||
const toHandle = normalizeHandle(args.toUserHandle);
|
||||
if (!toHandle) throw new Error("toUserHandle required");
|
||||
|
||||
const toUser = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", toHandle))
|
||||
.first();
|
||||
if (!toUser || toUser.deletedAt || toUser.deactivatedAt) throw new Error("User not found");
|
||||
const toUser = await getActiveUserByHandleOrPersonalPublisher(ctx, toHandle);
|
||||
if (!toUser) throw new Error("User not found");
|
||||
if (toUser._id === args.actorUserId) throw new Error("Cannot transfer to yourself");
|
||||
|
||||
const activePending = await getActivePendingTransferForSkill(ctx, args.skillId, now);
|
||||
@@ -157,7 +158,7 @@ export const acceptTransferInternal = internalMutation({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
await requireActiveUserById(ctx, args.actorUserId);
|
||||
const newOwner = await requireActiveUserById(ctx, args.actorUserId);
|
||||
|
||||
const transfer = await validatePendingTransferForActor(ctx, {
|
||||
transferId: args.transferId,
|
||||
@@ -173,10 +174,27 @@ export const acceptTransferInternal = internalMutation({
|
||||
throw new Error("Transfer is no longer valid");
|
||||
}
|
||||
|
||||
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
|
||||
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
ownerUserId: args.actorUserId,
|
||||
ownerPublisherId: newPublisher._id,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const aliases = await ctx.db
|
||||
.query("skillSlugAliases")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", skill._id))
|
||||
.collect();
|
||||
for (const alias of aliases) {
|
||||
await ctx.db.patch(alias._id, {
|
||||
ownerUserId: args.actorUserId,
|
||||
ownerPublisherId: newPublisher._id,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.patch(transfer._id, { status: "accepted", respondedAt: now });
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
authTables: {},
|
||||
}));
|
||||
|
||||
const { getAuthUserId } = await import("@convex-dev/auth/server");
|
||||
const { deleteTags } = await import("./skills");
|
||||
|
||||
type WrappedHandler<TArgs, TResult = unknown> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const deleteTagsHandler = (
|
||||
deleteTags as unknown as WrappedHandler<{
|
||||
skillId: string;
|
||||
tags: string[];
|
||||
}>
|
||||
)._handler;
|
||||
|
||||
function buildGlobalStatsQuery(table: string) {
|
||||
if (table !== "globalStats") return null;
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({ _id: "globalStats:1", activeSkillsCount: 100 }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDigestQuery(table: string) {
|
||||
if (table !== "skillSearchDigest") return null;
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(params: { user: Record<string, unknown>; skill: Record<string, unknown> | null }) {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue(params.user._id as never);
|
||||
const patch = vi.fn(async (_id: string, value: Record<string, unknown>) => value);
|
||||
const db = {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === params.user._id) return params.user;
|
||||
if (params.skill && id === params.skill._id) return params.skill;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
const globalStatsQuery = buildGlobalStatsQuery(table);
|
||||
if (globalStatsQuery) return globalStatsQuery;
|
||||
const digestQuery = buildDigestQuery(table);
|
||||
if (digestQuery) return digestQuery;
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
insert: vi.fn(),
|
||||
patch,
|
||||
delete: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(() => null),
|
||||
};
|
||||
const auth = { getUserIdentity: vi.fn(async () => ({ tokenIdentifier: "test" })) };
|
||||
return { db, auth, patch };
|
||||
}
|
||||
|
||||
const ownerUser = {
|
||||
_id: "users:owner",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
role: undefined,
|
||||
};
|
||||
|
||||
const modUser = {
|
||||
_id: "users:mod",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
role: "moderator",
|
||||
};
|
||||
|
||||
const otherUser = {
|
||||
_id: "users:other",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
role: undefined,
|
||||
};
|
||||
|
||||
const baseSkill = {
|
||||
_id: "skills:1",
|
||||
ownerUserId: "users:owner",
|
||||
tags: {
|
||||
latest: "versions:3",
|
||||
stable: "versions:2",
|
||||
beta: "versions:3",
|
||||
"old-tag": "versions:1",
|
||||
},
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
|
||||
describe("deleteTags", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getAuthUserId).mockReset();
|
||||
});
|
||||
|
||||
it("deletes specified tags and keeps latest", async () => {
|
||||
const { db, auth, patch } = makeCtx({ user: ownerUser, skill: baseSkill });
|
||||
await deleteTagsHandler(
|
||||
{ db, auth } as never,
|
||||
{ skillId: "skills:1", tags: ["stable", "old-tag"] } as never,
|
||||
);
|
||||
expect(patch).toHaveBeenCalledOnce();
|
||||
const patchArgs = patch.mock.calls[0];
|
||||
expect(patchArgs[1]).toHaveProperty("tags");
|
||||
const newTags = (patchArgs[1] as Record<string, unknown>).tags as Record<string, string>;
|
||||
expect(newTags).toHaveProperty("latest");
|
||||
expect(newTags).toHaveProperty("beta");
|
||||
expect(newTags).not.toHaveProperty("stable");
|
||||
expect(newTags).not.toHaveProperty("old-tag");
|
||||
});
|
||||
|
||||
it("protects the latest tag from deletion", async () => {
|
||||
const { db, auth, patch } = makeCtx({ user: ownerUser, skill: baseSkill });
|
||||
await deleteTagsHandler(
|
||||
{ db, auth } as never,
|
||||
{ skillId: "skills:1", tags: ["latest"] } as never,
|
||||
);
|
||||
// No actual tag removed → no db.patch call
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips db write when no tags are actually removed", async () => {
|
||||
const { db, auth, patch } = makeCtx({ user: ownerUser, skill: baseSkill });
|
||||
await deleteTagsHandler(
|
||||
{ db, auth } as never,
|
||||
{ skillId: "skills:1", tags: ["nonexistent", "latest"] } as never,
|
||||
);
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws for non-owner non-moderator user", async () => {
|
||||
const { db, auth } = makeCtx({ user: otherUser, skill: baseSkill });
|
||||
await expect(
|
||||
deleteTagsHandler({ db, auth } as never, { skillId: "skills:1", tags: ["stable"] } as never),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("allows moderator to delete tags on other user's skill", async () => {
|
||||
const { db, auth, patch } = makeCtx({ user: modUser, skill: baseSkill });
|
||||
await deleteTagsHandler(
|
||||
{ db, auth } as never,
|
||||
{ skillId: "skills:1", tags: ["beta"] } as never,
|
||||
);
|
||||
expect(patch).toHaveBeenCalledOnce();
|
||||
const newTags = (patch.mock.calls[0][1] as Record<string, unknown>).tags as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
expect(newTags).not.toHaveProperty("beta");
|
||||
expect(newTags).toHaveProperty("latest");
|
||||
expect(newTags).toHaveProperty("stable");
|
||||
});
|
||||
|
||||
it("throws when skill not found", async () => {
|
||||
const { db, auth } = makeCtx({ user: ownerUser, skill: null });
|
||||
await expect(
|
||||
deleteTagsHandler(
|
||||
{ db, auth } as never,
|
||||
{ skillId: "skills:missing", tags: ["stable"] } as never,
|
||||
),
|
||||
).rejects.toThrow("Skill not found");
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ const listPackageCatalogPageHandler = (
|
||||
family: "skill";
|
||||
channel: "official" | "community";
|
||||
isOfficial: boolean;
|
||||
capabilityTags: string[];
|
||||
}>;
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
@@ -79,6 +80,7 @@ function makeDigest(
|
||||
changelog: "init",
|
||||
},
|
||||
tags: { latest: `skillVersions:${slug}-1` },
|
||||
capabilityTags: [],
|
||||
badges: {},
|
||||
stats: {
|
||||
downloads: 1,
|
||||
@@ -103,8 +105,13 @@ function makeDigest(
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(pages: Array<{ page: Array<Record<string, unknown>>; isDone: boolean; continueCursor: string }>) {
|
||||
const pageByCursor = new Map<string | null, { page: Array<Record<string, unknown>>; isDone: boolean; continueCursor: string }>();
|
||||
function makeCtx(
|
||||
pages: Array<{ page: Array<Record<string, unknown>>; isDone: boolean; continueCursor: string }>,
|
||||
) {
|
||||
const pageByCursor = new Map<
|
||||
string | null,
|
||||
{ page: Array<Record<string, unknown>>; isDone: boolean; continueCursor: string }
|
||||
>();
|
||||
const allDigests = pages.flatMap((page) => page.page);
|
||||
let cursor: string | null = null;
|
||||
for (const page of pages) {
|
||||
@@ -116,7 +123,12 @@ function makeCtx(pages: Array<{ page: Array<Record<string, unknown>>; isDone: bo
|
||||
query: (table: string) => {
|
||||
if (table === "skills") {
|
||||
return {
|
||||
withIndex: (_index: string, builder: (q: { eq: (field: string, value: string) => { field: string; value: string } }) => { field: string; value: string }) => {
|
||||
withIndex: (
|
||||
_index: string,
|
||||
builder: (q: {
|
||||
eq: (field: string, value: string) => { field: string; value: string };
|
||||
}) => { field: string; value: string },
|
||||
) => {
|
||||
const constraint = builder({ eq: (field, value) => ({ field, value }) });
|
||||
return {
|
||||
unique: async () => {
|
||||
@@ -205,4 +217,48 @@ describe("skills package catalog queries", () => {
|
||||
});
|
||||
expect(result[0]?.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("filters skills by capability tag", async () => {
|
||||
const result = await listPackageCatalogPageHandler(
|
||||
makeCtx([
|
||||
{
|
||||
page: [
|
||||
makeDigest("paytoll", { capabilityTags: ["crypto", "requires-wallet"] }),
|
||||
makeDigest("weather"),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
]),
|
||||
{
|
||||
capabilityTag: "crypto",
|
||||
paginationOpts: { cursor: null, numItems: 10 },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.page).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "paytoll",
|
||||
capabilityTags: ["crypto", "requires-wallet"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty immediately for unknown capability tags", async () => {
|
||||
const result = await listPackageCatalogPageHandler(
|
||||
makeCtx([
|
||||
{
|
||||
page: [makeDigest("paytoll", { capabilityTags: ["crypto", "requires-wallet"] })],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
]),
|
||||
{
|
||||
capabilityTag: "not-a-real-tag",
|
||||
paginationOpts: { cursor: null, numItems: 10 },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ page: [], isDone: true, continueCursor: "" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,10 @@ vi.mock("@convex-dev/auth/server", () => ({
|
||||
authTables: {},
|
||||
}));
|
||||
|
||||
import { getPendingScanSkillsInternal } from "./skills";
|
||||
import {
|
||||
getActiveSkillBatchForStaticScanBackfillInternal,
|
||||
getPendingScanSkillsInternal,
|
||||
} from "./skills";
|
||||
|
||||
type PendingScanResult = Array<{
|
||||
skillId: string;
|
||||
@@ -25,6 +28,17 @@ const getPendingScanSkillsHandler = (
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const getStaticScanBackfillBatchHandler = (
|
||||
getActiveSkillBatchForStaticScanBackfillInternal as unknown as WrappedHandler<
|
||||
Record<string, unknown>,
|
||||
{
|
||||
skills: Array<{ skillId: string; versionId: string; slug: string }>;
|
||||
nextCursor: number;
|
||||
done: boolean;
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
|
||||
describe("skills.getPendingScanSkillsInternal", () => {
|
||||
it("includes unresolved VT records from the oldest slice and skips finalized ones", async () => {
|
||||
const recentSkills = [
|
||||
@@ -215,6 +229,114 @@ describe("skills.getPendingScanSkillsInternal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("skills.getActiveSkillBatchForStaticScanBackfillInternal", () => {
|
||||
it("includes latest active skills with missing or stale static scan engine versions", async () => {
|
||||
const skills = [
|
||||
{
|
||||
_id: "skills:missing-static",
|
||||
_creationTime: 10,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
latestVersionId: "skillVersions:missing-static",
|
||||
slug: "missing-static",
|
||||
},
|
||||
{
|
||||
_id: "skills:stale-static",
|
||||
_creationTime: 20,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
latestVersionId: "skillVersions:stale-static",
|
||||
slug: "stale-static",
|
||||
},
|
||||
{
|
||||
_id: "skills:current-static",
|
||||
_creationTime: 30,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
latestVersionId: "skillVersions:current-static",
|
||||
slug: "current-static",
|
||||
},
|
||||
{
|
||||
_id: "skills:hidden-static",
|
||||
_creationTime: 40,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "hidden",
|
||||
latestVersionId: "skillVersions:hidden-static",
|
||||
slug: "hidden-static",
|
||||
},
|
||||
];
|
||||
|
||||
const versions = new Map<string, unknown>([
|
||||
["skillVersions:missing-static", { _id: "skillVersions:missing-static" }],
|
||||
[
|
||||
"skillVersions:stale-static",
|
||||
{
|
||||
_id: "skillVersions:stale-static",
|
||||
staticScan: { engineVersion: "v2.2.0" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"skillVersions:current-static",
|
||||
{
|
||||
_id: "skillVersions:current-static",
|
||||
staticScan: { engineVersion: "v2.4.0" },
|
||||
},
|
||||
],
|
||||
[
|
||||
"skillVersions:hidden-static",
|
||||
{
|
||||
_id: "skillVersions:hidden-static",
|
||||
staticScan: { engineVersion: "v2.2.0" },
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "skills") throw new Error(`unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (
|
||||
indexName: string,
|
||||
builder: (q: { gt: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
builder({ gt: () => ({}) });
|
||||
if (indexName !== "by_creation_time") {
|
||||
throw new Error(`unexpected index ${indexName}`);
|
||||
}
|
||||
return {
|
||||
order: () => ({
|
||||
take: async () => skills,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}),
|
||||
get: vi.fn(async (id: string) => versions.get(id) ?? null),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getStaticScanBackfillBatchHandler(ctx, {
|
||||
batchSize: 10,
|
||||
cursor: 0,
|
||||
});
|
||||
|
||||
expect(result.skills).toEqual([
|
||||
{
|
||||
skillId: "skills:missing-static",
|
||||
versionId: "skillVersions:missing-static",
|
||||
slug: "missing-static",
|
||||
},
|
||||
{
|
||||
skillId: "skills:stale-static",
|
||||
versionId: "skillVersions:stale-static",
|
||||
slug: "stale-static",
|
||||
},
|
||||
]);
|
||||
expect(result.done).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function makeSkill(
|
||||
id: string,
|
||||
versionId: string,
|
||||
|
||||
@@ -35,6 +35,12 @@ const getBySlugHandler = (
|
||||
image: string | null;
|
||||
bio?: string | null;
|
||||
} | null;
|
||||
latestVersion?: {
|
||||
files?: Array<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
} | null;
|
||||
} | null
|
||||
>
|
||||
)._handler;
|
||||
@@ -171,4 +177,70 @@ describe("skills.getBySlug", () => {
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes misleading file MIME types in public version metadata", async () => {
|
||||
const ctx = makeCtx({
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
_creationTime: 1,
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
summary: "Public demo skill",
|
||||
ownerUserId: "users:1",
|
||||
canonicalSkillId: undefined,
|
||||
forkOf: undefined,
|
||||
latestVersionId: "skillVersions:1",
|
||||
tags: {},
|
||||
stats: {
|
||||
downloads: 10,
|
||||
installsCurrent: 2,
|
||||
installsAllTime: 5,
|
||||
stars: 3,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
softDeletedAt: undefined,
|
||||
},
|
||||
owner: {
|
||||
_id: "users:1",
|
||||
_creationTime: 1,
|
||||
handle: "demo-owner",
|
||||
name: "Demo Owner",
|
||||
displayName: "Demo Owner",
|
||||
image: null,
|
||||
},
|
||||
latestVersion: {
|
||||
_id: "skillVersions:1",
|
||||
_creationTime: 2,
|
||||
skillId: "skills:1",
|
||||
version: "1.0.0",
|
||||
fingerprint: "abc",
|
||||
changelog: "",
|
||||
changelogSource: "user",
|
||||
files: [
|
||||
{
|
||||
path: "src/index.ts",
|
||||
size: 10,
|
||||
sha256: "deadbeef",
|
||||
contentType: "video/mp2t",
|
||||
},
|
||||
],
|
||||
createdBy: "users:1",
|
||||
createdAt: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getBySlugHandler(ctx, { slug: "demo" } as never);
|
||||
|
||||
expect(result?.latestVersion?.files).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "src/index.ts",
|
||||
contentType: "application/typescript",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ function makeCtx() {
|
||||
slug: "padel",
|
||||
displayName: "Padel",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:local",
|
||||
latestVersionId: "skillVersions:1",
|
||||
manualOverride: {
|
||||
verdict: "clean",
|
||||
@@ -103,6 +104,15 @@ function makeCtx() {
|
||||
switch (id) {
|
||||
case "skillVersions:1":
|
||||
return latestVersion;
|
||||
case "publishers:local":
|
||||
return {
|
||||
_id: "publishers:local",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "local-publisher",
|
||||
displayName: "Local Dev",
|
||||
linkedUserId: "users:owner",
|
||||
};
|
||||
case "users:owner":
|
||||
return {
|
||||
_id: "users:owner",
|
||||
@@ -150,7 +160,7 @@ describe("getBySlugForStaff audit logs", () => {
|
||||
vi.mocked(requireUser).mockReset();
|
||||
});
|
||||
|
||||
it("returns reviewer info and recent audit logs with actor handles", async () => {
|
||||
it("returns publisher-backed owner info plus recent audit logs with actor handles", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
@@ -162,6 +172,7 @@ describe("getBySlugForStaff audit logs", () => {
|
||||
slug: "padel",
|
||||
auditLogLimit: 5,
|
||||
})) as {
|
||||
owner: { handle?: string | null } | null;
|
||||
overrideReviewer: { handle?: string | null } | null;
|
||||
auditLogs: Array<{
|
||||
actor: { handle?: string | null } | null;
|
||||
@@ -171,6 +182,7 @@ describe("getBySlugForStaff audit logs", () => {
|
||||
|
||||
expect(getSkillBadgeMap).toHaveBeenCalled();
|
||||
expect(auditTake).toHaveBeenCalledWith(5);
|
||||
expect(result.owner?.handle).toBe("local-publisher");
|
||||
expect(result.overrideReviewer?.handle).toBe("moddy");
|
||||
expect(result.auditLogs).toHaveLength(2);
|
||||
expect(result.auditLogs[0]?.action).toBe("skill.manual_override.set");
|
||||
|
||||
+517
-73
@@ -1,4 +1,5 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { normalizeTextContentType } from "clawhub-schema";
|
||||
import { getPage, type IndexKey, paginator } from "convex-helpers/server/pagination";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
import { ConvexError, v, type Value } from "convex/values";
|
||||
@@ -38,6 +39,7 @@ import { deriveModerationFlags } from "./lib/moderation";
|
||||
import { buildModerationSnapshot } from "./lib/moderationEngine";
|
||||
import {
|
||||
legacyFlagsFromVerdict,
|
||||
MODERATION_ENGINE_VERSION,
|
||||
summarizeReasonCodes,
|
||||
verdictFromCodes,
|
||||
} from "./lib/moderationReasonCodes";
|
||||
@@ -66,15 +68,23 @@ import {
|
||||
reserveSlugForHardDeleteFinalize,
|
||||
upsertReservedSlugForRightfulOwner,
|
||||
} from "./lib/reservedSlugs";
|
||||
import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags";
|
||||
import {
|
||||
fetchText,
|
||||
type PublishResult,
|
||||
publishVersionForUser,
|
||||
queueHighlightedWebhook,
|
||||
} from "./lib/skillPublish";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import { getFrontmatterValue, hashSkillFiles } from "./lib/skills";
|
||||
import { computeIsSuspicious, isSkillSuspicious } from "./lib/skillSafety";
|
||||
import { digestToHydratableSkill, digestToOwnerInfo } from "./lib/skillSearchDigest";
|
||||
import {
|
||||
digestToHydratableSkill,
|
||||
digestToOwnerInfo,
|
||||
extractDigestFields,
|
||||
upsertSkillSearchDigest,
|
||||
} from "./lib/skillSearchDigest";
|
||||
import { adjustUserSkillStatsForSkillChange } from "./lib/userSkillStats";
|
||||
import schema from "./schema";
|
||||
|
||||
export { publishVersionForUser } from "./lib/skillPublish";
|
||||
@@ -111,6 +121,7 @@ const DEFAULT_STAFF_AUDIT_LOG_LIMIT = 10;
|
||||
const MAX_STAFF_AUDIT_LOG_LIMIT = 50;
|
||||
const USER_MODERATION_REASON = "user.moderation";
|
||||
const SKILL_CATALOG_CURSOR_PREFIX = "skillcat:";
|
||||
const SKILL_CAPABILITY_TAG_SET = new Set<string>(SKILL_CAPABILITY_TAGS);
|
||||
|
||||
function buildStructuredModerationPatch(params: {
|
||||
staticScan?: Doc<"skillVersions">["staticScan"];
|
||||
@@ -316,6 +327,8 @@ const NONSUSPICIOUS_SORT_INDEXES = {
|
||||
stars: "by_nonsuspicious_stars",
|
||||
installs: "by_nonsuspicious_installs",
|
||||
} as const;
|
||||
const MAX_FILTERED_PUBLIC_LIST_SCAN_PAGES = 12;
|
||||
const MAX_FILTERED_PUBLIC_LIST_SCAN_ROWS = 500;
|
||||
|
||||
function isSkillVersionId(
|
||||
value: Id<"skillVersions"> | null | undefined,
|
||||
@@ -450,7 +463,7 @@ async function syncSkillModerationFromLatestVersion(
|
||||
|
||||
function buildConflictingSkillUrl(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
owner: SkillOwnerRef,
|
||||
) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt || !isPublicSkillDoc(skill)) return null;
|
||||
const ownerParam = owner.handle?.trim() || String(owner._id);
|
||||
@@ -460,7 +473,7 @@ function buildConflictingSkillUrl(
|
||||
|
||||
function buildSlugTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
owner: SkillOwnerRef,
|
||||
) {
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) {
|
||||
return (
|
||||
@@ -476,7 +489,7 @@ function buildSlugTakenErrorMessage(
|
||||
|
||||
function buildAliasTakenErrorMessage(
|
||||
skill: Doc<"skills">,
|
||||
owner: Doc<"users"> | Doc<"publishers"> | null | undefined,
|
||||
owner: SkillOwnerRef,
|
||||
) {
|
||||
const base = "Slug redirects to an existing skill. Choose a different slug.";
|
||||
const url = buildConflictingSkillUrl(skill, owner);
|
||||
@@ -488,6 +501,16 @@ function normalizeSkillSlugKey(slug: string) {
|
||||
return slug.trim().toLowerCase();
|
||||
}
|
||||
|
||||
type SkillOwnerRef =
|
||||
| {
|
||||
_id: Id<"users"> | Id<"publishers">;
|
||||
handle?: string | null;
|
||||
deletedAt?: number | null;
|
||||
deactivatedAt?: number | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
function normalizeSkillSlugForWrite(slug: string) {
|
||||
const normalized = normalizeSkillSlugKey(slug);
|
||||
if (!normalized || !/^[a-z0-9][a-z0-9-]*$/.test(normalized)) {
|
||||
@@ -736,6 +759,7 @@ async function hardDeleteSkillStep(
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
@@ -1041,6 +1065,7 @@ type PublicSkillVersion = {
|
||||
createdBy?: Id<"users">;
|
||||
createdAt?: number;
|
||||
softDeletedAt?: number;
|
||||
capabilityTags?: string[];
|
||||
sha256hash?: string;
|
||||
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
|
||||
llmAnalysis?: Doc<"skillVersions">["llmAnalysis"];
|
||||
@@ -1207,7 +1232,7 @@ function toPublicSkillVersion(
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
contentType: file.contentType,
|
||||
contentType: normalizeTextContentType(file.path, file.contentType),
|
||||
})),
|
||||
parsed: version.parsed
|
||||
? {
|
||||
@@ -1218,6 +1243,7 @@ function toPublicSkillVersion(
|
||||
createdBy: version.createdBy,
|
||||
createdAt: version.createdAt,
|
||||
softDeletedAt: version.softDeletedAt,
|
||||
capabilityTags: version.capabilityTags,
|
||||
sha256hash: version.sha256hash,
|
||||
vtAnalysis: version.vtAnalysis,
|
||||
llmAnalysis: version.llmAnalysis,
|
||||
@@ -1632,7 +1658,11 @@ export const getBySlugForStaff = query({
|
||||
if (!skill) return null;
|
||||
|
||||
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null;
|
||||
const owner = toPublicUser(await ctx.db.get(skill.ownerUserId));
|
||||
const ownerPublisher = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
});
|
||||
const owner = toPublicPublisher(ownerPublisher);
|
||||
const badges = await getSkillBadgeMap(ctx, skill._id);
|
||||
const rawAuditLogs = await ctx.db
|
||||
.query("auditLogs")
|
||||
@@ -1659,10 +1689,20 @@ export const getBySlugForStaff = query({
|
||||
}));
|
||||
|
||||
const forkOfSkill = skill.forkOf?.skillId ? await ctx.db.get(skill.forkOf.skillId) : null;
|
||||
const forkOfOwner = forkOfSkill ? await ctx.db.get(forkOfSkill.ownerUserId) : null;
|
||||
const forkOfOwner = forkOfSkill
|
||||
? await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: forkOfSkill.ownerPublisherId,
|
||||
ownerUserId: forkOfSkill.ownerUserId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const canonicalSkill = skill.canonicalSkillId ? await ctx.db.get(skill.canonicalSkillId) : null;
|
||||
const canonicalOwner = canonicalSkill ? await ctx.db.get(canonicalSkill.ownerUserId) : null;
|
||||
const canonicalOwner = canonicalSkill
|
||||
? await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: canonicalSkill.ownerPublisherId,
|
||||
ownerUserId: canonicalSkill.ownerUserId,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
requestedSlug: resolved.requestedSlug,
|
||||
@@ -1681,8 +1721,8 @@ export const getBySlugForStaff = query({
|
||||
displayName: forkOfSkill.displayName,
|
||||
},
|
||||
owner: {
|
||||
handle: forkOfOwner?.handle ?? forkOfOwner?.name ?? null,
|
||||
userId: forkOfOwner?._id ?? null,
|
||||
handle: forkOfOwner?.handle ?? null,
|
||||
userId: forkOfOwner?.linkedUserId ?? null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
@@ -1693,8 +1733,8 @@ export const getBySlugForStaff = query({
|
||||
displayName: canonicalSkill.displayName,
|
||||
},
|
||||
owner: {
|
||||
handle: canonicalOwner?.handle ?? canonicalOwner?.name ?? null,
|
||||
userId: canonicalOwner?._id ?? null,
|
||||
handle: canonicalOwner?.handle ?? null,
|
||||
userId: canonicalOwner?.linkedUserId ?? null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
@@ -2055,7 +2095,8 @@ export const list = query({
|
||||
)
|
||||
.unique());
|
||||
const isOwnDashboard = Boolean(
|
||||
membership || (userId && ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === userId),
|
||||
membership ||
|
||||
(userId && ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === userId),
|
||||
);
|
||||
const scopedEntries = await ctx.db
|
||||
.query("skills")
|
||||
@@ -2490,6 +2531,7 @@ export const report = mutation({
|
||||
const nextSkill = { ...skill, ...updates };
|
||||
await ctx.db.patch(skill._id, updates);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
if (shouldAutoHide) {
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now);
|
||||
@@ -2670,8 +2712,12 @@ export const listPublicPageV4 = query({
|
||||
dir: v.optional(v.union(v.literal("asc"), v.literal("desc"))),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
if (args.capabilityTag && !isKnownSkillCapabilityTag(args.capabilityTag)) {
|
||||
return { page: [], hasMore: false, nextCursor: null };
|
||||
}
|
||||
const sort = args.sort ?? "newest";
|
||||
const dir = args.dir ?? (sort === "name" ? "asc" : "desc");
|
||||
const numItems = clampInt(args.numItems ?? 25, 1, MAX_PUBLIC_LIST_LIMIT);
|
||||
@@ -2684,6 +2730,7 @@ export const listPublicPageV4 = query({
|
||||
sort,
|
||||
dir,
|
||||
numItems,
|
||||
capabilityTag: args.capabilityTag,
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly ?? false,
|
||||
});
|
||||
}
|
||||
@@ -2700,46 +2747,113 @@ export const listPublicPageV4 = query({
|
||||
const isFirstPage = !decodedCursor;
|
||||
const startIndexKey: IndexKey = decodedCursor ?? eqPrefix;
|
||||
|
||||
const result = await getPage(ctx, {
|
||||
table: "skillSearchDigest",
|
||||
startIndexKey,
|
||||
startInclusive: isFirstPage,
|
||||
endIndexKey: eqPrefix,
|
||||
endInclusive: true,
|
||||
absoluteMaxRows: numItems,
|
||||
order: dir,
|
||||
index: indexName,
|
||||
schema,
|
||||
});
|
||||
|
||||
// Build PublicSkillEntry[] from digests
|
||||
const items: PublicSkillEntry[] = [];
|
||||
for (const digest of result.page) {
|
||||
const hydratable = digestToHydratableSkill(digest);
|
||||
const publicSkill = toPublicSkill(hydratable);
|
||||
if (!publicSkill) continue;
|
||||
const ownerInfo = digestToOwnerInfo(digest);
|
||||
if (!ownerInfo?.owner) continue;
|
||||
const latestVersion = digest.latestVersionSummary
|
||||
? toPublicSkillListVersionFromSummary(digest.latestVersionSummary, digest.latestVersionId)
|
||||
: null;
|
||||
items.push({
|
||||
skill: publicSkill,
|
||||
latestVersion,
|
||||
ownerHandle: ownerInfo.ownerHandle,
|
||||
owner: ownerInfo.owner,
|
||||
if (!args.capabilityTag) {
|
||||
const result = await getPage(ctx, {
|
||||
table: "skillSearchDigest",
|
||||
startIndexKey,
|
||||
startInclusive: isFirstPage,
|
||||
endIndexKey: eqPrefix,
|
||||
endInclusive: true,
|
||||
absoluteMaxRows: numItems,
|
||||
order: dir,
|
||||
index: indexName,
|
||||
schema,
|
||||
});
|
||||
|
||||
const items = result.page
|
||||
.map((digest) => buildPublicSkillEntryFromDigest(digest))
|
||||
.filter((item): item is PublicSkillEntry => item !== null);
|
||||
let nextCursor: string | null = null;
|
||||
if (result.hasMore && result.indexKeys.length > 0) {
|
||||
nextCursor = encodeIndexKey(result.indexKeys[result.indexKeys.length - 1]);
|
||||
}
|
||||
|
||||
return { page: items, hasMore: result.hasMore, nextCursor };
|
||||
}
|
||||
|
||||
const items: PublicSkillEntry[] = [];
|
||||
let scanCursor = startIndexKey;
|
||||
let scanInclusive = isFirstPage;
|
||||
let hasMore = false;
|
||||
let nextCursor: string | null = null;
|
||||
if (result.hasMore && result.indexKeys.length > 0) {
|
||||
nextCursor = encodeIndexKey(result.indexKeys[result.indexKeys.length - 1]);
|
||||
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;
|
||||
const batchSize = Math.min(remainingRows, Math.max(numItems * 3, numItems));
|
||||
const result = await getPage(ctx, {
|
||||
table: "skillSearchDigest",
|
||||
startIndexKey: scanCursor,
|
||||
startInclusive: scanInclusive,
|
||||
endIndexKey: eqPrefix,
|
||||
endInclusive: true,
|
||||
absoluteMaxRows: batchSize,
|
||||
order: dir,
|
||||
index: indexName,
|
||||
schema,
|
||||
});
|
||||
remainingRows -= batchSize;
|
||||
if (result.indexKeys.length === 0) {
|
||||
hasMore = false;
|
||||
nextCursor = null;
|
||||
break;
|
||||
}
|
||||
|
||||
for (let index = 0; index < result.page.length; index += 1) {
|
||||
const digest = result.page[index];
|
||||
const cursor = result.indexKeys[index];
|
||||
if ((digest.capabilityTags ?? []).includes(args.capabilityTag)) {
|
||||
const item = buildPublicSkillEntryFromDigest(digest);
|
||||
if (item) items.push(item);
|
||||
}
|
||||
if (items.length >= numItems) {
|
||||
hasMore = result.hasMore || index < result.page.length - 1;
|
||||
nextCursor = hasMore ? encodeIndexKey(cursor) : null;
|
||||
return { page: items, hasMore, nextCursor };
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.hasMore) {
|
||||
hasMore = false;
|
||||
nextCursor = null;
|
||||
break;
|
||||
}
|
||||
|
||||
scanCursor = result.indexKeys[result.indexKeys.length - 1];
|
||||
scanInclusive = false;
|
||||
hasMore = true;
|
||||
nextCursor = encodeIndexKey(scanCursor);
|
||||
}
|
||||
|
||||
return { page: items, hasMore: result.hasMore, nextCursor };
|
||||
// Guard: never signal more pages when the scan budget is exhausted
|
||||
// without finding any items — that would cause the client's
|
||||
// IntersectionObserver auto-load to loop on empty responses.
|
||||
if (items.length === 0) {
|
||||
hasMore = false;
|
||||
nextCursor = null;
|
||||
}
|
||||
|
||||
return { page: items, hasMore, nextCursor };
|
||||
},
|
||||
});
|
||||
|
||||
function buildPublicSkillEntryFromDigest(digest: Doc<"skillSearchDigest">): PublicSkillEntry | null {
|
||||
const hydratable = digestToHydratableSkill(digest);
|
||||
const publicSkill = toPublicSkill(hydratable);
|
||||
if (!publicSkill) return null;
|
||||
const ownerInfo = digestToOwnerInfo(digest);
|
||||
if (!ownerInfo?.owner) return null;
|
||||
const latestVersion = digest.latestVersionSummary
|
||||
? toPublicSkillListVersionFromSummary(digest.latestVersionSummary, digest.latestVersionId)
|
||||
: null;
|
||||
return {
|
||||
skill: publicSkill,
|
||||
latestVersion,
|
||||
ownerHandle: ownerInfo.ownerHandle,
|
||||
owner: ownerInfo.owner,
|
||||
};
|
||||
}
|
||||
|
||||
type PublicSkillCatalogItem = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
@@ -2775,12 +2889,13 @@ function decodeSkillCatalogCursor(raw: string | null | undefined): SkillCatalogC
|
||||
return { cursor: raw, offset: 0, pageSize: null, done: false };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw.slice(SKILL_CATALOG_CURSOR_PREFIX.length)) as Partial<SkillCatalogCursorState>;
|
||||
const parsed = JSON.parse(
|
||||
raw.slice(SKILL_CATALOG_CURSOR_PREFIX.length),
|
||||
) as Partial<SkillCatalogCursorState>;
|
||||
return {
|
||||
cursor: typeof parsed.cursor === "string" ? parsed.cursor : null,
|
||||
offset: typeof parsed.offset === "number" && parsed.offset > 0 ? parsed.offset : 0,
|
||||
pageSize:
|
||||
typeof parsed.pageSize === "number" && parsed.pageSize > 0 ? parsed.pageSize : null,
|
||||
pageSize: typeof parsed.pageSize === "number" && parsed.pageSize > 0 ? parsed.pageSize : null,
|
||||
done: parsed.done === true,
|
||||
};
|
||||
} catch {
|
||||
@@ -2814,12 +2929,13 @@ function skillCatalogMatchesFilters(
|
||||
) {
|
||||
if (!isVisibleSkillCatalogDigest(digest)) return false;
|
||||
if (args.channel === "private") return false;
|
||||
if (args.capabilityTag) return false;
|
||||
if (args.executesCode === true) return false;
|
||||
const isOfficial = isSkillCatalogOfficial(digest);
|
||||
const channel = getSkillCatalogChannel(digest);
|
||||
if (typeof args.isOfficial === "boolean" && isOfficial !== args.isOfficial) return false;
|
||||
if (args.channel && channel !== args.channel) return false;
|
||||
if (args.capabilityTag && !(digest.capabilityTags ?? []).includes(args.capabilityTag))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2837,7 +2953,7 @@ function toPublicSkillCatalogItem(digest: Doc<"skillSearchDigest">): PublicSkill
|
||||
createdAt: digest.createdAt,
|
||||
updatedAt: digest.updatedAt,
|
||||
latestVersion: digest.latestVersionSummary?.version ?? null,
|
||||
capabilityTags: [],
|
||||
capabilityTags: digest.capabilityTags ?? [],
|
||||
executesCode: false,
|
||||
verificationTier: null,
|
||||
};
|
||||
@@ -2862,16 +2978,25 @@ function scoreSkillCatalogResult(digest: Doc<"skillSearchDigest">, queryText: st
|
||||
return score;
|
||||
}
|
||||
|
||||
function isKnownSkillCapabilityTag(tag: string | undefined) {
|
||||
return typeof tag === "string" && SKILL_CAPABILITY_TAG_SET.has(tag);
|
||||
}
|
||||
|
||||
export const listPackageCatalogPage = query({
|
||||
args: {
|
||||
channel: v.optional(v.union(v.literal("official"), v.literal("community"), v.literal("private"))),
|
||||
channel: v.optional(
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
paginationOpts: paginationOptsValidator,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
if (args.channel === "private" || args.executesCode === true || args.capabilityTag) {
|
||||
if (args.capabilityTag && !isKnownSkillCapabilityTag(args.capabilityTag)) {
|
||||
return { page: [], isDone: true, continueCursor: "" };
|
||||
}
|
||||
if (args.channel === "private" || args.executesCode === true) {
|
||||
return { page: [], isDone: true, continueCursor: "" };
|
||||
}
|
||||
|
||||
@@ -2894,7 +3019,9 @@ export const listPackageCatalogPage = query({
|
||||
loops += 1;
|
||||
const effectivePageSize = Math.min(
|
||||
remainingScanBudget,
|
||||
offset > 0 && pageSize ? Math.max(pageSize, offset + 1) : Math.max(targetCount * 3, targetCount),
|
||||
offset > 0 && pageSize
|
||||
? Math.max(pageSize, offset + 1)
|
||||
: Math.max(targetCount * 3, targetCount),
|
||||
);
|
||||
if (effectivePageSize <= 0) break;
|
||||
remainingScanBudget -= effectivePageSize;
|
||||
@@ -2948,7 +3075,9 @@ export const searchPackageCatalogPublic = query({
|
||||
args: {
|
||||
query: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
channel: v.optional(v.union(v.literal("official"), v.literal("community"), v.literal("private"))),
|
||||
channel: v.optional(
|
||||
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
|
||||
),
|
||||
isOfficial: v.optional(v.boolean()),
|
||||
executesCode: v.optional(v.boolean()),
|
||||
capabilityTag: v.optional(v.string()),
|
||||
@@ -2956,7 +3085,8 @@ export const searchPackageCatalogPublic = query({
|
||||
handler: async (ctx, args) => {
|
||||
const queryText = args.query.trim().toLowerCase();
|
||||
if (!queryText) return [];
|
||||
if (args.channel === "private" || args.executesCode === true || args.capabilityTag) return [];
|
||||
if (args.capabilityTag && !isKnownSkillCapabilityTag(args.capabilityTag)) return [];
|
||||
if (args.channel === "private" || args.executesCode === true) return [];
|
||||
|
||||
const targetCount = Math.max(1, Math.min(args.limit ?? 20, 100));
|
||||
const matches: Array<{ score: number; package: PublicSkillCatalogItem }> = [];
|
||||
@@ -3020,6 +3150,7 @@ async function fetchHighlightedPage(
|
||||
sort: SortKey;
|
||||
dir: "asc" | "desc";
|
||||
numItems: number;
|
||||
capabilityTag?: string;
|
||||
nonSuspiciousOnly: boolean;
|
||||
},
|
||||
) {
|
||||
@@ -3039,6 +3170,7 @@ async function fetchHighlightedPage(
|
||||
.unique();
|
||||
if (!digest || digest.softDeletedAt) continue;
|
||||
if (opts.nonSuspiciousOnly && digest.isSuspicious) continue;
|
||||
if (opts.capabilityTag && !(digest.capabilityTags ?? []).includes(opts.capabilityTag)) continue;
|
||||
digests.push(digest);
|
||||
}
|
||||
|
||||
@@ -3065,23 +3197,9 @@ async function fetchHighlightedPage(
|
||||
const trimmed = digests.slice(0, opts.numItems);
|
||||
|
||||
// Build PublicSkillEntry[]
|
||||
const items: PublicSkillEntry[] = [];
|
||||
for (const digest of trimmed) {
|
||||
const hydratable = digestToHydratableSkill(digest);
|
||||
const publicSkill = toPublicSkill(hydratable);
|
||||
if (!publicSkill) continue;
|
||||
const ownerInfo = digestToOwnerInfo(digest);
|
||||
if (!ownerInfo?.owner) continue;
|
||||
const latestVersion = digest.latestVersionSummary
|
||||
? toPublicSkillListVersionFromSummary(digest.latestVersionSummary, digest.latestVersionId)
|
||||
: null;
|
||||
items.push({
|
||||
skill: publicSkill,
|
||||
latestVersion,
|
||||
ownerHandle: ownerInfo.ownerHandle,
|
||||
owner: ownerInfo.owner,
|
||||
});
|
||||
}
|
||||
const items = trimmed
|
||||
.map((digest) => buildPublicSkillEntryFromDigest(digest))
|
||||
.filter((item): item is PublicSkillEntry => item !== null);
|
||||
|
||||
// Highlighted skills are few enough to return in one page — no cursor needed
|
||||
return { page: items, hasMore: false, nextCursor: null };
|
||||
@@ -3581,6 +3699,56 @@ export const getActiveSkillBatchForLlmBackfillInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get active latest skill versions whose static scan is missing or uses an older engine version.
|
||||
* Used to backfill new static rules onto already-published skills.
|
||||
*/
|
||||
export const getActiveSkillBatchForStaticScanBackfillInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.number()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = args.batchSize ?? 25;
|
||||
const cursor = args.cursor ?? 0;
|
||||
|
||||
const candidates = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_creation_time", (q) => q.gt("_creationTime", cursor))
|
||||
.order("asc")
|
||||
.take(batchSize * 4);
|
||||
|
||||
const results: Array<{
|
||||
skillId: Id<"skills">;
|
||||
versionId: Id<"skillVersions">;
|
||||
slug: string;
|
||||
}> = [];
|
||||
let nextCursor = cursor;
|
||||
|
||||
for (const skill of candidates) {
|
||||
nextCursor = skill._creationTime;
|
||||
if (results.length >= batchSize) break;
|
||||
|
||||
if (skill.softDeletedAt) continue;
|
||||
if ((skill.moderationStatus ?? "active") !== "active") continue;
|
||||
if (!skill.latestVersionId) continue;
|
||||
|
||||
const version = await ctx.db.get(skill.latestVersionId);
|
||||
if (!version) continue;
|
||||
if (version.staticScan?.engineVersion === MODERATION_ENGINE_VERSION) continue;
|
||||
|
||||
results.push({
|
||||
skillId: skill._id,
|
||||
versionId: version._id,
|
||||
slug: skill.slug,
|
||||
});
|
||||
}
|
||||
|
||||
const done = candidates.length < batchSize * 4;
|
||||
return { skills: results, nextCursor, done };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get skills with stale moderationReason that have vtAnalysis cached.
|
||||
* Used to sync moderationReason with cached VT results.
|
||||
@@ -3679,6 +3847,159 @@ export const getPendingVTSkillsInternal = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const updateSkillVersionStaticScanInternal = internalMutation({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
staticScan: v.object({
|
||||
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
|
||||
reasonCodes: v.array(v.string()),
|
||||
findings: v.array(
|
||||
v.object({
|
||||
code: v.string(),
|
||||
severity: v.union(v.literal("info"), v.literal("warn"), v.literal("critical")),
|
||||
file: v.string(),
|
||||
line: v.number(),
|
||||
message: v.string(),
|
||||
evidence: v.string(),
|
||||
}),
|
||||
),
|
||||
summary: v.string(),
|
||||
engineVersion: v.string(),
|
||||
checkedAt: v.number(),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const version = await ctx.db.get(args.versionId);
|
||||
if (!version || version.skillId !== args.skillId) return { ok: true as const, skipped: "missing" as const };
|
||||
|
||||
await ctx.db.patch(version._id, {
|
||||
staticScan: args.staticScan,
|
||||
});
|
||||
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) return { ok: true as const, skipped: "missing" as const };
|
||||
if (skill.latestVersionId !== version._id) {
|
||||
return { ok: true as const, skipped: "not_latest" as const };
|
||||
}
|
||||
|
||||
const owner = skill.ownerUserId ? await ctx.db.get(skill.ownerUserId) : null;
|
||||
const now = Date.now();
|
||||
const updatedVersion = { ...version, staticScan: args.staticScan };
|
||||
const basePatch = buildScannerModerationPatchFromVersion({
|
||||
owner,
|
||||
version: updatedVersion,
|
||||
now,
|
||||
});
|
||||
const patch = applySkillManualOverrideToSkillPatch({
|
||||
skill,
|
||||
basePatch: {
|
||||
...basePatch,
|
||||
updatedAt: now,
|
||||
},
|
||||
now,
|
||||
});
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
if (patch.moderationVerdict === "malicious" && skill.ownerUserId) {
|
||||
await ctx.scheduler.runAfter(0, internal.users.placeUserUnderModerationInternal, {
|
||||
ownerUserId: skill.ownerUserId,
|
||||
slug: skill.slug,
|
||||
reason:
|
||||
patch.moderationReasonCodes?.find((code) => code.startsWith("malicious.")) ??
|
||||
"malicious.static_scan",
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true as const, status: args.staticScan.status };
|
||||
},
|
||||
});
|
||||
|
||||
export const scanSkillVersionStaticallyInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
versionId: v.id("skillVersions"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const [skill, version] = await Promise.all([
|
||||
ctx.runQuery(internal.skills.getSkillByIdInternal, { skillId: args.skillId }),
|
||||
ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId: args.versionId }),
|
||||
]);
|
||||
|
||||
if (!skill || !version) {
|
||||
return { ok: true as const, skipped: "missing" as const };
|
||||
}
|
||||
|
||||
const staticScan = await runStaticPublishScan(ctx, {
|
||||
slug: skill.slug,
|
||||
displayName: skill.displayName,
|
||||
summary: skill.summary ?? undefined,
|
||||
frontmatter: version.parsed?.frontmatter ?? {},
|
||||
metadata: version.parsed?.metadata,
|
||||
files: version.files,
|
||||
});
|
||||
|
||||
return await ctx.runMutation(internal.skills.updateSkillVersionStaticScanInternal, {
|
||||
skillId: skill._id,
|
||||
versionId: version._id,
|
||||
staticScan,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const backfillSkillStaticScansInternal: ReturnType<typeof internalAction> = internalAction({
|
||||
args: {
|
||||
cursor: v.optional(v.number()),
|
||||
batchSize: v.optional(v.number()),
|
||||
rescanned: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = Math.max(1, Math.min(args.batchSize ?? 25, 100));
|
||||
const batch = await ctx.runQuery(internal.skills.getActiveSkillBatchForStaticScanBackfillInternal, {
|
||||
cursor: args.cursor,
|
||||
batchSize,
|
||||
});
|
||||
|
||||
let rescanned = args.rescanned ?? 0;
|
||||
for (const skill of batch.skills) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.scanSkillVersionStaticallyInternal, {
|
||||
skillId: skill.skillId,
|
||||
versionId: skill.versionId,
|
||||
});
|
||||
rescanned += 1;
|
||||
}
|
||||
|
||||
if (!batch.done) {
|
||||
await ctx.scheduler.runAfter(0, internal.skills.backfillSkillStaticScansInternal, {
|
||||
cursor: batch.nextCursor,
|
||||
batchSize,
|
||||
rescanned,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rescanned,
|
||||
nextCursor: batch.nextCursor,
|
||||
done: batch.done,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const backfillSkillStaticScans: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUserFromAction(ctx);
|
||||
assertAdmin(user);
|
||||
return await ctx.runAction(internal.skills.backfillSkillStaticScansInternal, {
|
||||
batchSize: args.batchSize,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Emergency escalation by skillId for legacy rows without sha256hash.
|
||||
* Rebuilds the full moderation snapshot so legacy rows stay in sync with structured fields.
|
||||
@@ -3937,6 +4258,7 @@ export const applyBanToOwnedSkillsBatchInternal = internalMutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, args.bannedAt);
|
||||
}
|
||||
|
||||
@@ -4048,6 +4370,7 @@ export const restoreOwnedSkillsForUnbanBatchInternal = internalMutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, false, now);
|
||||
restoredCount += 1;
|
||||
@@ -4727,6 +5050,7 @@ export const updateTags = mutation({
|
||||
changelogSource: version.changelogSource,
|
||||
clawdis: version.parsed?.clawdis,
|
||||
};
|
||||
patch.capabilityTags = version.capabilityTags;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4750,6 +5074,38 @@ export const updateTags = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteTags = mutation({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
tags: v.array(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) throw new Error("Skill not found");
|
||||
if (skill.ownerUserId !== user._id) {
|
||||
assertModerator(user);
|
||||
}
|
||||
|
||||
const nextTags = { ...skill.tags };
|
||||
let changed = false;
|
||||
for (const tag of args.tags) {
|
||||
if (tag === "latest") continue; // protect the latest tag from deletion
|
||||
if (tag in nextTags) {
|
||||
delete nextTags[tag];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
tags: nextTags,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const setRedactionApproved = mutation({
|
||||
args: { skillId: v.id("skills"), approved: v.boolean() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -4924,7 +5280,11 @@ export const clearSkillManualOverride = mutation({
|
||||
});
|
||||
|
||||
export const setSoftDeleted = mutation({
|
||||
args: { skillId: v.id("skills"), deleted: v.boolean() },
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
deleted: v.boolean(),
|
||||
reason: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
@@ -4932,9 +5292,16 @@ export const setSoftDeleted = mutation({
|
||||
if (!skill) throw new Error("Skill not found");
|
||||
|
||||
const now = Date.now();
|
||||
const note = args.reason ? trimManualOverrideNote(args.reason) : undefined;
|
||||
if (!note) {
|
||||
throw new ConvexError(
|
||||
args.deleted ? "Hide reason is required." : "Restore reason is required.",
|
||||
);
|
||||
}
|
||||
const patch: Partial<Doc<"skills">> = {
|
||||
softDeletedAt: args.deleted ? now : undefined,
|
||||
moderationStatus: args.deleted ? "hidden" : "active",
|
||||
moderationNotes: note,
|
||||
hiddenAt: args.deleted ? now : undefined,
|
||||
hiddenBy: args.deleted ? user._id : undefined,
|
||||
lastReviewedAt: now,
|
||||
@@ -4943,6 +5310,7 @@ export const setSoftDeleted = mutation({
|
||||
const nextSkill = { ...skill, ...patch };
|
||||
await ctx.db.patch(skill._id, patch);
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);
|
||||
|
||||
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, args.deleted, now);
|
||||
|
||||
@@ -4951,7 +5319,11 @@ export const setSoftDeleted = mutation({
|
||||
action: args.deleted ? "skill.delete" : "skill.undelete",
|
||||
targetType: "skill",
|
||||
targetId: skill._id,
|
||||
metadata: { slug: skill.slug, softDeletedAt: args.deleted ? now : null },
|
||||
metadata: {
|
||||
slug: skill.slug,
|
||||
softDeletedAt: args.deleted ? now : null,
|
||||
reason: note,
|
||||
},
|
||||
createdAt: now,
|
||||
});
|
||||
},
|
||||
@@ -4977,6 +5349,7 @@ export const changeOwner = mutation({
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await adjustUserSkillStatsForSkillChange(ctx, skill, { ...skill, ownerUserId: args.ownerUserId });
|
||||
|
||||
const embeddings = await listSkillEmbeddingsForSkill(ctx, skill._id);
|
||||
for (const embedding of embeddings) {
|
||||
@@ -5630,6 +6003,72 @@ export const setDeprecatedBadge = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const setSkillCapabilityTags = mutation({
|
||||
args: { skillId: v.id("skills"), capabilityTags: v.array(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx);
|
||||
assertModerator(user);
|
||||
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) throw new Error("Skill not found");
|
||||
|
||||
const invalidTags = args.capabilityTags.filter(
|
||||
(tag) => !SKILL_CAPABILITY_TAGS.includes(tag as (typeof SKILL_CAPABILITY_TAGS)[number]),
|
||||
);
|
||||
if (invalidTags.length > 0) {
|
||||
throw new ConvexError(`Unknown capability tags: ${invalidTags.join(", ")}`);
|
||||
}
|
||||
|
||||
const selectedTags = new Set(args.capabilityTags);
|
||||
const normalizedTags = SKILL_CAPABILITY_TAGS.filter((tag) => selectedTags.has(tag));
|
||||
const now = Date.now();
|
||||
|
||||
if (skill.latestVersionId) {
|
||||
const latestVersion = await ctx.db.get(skill.latestVersionId);
|
||||
if (latestVersion) {
|
||||
await ctx.db.patch(latestVersion._id, {
|
||||
capabilityTags: normalizedTags.length ? normalizedTags : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nextSkill = {
|
||||
...skill,
|
||||
capabilityTags: normalizedTags.length ? normalizedTags : undefined,
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
capabilityTags: nextSkill.capabilityTags,
|
||||
lastReviewedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: nextSkill.ownerPublisherId,
|
||||
ownerUserId: nextSkill.ownerUserId,
|
||||
});
|
||||
await upsertSkillSearchDigest(ctx, {
|
||||
...extractDigestFields(nextSkill),
|
||||
ownerHandle: owner?.handle ?? "",
|
||||
ownerKind: owner?.kind,
|
||||
ownerName: owner?.linkedUserId ? owner.handle : undefined,
|
||||
ownerDisplayName: owner?.displayName,
|
||||
ownerImage: owner?.image,
|
||||
});
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: user._id,
|
||||
action: "skill.capability_tags.set",
|
||||
targetType: "skill",
|
||||
targetId: skill._id,
|
||||
metadata: { capabilityTags: normalizedTags },
|
||||
createdAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const hardDelete = mutation({
|
||||
args: { skillId: v.id("skills") },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -5691,6 +6130,7 @@ export const insertVersion = internalMutation({
|
||||
clawdis: v.optional(v.any()),
|
||||
license: v.optional(v.literal(PLATFORM_SKILL_LICENSE)),
|
||||
}),
|
||||
capabilityTags: v.optional(v.array(v.string())),
|
||||
summary: v.optional(v.string()),
|
||||
qualityAssessment: v.optional(
|
||||
v.object({
|
||||
@@ -5942,6 +6382,7 @@ export const insertVersion = internalMutation({
|
||||
forkOf,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
capabilityTags: args.capabilityTags,
|
||||
softDeletedAt: undefined,
|
||||
badges: {
|
||||
redactionApproved: undefined,
|
||||
@@ -5989,6 +6430,7 @@ export const insertVersion = internalMutation({
|
||||
// Digest sync is handled after the version patch below (line ~4222),
|
||||
// which captures the final state including latestVersionId and tags.
|
||||
await adjustGlobalPublicCountForSkillChange(ctx, null, skill);
|
||||
await adjustUserSkillStatsForSkillChange(ctx, null, skill);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6010,6 +6452,7 @@ export const insertVersion = internalMutation({
|
||||
changelogSource: args.changelogSource,
|
||||
files: args.files,
|
||||
parsed: args.parsed,
|
||||
capabilityTags: args.capabilityTags,
|
||||
staticScan: args.staticScan,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
@@ -6055,6 +6498,7 @@ export const insertVersion = internalMutation({
|
||||
clawdis: args.parsed.clawdis,
|
||||
},
|
||||
tags: nextTags,
|
||||
capabilityTags: args.capabilityTags,
|
||||
stats: { ...skill.stats, versions: skill.stats.versions + 1 },
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: initialModerationStatus,
|
||||
|
||||
+84
-3
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getSoulBySlugInternal, insertVersion } from "./souls";
|
||||
import { getSoulBySlugInternal, insertVersion, list } from "./souls";
|
||||
|
||||
type WrappedHandler<TArgs> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>;
|
||||
@@ -10,6 +10,7 @@ const insertVersionHandler = (insertVersion as unknown as WrappedHandler<Record<
|
||||
const getSoulBySlugInternalHandler = (
|
||||
getSoulBySlugInternal as unknown as WrappedHandler<{ slug: string }>
|
||||
)._handler;
|
||||
const listHandler = (list as unknown as WrappedHandler<{ ownerUserId?: string; limit?: number }>)._handler;
|
||||
|
||||
describe("souls.insertVersion", () => {
|
||||
it("throws a soul-specific ownership error for non-owners", async () => {
|
||||
@@ -23,7 +24,10 @@ describe("souls.insertVersion", () => {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "souls") throw new Error(`unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (name: string, build: ((q: { eq: (field: string, value: string) => unknown }) => unknown) | undefined) => {
|
||||
withIndex: (
|
||||
name: string,
|
||||
build: ((q: { eq: (field: string, value: string) => unknown }) => unknown) | undefined,
|
||||
) => {
|
||||
if (name !== "by_slug") throw new Error(`unexpected index ${name}`);
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
@@ -92,7 +96,12 @@ describe("souls.insertVersion", () => {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "souls") throw new Error(`unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (name: string, build: ((q: { eq: (field: string, value: string) => unknown }) => unknown) | undefined) => {
|
||||
withIndex: (
|
||||
name: string,
|
||||
build:
|
||||
| ((q: { eq: (field: string, value: string) => unknown }) => unknown)
|
||||
| undefined,
|
||||
) => {
|
||||
if (name !== "by_slug") throw new Error(`unexpected index ${name}`);
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
@@ -131,3 +140,75 @@ describe("souls.insertVersion", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("souls.list", () => {
|
||||
it("uses the active browse index and only takes the requested limit", async () => {
|
||||
let requestedIndex: string | null = null;
|
||||
let requestedSoftDeletedAt: number | undefined;
|
||||
let requestedLimit: number | null = null;
|
||||
|
||||
const result = await listHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "souls") throw new Error(`unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (
|
||||
name: string,
|
||||
build:
|
||||
| ((q: { eq: (field: string, value: undefined) => unknown }) => unknown)
|
||||
| undefined,
|
||||
) => {
|
||||
requestedIndex = name;
|
||||
const q = {
|
||||
eq: (field: string, value: undefined) => {
|
||||
if (field !== "softDeletedAt") throw new Error(`unexpected field ${field}`);
|
||||
requestedSoftDeletedAt = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
build?.(q);
|
||||
return {
|
||||
order: () => ({
|
||||
take: async (limit: number) => {
|
||||
requestedLimit = limit;
|
||||
return [
|
||||
{
|
||||
_id: "souls:1",
|
||||
_creationTime: 1,
|
||||
slug: "demo-soul",
|
||||
displayName: "Demo Soul",
|
||||
summary: "A demo soul",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
softDeletedAt: undefined,
|
||||
stats: { downloads: 1, stars: 2, versions: 3, comments: 4 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
{ limit: 7 } as never,
|
||||
);
|
||||
|
||||
expect(requestedIndex).toBe("by_active_updated");
|
||||
expect(requestedSoftDeletedAt).toBeUndefined();
|
||||
expect(requestedLimit).toBe(7);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
_id: "souls:1",
|
||||
slug: "demo-soul",
|
||||
displayName: "Demo Soul",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+2
-3
@@ -138,11 +138,10 @@ export const list = query({
|
||||
}
|
||||
const entries = await ctx.db
|
||||
.query("souls")
|
||||
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
|
||||
.order("desc")
|
||||
.take(limit * 5);
|
||||
.take(limit);
|
||||
return entries
|
||||
.filter((soul) => !soul.softDeletedAt)
|
||||
.slice(0, limit)
|
||||
.map((soul) => toPublicSoul(soul))
|
||||
.filter((soul): soul is NonNullable<typeof soul> => Boolean(soul));
|
||||
},
|
||||
|
||||
+3
-1
@@ -34,6 +34,8 @@ export const toggle = mutation({
|
||||
return { starred: false };
|
||||
}
|
||||
|
||||
if (skill.softDeletedAt) throw new Error("Skill not found");
|
||||
|
||||
await ctx.db.insert("stars", {
|
||||
skillId: args.skillId,
|
||||
userId,
|
||||
@@ -70,7 +72,7 @@ export const addStarInternal = internalMutation({
|
||||
args: { userId: v.id("users"), skillId: v.id("skills") },
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) throw new Error("Skill not found");
|
||||
if (!skill || skill.softDeletedAt) throw new Error("Skill not found");
|
||||
const existing = await ctx.db
|
||||
.query("stars")
|
||||
.withIndex("by_skill_user", (q) => q.eq("skillId", args.skillId).eq("userId", args.userId))
|
||||
|
||||
@@ -230,8 +230,11 @@ export const reconcileSkillStarCounts = internalMutation({
|
||||
.order("asc")
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
|
||||
let scanned = 0;
|
||||
let patched = 0;
|
||||
for (const skill of page) {
|
||||
if (skill.softDeletedAt) continue;
|
||||
scanned += 1;
|
||||
// Count actual star records for this skill
|
||||
const starRecords = await ctx.db
|
||||
.query("stars")
|
||||
@@ -263,7 +266,7 @@ export const reconcileSkillStarCounts = internalMutation({
|
||||
}
|
||||
|
||||
return {
|
||||
scanned: page.length,
|
||||
scanned,
|
||||
patched,
|
||||
cursor: isDone ? null : continueCursor,
|
||||
isDone,
|
||||
|
||||
+585
-6
@@ -18,6 +18,7 @@ const { getAuthUserId } = await import("@convex-dev/auth/server");
|
||||
const { insertStatEvent } = await import("./skillStatEvents");
|
||||
const {
|
||||
ensureHandler,
|
||||
getByHandle,
|
||||
list,
|
||||
searchInternal,
|
||||
banUserInternal,
|
||||
@@ -32,6 +33,8 @@ type WrappedHandler<TArgs, TResult> = {
|
||||
};
|
||||
|
||||
const meHandler = (me as unknown as WrappedHandler<Record<string, never>, unknown>)._handler;
|
||||
const getByHandleHandler = (getByHandle as unknown as WrappedHandler<{ handle: string }, unknown>)
|
||||
._handler;
|
||||
|
||||
function makeCtx() {
|
||||
const patch = vi.fn();
|
||||
@@ -116,12 +119,63 @@ function makeCtx() {
|
||||
};
|
||||
}
|
||||
|
||||
function makeListCtx(users: Array<Record<string, unknown>>) {
|
||||
function makeListCtx(
|
||||
users: Array<Record<string, unknown>>,
|
||||
options?: {
|
||||
publishersByHandle?: Record<string, Record<string, unknown>>;
|
||||
usersById?: Record<string, Record<string, unknown> | null>;
|
||||
},
|
||||
) {
|
||||
const take = vi.fn(async (n: number) => users.slice(0, n));
|
||||
const collect = vi.fn(async () => users);
|
||||
const order = vi.fn(() => ({ take, collect }));
|
||||
const query = vi.fn(() => ({ order }));
|
||||
const get = vi.fn();
|
||||
const publishersByHandle = options?.publishersByHandle ?? {};
|
||||
const usersById = options?.usersById ?? {};
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
order,
|
||||
withIndex: (
|
||||
name: string,
|
||||
cb?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
||||
let handle = "";
|
||||
cb?.({
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
return {
|
||||
unique: vi.fn(async () => users.find((user) => user.handle === handle) ?? null),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (
|
||||
name: string,
|
||||
cb?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
if (name !== "by_handle") throw new Error(`Unexpected publishers index ${name}`);
|
||||
let handle = "";
|
||||
cb?.({
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return {};
|
||||
},
|
||||
});
|
||||
return { unique: vi.fn(async () => publishersByHandle[handle] ?? null) };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
const get = vi.fn<(id: string) => Promise<Record<string, unknown> | null>>(
|
||||
async (id: string) => usersById[id] ?? null,
|
||||
);
|
||||
return {
|
||||
ctx: { db: { query, get, normalizeId: vi.fn() } } as never,
|
||||
take,
|
||||
@@ -335,6 +389,115 @@ describe("ensureHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs an existing handle that is no longer claimable", async () => {
|
||||
const { ctx, patch, query } = makeCtx();
|
||||
query.mockImplementation(((table: string) => {
|
||||
if (table === "reservedHandles") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_handle_active_updatedAt") {
|
||||
throw new Error(`Unexpected reservedHandles index ${name}`);
|
||||
}
|
||||
return { order: () => ({ take: async () => [] }) };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (
|
||||
name: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
if (field === "linkedUserId") linkedUserId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
if (name === "by_handle") {
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
if (handle === "openclaw") {
|
||||
return {
|
||||
_id: "publishers:openclaw",
|
||||
kind: "org",
|
||||
handle: "openclaw",
|
||||
displayName: "OpenClaw",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (name === "by_linked_user") {
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
linkedUserId === "users:owner"
|
||||
? {
|
||||
_id: "publishers:openclaw-user",
|
||||
kind: "user",
|
||||
handle: "openclaw-user",
|
||||
linkedUserId: "users:owner",
|
||||
displayName: "OpenClaw User",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected publishers index ${name}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_publisher_user") {
|
||||
throw new Error(`Unexpected publisherMembers index ${name}`);
|
||||
}
|
||||
return { unique: vi.fn(async () => null) };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "packages" || table === "skills") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_owner_publisher") {
|
||||
throw new Error(`Unexpected ${table} index ${name}`);
|
||||
}
|
||||
return { collect: vi.fn(async () => []) };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}) as never);
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:owner",
|
||||
user: {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "openclaw",
|
||||
displayName: "openclaw",
|
||||
name: "openclaw",
|
||||
email: "owner@example.com",
|
||||
role: "user",
|
||||
createdAt: 1,
|
||||
personalPublisherId: "publishers:openclaw-user",
|
||||
},
|
||||
} as never);
|
||||
|
||||
await ensureHandler(ctx);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith("users:owner", {
|
||||
handle: "openclaw-2",
|
||||
displayName: "openclaw-2",
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-claim a reserved handle for another user", async () => {
|
||||
const { ctx, patch, query } = makeCtx();
|
||||
query.mockImplementation(((table: string) => {
|
||||
@@ -395,7 +558,10 @@ describe("ensureHandler", () => {
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
withIndex: (
|
||||
name: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
@@ -498,6 +664,181 @@ describe("me", () => {
|
||||
expect(result).toBeNull();
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when auth resolves to an invalid user id", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:broken" as never);
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === "users:broken") throw new Error("Table mismatch");
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await meHandler({ db: { get } } as never, {});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(get).toHaveBeenCalledWith("users:broken");
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.getByHandle", () => {
|
||||
it("normalizes the incoming handle before querying", async () => {
|
||||
const unique = vi.fn(async () => ({
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "jaredforreal",
|
||||
name: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
image: undefined,
|
||||
bio: undefined,
|
||||
}));
|
||||
|
||||
const result = await getByHandleHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "users") throw new Error(`Unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (
|
||||
name: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
if (name !== "handle") throw new Error(`Unexpected index ${name}`);
|
||||
let handle = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
expect(handle).toBe("jaredforreal");
|
||||
return { unique };
|
||||
},
|
||||
};
|
||||
}),
|
||||
get: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
{ handle: " @JaredForReal " },
|
||||
);
|
||||
|
||||
expect(unique).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({
|
||||
_id: "users:owner",
|
||||
handle: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the linked user for a personal publisher handle", async () => {
|
||||
const userUnique = vi.fn(async () => null);
|
||||
const publisherUnique = vi.fn(async () => ({
|
||||
_id: "publishers:jaredforreal",
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
linkedUserId: "users:owner",
|
||||
displayName: "Jared",
|
||||
}));
|
||||
const get = vi.fn(async (id: string) =>
|
||||
id === "users:owner"
|
||||
? {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "jared",
|
||||
name: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
image: undefined,
|
||||
bio: "Profile",
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const result = await getByHandleHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
||||
return { unique: userUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_handle") throw new Error(`Unexpected publishers index ${name}`);
|
||||
return { unique: publisherUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
get,
|
||||
},
|
||||
} as never,
|
||||
{ handle: "jaredforreal" },
|
||||
);
|
||||
|
||||
expect(userUnique).toHaveBeenCalledOnce();
|
||||
expect(publisherUnique).toHaveBeenCalledOnce();
|
||||
expect(get).toHaveBeenCalledWith("users:owner");
|
||||
expect(result).toMatchObject({
|
||||
_id: "users:owner",
|
||||
handle: "jared",
|
||||
name: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
bio: "Profile",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resolve a deleted personal publisher handle", async () => {
|
||||
const userUnique = vi.fn(async () => null);
|
||||
const publisherUnique = vi.fn(async () => ({
|
||||
_id: "publishers:jaredforreal",
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
linkedUserId: "users:owner",
|
||||
deletedAt: 1_700_000_000_000,
|
||||
displayName: "Jared",
|
||||
}));
|
||||
const get = vi.fn(async () => {
|
||||
throw new Error("linked user should not be loaded for inactive publishers");
|
||||
});
|
||||
|
||||
const result = await getByHandleHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
||||
return { unique: userUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_handle") throw new Error(`Unexpected publishers index ${name}`);
|
||||
return { unique: publisherUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
get,
|
||||
},
|
||||
} as never,
|
||||
{ handle: "jaredforreal" },
|
||||
);
|
||||
|
||||
expect(userUnique).toHaveBeenCalledOnce();
|
||||
expect(publisherUnique).toHaveBeenCalledOnce();
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.syncGitHubProfileInternal", () => {
|
||||
@@ -580,7 +921,10 @@ describe("users.syncGitHubProfileInternal", () => {
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
withIndex: (
|
||||
name: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
@@ -731,6 +1075,81 @@ describe("users.list", () => {
|
||||
expect(result.items[0]?.handle).toBe("alice");
|
||||
});
|
||||
|
||||
it("includes an exact older handle match outside the bounded scan", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const users = [
|
||||
...Array.from({ length: 500 }, (_value, index) => ({
|
||||
_id: `users:recent-${index}`,
|
||||
_creationTime: 10_000 - index,
|
||||
handle: `recent-${index}`,
|
||||
role: "user",
|
||||
})),
|
||||
{ _id: "users:older", _creationTime: 1, handle: "alice", role: "user" },
|
||||
];
|
||||
const { ctx, take, collect } = makeListCtx(users);
|
||||
const listHandler = (
|
||||
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler;
|
||||
|
||||
const result = (await listHandler(ctx, { limit: 50, search: "alice" })) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
expect(take).toHaveBeenCalledWith(500);
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.items[0]?._id).toBe("users:older");
|
||||
});
|
||||
|
||||
it("includes an exact personal publisher handle match without a full collect", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const users = [{ _id: "users:1", _creationTime: 2, handle: "alice", role: "user" }];
|
||||
const { ctx, take, collect } = makeListCtx(users, {
|
||||
publishersByHandle: {
|
||||
lmlukef: {
|
||||
_id: "publishers:lmlukef",
|
||||
kind: "user",
|
||||
handle: "lmlukef",
|
||||
linkedUserId: "users:owner",
|
||||
},
|
||||
},
|
||||
usersById: {
|
||||
"users:owner": {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "luke",
|
||||
name: "different-gh-login",
|
||||
displayName: "Luke",
|
||||
role: "user",
|
||||
},
|
||||
},
|
||||
});
|
||||
const listHandler = (
|
||||
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler;
|
||||
|
||||
const result = (await listHandler(ctx, { limit: 50, search: "lmLukeF" })) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
expect(take).toHaveBeenCalledWith(500);
|
||||
expect(collect).not.toHaveBeenCalled();
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
_id: "users:owner",
|
||||
handle: "luke",
|
||||
displayName: "Luke",
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps large limit and search scan size", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
@@ -785,6 +1204,45 @@ describe("users.list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes an exact publisher-handle match even when the linked user is banned", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
user: { _id: "users:admin", role: "admin" },
|
||||
} as never);
|
||||
const users = [
|
||||
{
|
||||
_id: "users:1",
|
||||
_creationTime: 3,
|
||||
handle: "different-login",
|
||||
displayName: "ClawGrid",
|
||||
deletedAt: 123,
|
||||
role: "user",
|
||||
},
|
||||
{ _id: "users:2", _creationTime: 2, handle: "alice", role: "user" },
|
||||
];
|
||||
const { ctx } = makeListCtx(users, {
|
||||
publishersByHandle: {
|
||||
clawgrid: {
|
||||
_id: "publishers:clawgrid",
|
||||
handle: "clawgrid",
|
||||
kind: "user",
|
||||
linkedUserId: "users:1",
|
||||
},
|
||||
},
|
||||
usersById: {
|
||||
"users:1": users[0]!,
|
||||
},
|
||||
});
|
||||
const listHandler = (
|
||||
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler;
|
||||
|
||||
await expect(listHandler(ctx, { limit: 10, search: "clawgrid" })).resolves.toMatchObject({
|
||||
total: 1,
|
||||
items: [{ _id: "users:1", deletedAt: 123 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats whitespace search as empty search", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:admin",
|
||||
@@ -892,6 +1350,127 @@ describe("users.searchInternal", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes an exact personal publisher handle match in admin search", async () => {
|
||||
const users = [
|
||||
{ _id: "users:1", _creationTime: 2, handle: "alice", name: "alice", role: "user" },
|
||||
];
|
||||
const { ctx, get } = makeListCtx(users, {
|
||||
publishersByHandle: {
|
||||
lmlukef: {
|
||||
_id: "publishers:lmlukef",
|
||||
kind: "user",
|
||||
handle: "lmlukef",
|
||||
linkedUserId: "users:owner",
|
||||
},
|
||||
},
|
||||
usersById: {
|
||||
"users:owner": {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "luke",
|
||||
name: "different-gh-login",
|
||||
displayName: "Luke",
|
||||
role: "user",
|
||||
},
|
||||
},
|
||||
});
|
||||
const handler = (
|
||||
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler;
|
||||
get.mockImplementation(async (id: string) => {
|
||||
if (id === "users:admin") return { _id: "users:admin", role: "admin" };
|
||||
if (id === "users:owner") {
|
||||
return {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "luke",
|
||||
name: "different-gh-login",
|
||||
displayName: "Luke",
|
||||
role: "user",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = (await handler(ctx, {
|
||||
actorUserId: "users:admin",
|
||||
query: "lmLukeF",
|
||||
limit: 25,
|
||||
})) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.items[0]).toEqual({
|
||||
userId: "users:owner",
|
||||
handle: "luke",
|
||||
displayName: "Luke",
|
||||
name: "different-gh-login",
|
||||
role: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not double-count total when the fallback user already matched off-page", async () => {
|
||||
const users = [
|
||||
{
|
||||
_id: "users:1",
|
||||
_creationTime: 3,
|
||||
handle: "lmquery-top",
|
||||
name: "lmquery-top",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
_id: "users:2",
|
||||
_creationTime: 2,
|
||||
handle: "lmquery-mid",
|
||||
name: "lmquery-mid",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "owner-lmquery",
|
||||
name: "owner-lmquery",
|
||||
displayName: "Owner Lmquery",
|
||||
role: "user",
|
||||
},
|
||||
];
|
||||
const { ctx, get } = makeListCtx(users, {
|
||||
publishersByHandle: {
|
||||
lmquery: {
|
||||
_id: "publishers:lmquery",
|
||||
kind: "user",
|
||||
handle: "lmquery",
|
||||
linkedUserId: "users:owner",
|
||||
},
|
||||
},
|
||||
usersById: {
|
||||
"users:owner": users[2] as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
const handler = (
|
||||
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
||||
)._handler;
|
||||
get.mockImplementation(async (id: string) => {
|
||||
if (id === "users:admin") return { _id: "users:admin", role: "admin" };
|
||||
if (id === "users:owner") return users[2] as Record<string, unknown>;
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = (await handler(ctx, {
|
||||
actorUserId: "users:admin",
|
||||
query: "lmquery",
|
||||
limit: 2,
|
||||
})) as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
expect(result.total).toBe(3);
|
||||
expect(result.items.map((item) => item.userId)).toEqual(["users:owner", "users:1"]);
|
||||
});
|
||||
|
||||
it("rejects deactivated actors", async () => {
|
||||
const { ctx, get } = makeListCtx([]);
|
||||
const handler = (
|
||||
@@ -914,7 +1493,7 @@ describe("users.searchInternal", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps limit for empty query and uses non-search path", async () => {
|
||||
it("still caps empty-query listing and uses non-search path", async () => {
|
||||
const users = Array.from({ length: 400 }, (_value, index) => ({
|
||||
_id: `users:${index}`,
|
||||
_creationTime: 1_000 - index,
|
||||
|
||||
+123
-46
@@ -1,12 +1,16 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, assertModerator, requireUser } from "./lib/access";
|
||||
import { assertAdmin, assertModerator, getOptionalActiveAuthUserId, requireUser } from "./lib/access";
|
||||
import { syncGitHubProfile } from "./lib/githubAccount";
|
||||
import { ensurePersonalPublisherForUser, getPublisherByHandle } from "./lib/publishers";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPublisherByHandle,
|
||||
getUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
getLatestActiveReservedHandle,
|
||||
@@ -36,12 +40,7 @@ export const getByIdInternal = internalQuery({
|
||||
export const getByHandleInternal = internalQuery({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const normalizedHandle = normalizeReservedHandle(args.handle);
|
||||
if (!normalizedHandle) return null;
|
||||
return await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
|
||||
.unique();
|
||||
return await getUserByHandleOrPersonalPublisher(ctx, args.handle);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,15 +56,28 @@ export const searchInternal = internalQuery({
|
||||
assertAdmin(actor);
|
||||
|
||||
const limit = clampInt(args.limit ?? 20, 1, MAX_USER_LIST_LIMIT);
|
||||
const result = await queryUsersForAdminList(ctx, { limit, search: args.query });
|
||||
const items = result.items.map((user) => ({
|
||||
const exactHandleUser = args.query
|
||||
? await getUserByHandleOrPersonalPublisher(ctx, args.query)
|
||||
: null;
|
||||
const result = await queryUsersForAdminList(ctx, {
|
||||
limit,
|
||||
search: args.query,
|
||||
exactUserId: exactHandleUser?._id,
|
||||
});
|
||||
const dedupedUsers = exactHandleUser
|
||||
? [exactHandleUser, ...result.items.filter((user) => user._id !== exactHandleUser._id)]
|
||||
: result.items;
|
||||
const total = exactHandleUser
|
||||
? result.total + (result.containsExactUser ? 0 : 1)
|
||||
: result.total;
|
||||
const items = dedupedUsers.slice(0, limit).map((user) => ({
|
||||
userId: user._id,
|
||||
handle: user.handle ?? null,
|
||||
displayName: user.displayName ?? null,
|
||||
name: user.name ?? null,
|
||||
role: user.role ?? null,
|
||||
}));
|
||||
return { items, total: result.total };
|
||||
return { items, total };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -166,17 +178,9 @@ export const syncGitHubProfileAction = internalAction({
|
||||
export const me = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
let userId: Awaited<ReturnType<typeof getAuthUserId>>;
|
||||
try {
|
||||
userId = await getAuthUserId(ctx);
|
||||
} catch {
|
||||
// Public pages should treat broken/stale auth as anonymous instead of crashing SSR.
|
||||
return null;
|
||||
}
|
||||
const userId = await getOptionalActiveAuthUserId(ctx);
|
||||
if (!userId) return null;
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return null;
|
||||
return user;
|
||||
return await ctx.db.get(userId);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -235,6 +239,9 @@ async function computeEnsureUpdates(ctx: MutationCtx, user: Doc<"users">) {
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
const existingHandle = normalizeHandle(user.handle);
|
||||
const existingHandleClaimable = existingHandle
|
||||
? await canUserClaimHandle(ctx, existingHandle, user._id)
|
||||
: false;
|
||||
const githubLogin = normalizeHandle(user.name);
|
||||
const requestedHandle = deriveHandle({
|
||||
existingHandle,
|
||||
@@ -245,15 +252,20 @@ async function computeEnsureUpdates(ctx: MutationCtx, user: Doc<"users">) {
|
||||
requestedHandle && (await canUserClaimHandle(ctx, requestedHandle, user._id))
|
||||
? requestedHandle
|
||||
: undefined;
|
||||
if (!derivedHandle && !existingHandle) {
|
||||
const emailFallback = !requestedHandle && user.email ? user.email.split("@")[0]?.trim() : user.email?.split("@")[0]?.trim();
|
||||
if (!derivedHandle && (!existingHandle || !existingHandleClaimable)) {
|
||||
const emailFallback = normalizeHandle(user.email?.split("@")[0]);
|
||||
const emailFallbackHandle =
|
||||
emailFallback && emailFallback !== requestedHandle
|
||||
? await resolveAvailableHandle(ctx, emailFallback, user._id)
|
||||
: undefined;
|
||||
derivedHandle =
|
||||
(emailFallback &&
|
||||
emailFallback !== requestedHandle &&
|
||||
(await resolveAvailableHandle(ctx, emailFallback, user._id))) ||
|
||||
(await resolveAvailableHandle(ctx, requestedHandle, user._id));
|
||||
(await resolveAvailableHandle(
|
||||
ctx,
|
||||
requestedHandle ?? existingHandle ?? githubLogin ?? emailFallback,
|
||||
user._id,
|
||||
)) ?? emailFallbackHandle;
|
||||
}
|
||||
const baseHandle = derivedHandle ?? existingHandle;
|
||||
const baseHandle = derivedHandle ?? (existingHandleClaimable ? existingHandle : undefined);
|
||||
|
||||
if (derivedHandle && existingHandle !== derivedHandle) {
|
||||
updates.handle = derivedHandle;
|
||||
@@ -353,7 +365,41 @@ export const list = query({
|
||||
const { user } = await requireUser(ctx);
|
||||
assertAdmin(user);
|
||||
const limit = clampInt(args.limit ?? 50, 1, MAX_USER_LIST_LIMIT);
|
||||
return queryUsersForAdminList(ctx, { limit, search: args.search });
|
||||
const exactHandleUser = args.search
|
||||
? await getUserByHandleOrPersonalPublisher(ctx, args.search)
|
||||
: null;
|
||||
const result = await queryUsersForAdminList(ctx, {
|
||||
limit,
|
||||
search: args.search,
|
||||
exactUserId: exactHandleUser?._id,
|
||||
});
|
||||
const dedupedUsers = exactHandleUser
|
||||
? [exactHandleUser, ...result.items.filter((entry) => entry._id !== exactHandleUser._id)]
|
||||
: result.items;
|
||||
const total = exactHandleUser
|
||||
? result.total + (result.containsExactUser ? 0 : 1)
|
||||
: result.total;
|
||||
return {
|
||||
items: dedupedUsers.slice(0, limit),
|
||||
total,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const listPublic = query({
|
||||
args: { limit: v.optional(v.number()), search: v.optional(v.string()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = clampInt(args.limit ?? 40, 1, 100);
|
||||
const result = await queryUsersForPublicList(ctx, {
|
||||
limit,
|
||||
search: args.search,
|
||||
});
|
||||
return {
|
||||
items: result.items
|
||||
.map((user) => toPublicUser(user))
|
||||
.filter((user): user is NonNullable<ReturnType<typeof toPublicUser>> => Boolean(user)),
|
||||
total: result.total,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -367,26 +413,47 @@ function computeUserSearchScanLimit(limit: number) {
|
||||
}
|
||||
|
||||
async function queryUsersForAdminList(
|
||||
ctx: {
|
||||
db: {
|
||||
query: (table: "users") => {
|
||||
order: (order: "desc") => { take: (n: number) => Promise<Doc<"users">[]> };
|
||||
};
|
||||
};
|
||||
},
|
||||
args: { limit: number; search?: string },
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
args: { limit: number; search?: string; exactUserId?: Id<"users"> },
|
||||
) {
|
||||
const normalizedSearch = normalizeSearchQuery(args.search);
|
||||
const orderedUsers = ctx.db.query("users").order("desc");
|
||||
|
||||
if (!normalizedSearch) {
|
||||
const items = await orderedUsers.take(args.limit);
|
||||
return { items, total: items.length };
|
||||
return { items, total: items.length, containsExactUser: false };
|
||||
}
|
||||
|
||||
const scannedUsers = await orderedUsers.take(computeUserSearchScanLimit(args.limit));
|
||||
const result = buildUserSearchResults(scannedUsers, normalizedSearch);
|
||||
return { items: result.items.slice(0, args.limit), total: result.total };
|
||||
return {
|
||||
items: result.items.slice(0, args.limit),
|
||||
total: result.total,
|
||||
containsExactUser: args.exactUserId
|
||||
? result.items.some((user) => user._id === args.exactUserId)
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryUsersForPublicList(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
args: { limit: number; search?: string },
|
||||
) {
|
||||
const normalizedSearch = normalizeSearchQuery(args.search);
|
||||
const scanLimit = normalizedSearch
|
||||
? computeUserSearchScanLimit(args.limit)
|
||||
: clampInt(args.limit * 6, args.limit, MAX_USER_SEARCH_SCAN);
|
||||
const scannedUsers = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_active_handle", (q) => q.eq("deletedAt", undefined).eq("deactivatedAt", undefined))
|
||||
.order("desc")
|
||||
.take(scanLimit);
|
||||
const activeUsers = scannedUsers.filter((user) => Boolean(user.handle));
|
||||
const result = buildUserSearchResults(activeUsers, normalizedSearch);
|
||||
return {
|
||||
items: result.items.slice(0, args.limit),
|
||||
total: result.total,
|
||||
};
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
@@ -396,11 +463,21 @@ function clampInt(value: number, min: number, max: number) {
|
||||
export const getByHandle = query({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", args.handle))
|
||||
.unique();
|
||||
return toPublicUser(user);
|
||||
return toPublicUser(await getActiveUserByHandleOrPersonalPublisher(ctx, args.handle));
|
||||
},
|
||||
});
|
||||
|
||||
/** Lightweight stats for user hover tooltips. Uses the skills by_owner index. */
|
||||
export const getHoverStats = query({
|
||||
args: { userId: v.id("users") },
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
|
||||
return {
|
||||
publishedSkills: user?.publishedSkills ?? 0,
|
||||
totalStars: user?.totalStars ?? 0,
|
||||
totalDownloads: user?.totalDownloads ?? 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+99
-38
@@ -161,11 +161,10 @@ describe("package VT retries", () => {
|
||||
{ releaseId: "packageReleases:demo", attempt: 2 },
|
||||
);
|
||||
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(5 * 60 * 1000, expect.anything(), {
|
||||
releaseId: "packageReleases:demo",
|
||||
attempt: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("retries package upload when VT upload fails", async () => {
|
||||
@@ -195,7 +194,9 @@ describe("package VT retries", () => {
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
get: vi.fn(
|
||||
async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" }),
|
||||
),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
@@ -208,11 +209,10 @@ describe("package VT retries", () => {
|
||||
sha256hash: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 2 },
|
||||
);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(5 * 60 * 1000, expect.anything(), {
|
||||
releaseId: "packageReleases:demo",
|
||||
attempt: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses existing AV engine verdicts for packages without re-uploading", async () => {
|
||||
@@ -258,7 +258,9 @@ describe("package VT retries", () => {
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
get: vi.fn(
|
||||
async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" }),
|
||||
),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
@@ -275,7 +277,73 @@ describe("package VT retries", () => {
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("promotes official source-linked packages with undetected-only VT stats via fallback", async () => {
|
||||
it("does not promote official source-linked packages with suspicious static scans via fallback", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: { id: "analysis-123" } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await scanPackageReleaseWithVirusTotalHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "suspicious" },
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
isOfficial: true,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(
|
||||
async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" }),
|
||||
),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
);
|
||||
|
||||
expect(runMutation.mock.calls.length).toBe(1);
|
||||
const mutationCalls = runMutation.mock.calls as unknown as Array<
|
||||
[unknown, Record<string, unknown>]
|
||||
>;
|
||||
expect(mutationCalls.some(([, payload]) => "vtAnalysis" in payload)).toBe(false);
|
||||
expect(fetchMock.mock.calls.length).toBe(2);
|
||||
expect(scheduler.runAfter.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("promotes official source-linked packages with clean static scans via fallback", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -306,7 +374,7 @@ describe("package VT retries", () => {
|
||||
version: "1.0.0",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "suspicious" },
|
||||
staticScan: { status: "clean" },
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
@@ -318,7 +386,9 @@ describe("package VT retries", () => {
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
get: vi.fn(
|
||||
async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" }),
|
||||
),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
@@ -382,7 +452,9 @@ describe("package VT retries", () => {
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
get: vi.fn(
|
||||
async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" }),
|
||||
),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
@@ -422,14 +494,13 @@ describe("package VT retries", () => {
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 4 },
|
||||
);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(5 * 60 * 1000, expect.anything(), {
|
||||
releaseId: "packageReleases:demo",
|
||||
attempt: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the same undetected-only fallback during package polling", async () => {
|
||||
it("does not apply undetected-only fallback during package polling when static scan is suspicious", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -476,17 +547,8 @@ describe("package VT retries", () => {
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({
|
||||
status: "clean",
|
||||
source: "engines-undetected-fallback",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
expect(scheduler.runAfter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies the same undetected-only fallback during community package polling", async () => {
|
||||
@@ -603,10 +665,9 @@ describe("package VT retries", () => {
|
||||
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 4 },
|
||||
);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(5 * 60 * 1000, expect.anything(), {
|
||||
releaseId: "packageReleases:demo",
|
||||
attempt: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+65
-30
@@ -179,7 +179,7 @@ function buildPackageUndetectedFallbackAnalysis(
|
||||
return null;
|
||||
}
|
||||
if (release.llmAnalysis?.status !== "clean") return null;
|
||||
if (!release.staticScan || release.staticScan.status === "malicious") return null;
|
||||
if (!release.staticScan || release.staticScan.status !== "clean") return null;
|
||||
if (stats.malicious !== 0 || stats.suspicious !== 0) return null;
|
||||
if ((stats.harmless ?? 0) <= 0 && (stats.undetected ?? 0) <= 0) return null;
|
||||
|
||||
@@ -187,7 +187,7 @@ function buildPackageUndetectedFallbackAnalysis(
|
||||
status: "clean",
|
||||
verdict: "undetected-only-fallback",
|
||||
analysis:
|
||||
"VirusTotal reported no malicious or suspicious engine hits. ClawHub promoted this source-linked package after clean LLM and non-malicious static scans.",
|
||||
"VirusTotal reported no malicious or suspicious engine hits. ClawHub promoted this source-linked package after clean LLM and clean static scans.",
|
||||
source: "engines-undetected-fallback",
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
@@ -635,10 +635,15 @@ export const scanPackageReleaseWithVirusTotal = internalAction({
|
||||
`[vt:package] Release ${args.releaseId} missing ${missingFiles}/${release.files.length} files, retrying`,
|
||||
);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
await runAfterRef(
|
||||
ctx,
|
||||
PACKAGE_SCAN_RETRY_DELAY_MS,
|
||||
internalRefs.vt.scanPackageReleaseWithVirusTotal,
|
||||
{
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -686,18 +691,28 @@ export const scanPackageReleaseWithVirusTotal = internalAction({
|
||||
const error = await response.text();
|
||||
console.error("[vt:package] VirusTotal upload error:", error);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
await runAfterRef(
|
||||
ctx,
|
||||
PACKAGE_SCAN_RETRY_DELAY_MS,
|
||||
internalRefs.vt.scanPackageReleaseWithVirusTotal,
|
||||
{
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.pollPackageReleaseScanResults, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: 1,
|
||||
});
|
||||
await runAfterRef(
|
||||
ctx,
|
||||
PACKAGE_SCAN_RETRY_DELAY_MS,
|
||||
internalRefs.vt.pollPackageReleaseScanResults,
|
||||
{
|
||||
releaseId: args.releaseId,
|
||||
attempt: 1,
|
||||
},
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[vt:package] Uploaded ${pkg.name}@${release.version} for scanning (${sha256hash})`,
|
||||
@@ -705,10 +720,15 @@ export const scanPackageReleaseWithVirusTotal = internalAction({
|
||||
} catch (error) {
|
||||
console.error("[vt:package] Failed to upload to VirusTotal:", error);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
await runAfterRef(
|
||||
ctx,
|
||||
PACKAGE_SCAN_RETRY_DELAY_MS,
|
||||
internalRefs.vt.scanPackageReleaseWithVirusTotal,
|
||||
{
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -737,10 +757,15 @@ export const pollPackageReleaseScanResults = internalAction({
|
||||
const vtResult = await checkExistingFile(apiKey, release.sha256hash);
|
||||
if (!vtResult) {
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.pollPackageReleaseScanResults, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
await runAfterRef(
|
||||
ctx,
|
||||
PACKAGE_SCAN_RETRY_DELAY_MS,
|
||||
internalRefs.vt.pollPackageReleaseScanResults,
|
||||
{
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -756,18 +781,28 @@ export const pollPackageReleaseScanResults = internalAction({
|
||||
|
||||
await requestRescan(apiKey, release.sha256hash);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.pollPackageReleaseScanResults, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
await runAfterRef(
|
||||
ctx,
|
||||
PACKAGE_SCAN_RETRY_DELAY_MS,
|
||||
internalRefs.vt.pollPackageReleaseScanResults,
|
||||
{
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[vt:package] Error polling ${release.sha256hash}:`, error);
|
||||
if (attempt < PACKAGE_SCAN_MAX_ATTEMPTS) {
|
||||
await runAfterRef(ctx, PACKAGE_SCAN_RETRY_DELAY_MS, internalRefs.vt.pollPackageReleaseScanResults, {
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
await runAfterRef(
|
||||
ctx,
|
||||
PACKAGE_SCAN_RETRY_DELAY_MS,
|
||||
internalRefs.vt.pollPackageReleaseScanResults,
|
||||
{
|
||||
releaseId: args.releaseId,
|
||||
attempt: attempt + 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ read_when:
|
||||
|
||||
- Web app: TanStack Start (React) under `src/`.
|
||||
- Backend: Convex under `convex/` (DB, storage, actions, HTTP routes).
|
||||
- CLI: `packages/clawdhub/` (published as `clawhub`, legacy `clawdhub`).
|
||||
- CLI: `packages/clawhub/` (published as `clawhub`, legacy `clawdhub`).
|
||||
- Shared schemas/routes: `packages/schema/` (`clawhub-schema`).
|
||||
|
||||
## Data + storage
|
||||
|
||||
@@ -11,6 +11,7 @@ read_when:
|
||||
|
||||
- Convex Auth + GitHub OAuth App.
|
||||
- GitHub is the only supported login provider.
|
||||
- Disabled/banned accounts are blocked during OAuth completion and should surface a user-facing reason instead of a generic auth failure.
|
||||
- Env vars:
|
||||
- `AUTH_GITHUB_ID`
|
||||
- `AUTH_GITHUB_SECRET`
|
||||
|
||||
+66
-4
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# CLI
|
||||
|
||||
CLI package: `packages/clawdhub/` (published as `clawhub`, bin: `clawhub`).
|
||||
CLI package: `packages/clawhub/` (published as `clawhub`, bin: `clawhub`).
|
||||
|
||||
From this repo you can run it via the wrapper script:
|
||||
|
||||
@@ -62,6 +62,9 @@ When no proxy variable is set, behavior is unchanged (direct connections).
|
||||
Stores your API token + cached registry URL.
|
||||
|
||||
- macOS: `~/Library/Application Support/clawhub/config.json`
|
||||
- Linux/XDG: `$XDG_CONFIG_HOME/clawhub/config.json` or `~/.config/clawhub/config.json`
|
||||
- Windows: `%APPDATA%\\clawhub\\config.json`
|
||||
- Legacy fallback: if `clawhub/config.json` does not exist yet but `clawdhub/config.json` does, the CLI reuses the legacy path
|
||||
- override: `CLAWHUB_CONFIG_PATH` (legacy `CLAWDHUB_CONFIG_PATH`)
|
||||
|
||||
## Commands
|
||||
@@ -132,12 +135,13 @@ Stores your API token + cached registry URL.
|
||||
- refuses by default
|
||||
- overwrites with `--force` (or prompt, if interactive)
|
||||
|
||||
### `publish <path>`
|
||||
### `skill publish <path>`
|
||||
|
||||
- Publishes via `POST /api/v1/skills` (multipart).
|
||||
- Requires semver: `--version 1.2.3`.
|
||||
- Publishing a skill means it is released under `MIT-0` on ClawHub.
|
||||
- Published skills are free to use, modify, and redistribute without attribution.
|
||||
- Legacy alias: `publish <path>`.
|
||||
|
||||
### `delete <slug>`
|
||||
|
||||
@@ -208,11 +212,69 @@ Stores your API token + cached registry URL.
|
||||
- `--fuzzy` resolves the handle via fuzzy user search (admin only).
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `package publish <path>`
|
||||
### `package publish <source>`
|
||||
|
||||
- Publishes a code plugin or bundle plugin via `POST /api/v1/packages`.
|
||||
- `<source>` accepts:
|
||||
- Local folder path: `./my-plugin`
|
||||
- GitHub repo: `owner/repo` or `owner/repo@ref`
|
||||
- GitHub URL: `https://github.com/owner/repo`
|
||||
- Metadata is auto-detected from `package.json`, `openclaw.plugin.json`, and `openclaw.bundle.json`.
|
||||
- For GitHub sources, source attribution is auto-populated from the repo, resolved commit, ref, and subpath.
|
||||
- For local folders, source attribution is auto-detected from local git when the origin remote points at GitHub.
|
||||
- External code plugins must declare `openclaw.compat.pluginApi` and `openclaw.build.openclawVersion` explicitly.
|
||||
Top-level `package.json.version` is not used as a fallback for publish validation.
|
||||
- `--dry-run` previews the resolved publish payload without uploading.
|
||||
- `--json` emits machine-readable output for CI.
|
||||
- `--owner <handle>` lets admins publish under a shared owner account while keeping their own token as the actor.
|
||||
- Code plugins still require `--source-repo` and `--source-commit`.
|
||||
- Existing flags (`--family`, `--name`, `--version`, `--source-repo`, `--source-commit`, `--source-ref`, `--source-path`) still work as overrides.
|
||||
- Private GitHub repos require `GITHUB_TOKEN`.
|
||||
|
||||
#### GitHub Actions
|
||||
|
||||
ClawHub also ships an official reusable workflow at
|
||||
[`/.github/workflows/package-publish.yml`](../.github/workflows/package-publish.yml)
|
||||
for plugin repos.
|
||||
|
||||
Typical caller setup:
|
||||
|
||||
```yaml
|
||||
name: Package Publish
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
dry-run:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: openclaw/clawhub/.github/workflows/package-publish.yml@main
|
||||
with:
|
||||
dry_run: true
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: openclaw/clawhub/.github/workflows/package-publish.yml@main
|
||||
with:
|
||||
dry_run: false
|
||||
secrets:
|
||||
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The reusable workflow defaults `source` to the caller repo.
|
||||
- `pull_request` should use `dry_run: true` so CI stays non-polluting.
|
||||
- Real publishes should be limited to trusted events such as `workflow_dispatch` or tag pushes.
|
||||
- Trusted publishing without a secret only works on `workflow_dispatch`; tag pushes still need `clawhub_token`.
|
||||
- Keep `clawhub_token` available for first publish, untrusted packages, or break-glass publishes.
|
||||
- The workflow uploads the JSON result as an artifact and exposes it as workflow outputs.
|
||||
|
||||
### `sync`
|
||||
|
||||
|
||||
+42
-9
@@ -25,21 +25,54 @@ bunx convex deploy
|
||||
Or use the GitHub Actions pipeline:
|
||||
|
||||
```bash
|
||||
gh workflow run deploy.yml
|
||||
gh workflow run deploy.yml --repo openclaw/clawhub --ref main
|
||||
```
|
||||
|
||||
GitHub Actions secrets required for `deploy.yml`:
|
||||
Production deploy notes:
|
||||
|
||||
- `CONVEX_DEPLOY_KEY`
|
||||
- Optional: `PLAYWRIGHT_AUTH_STORAGE_STATE_JSON` for authenticated smoke coverage
|
||||
- `deploy.yml` is manual-only (`workflow_dispatch`). Merging to `main` does not deploy.
|
||||
- The workflow must be started from `main`.
|
||||
- Deploy targets:
|
||||
- `full`: deploy Convex, verify contract, wait for the matching Vercel production deploy, then run smoke tests
|
||||
- `backend`: deploy Convex, verify contract, then run smoke tests against current production
|
||||
- `frontend`: wait for the Vercel production deploy for the selected `main` SHA, then run smoke tests
|
||||
- `frontend` does not call `vercel deploy` directly yet. It relies on the existing Vercel Git-based production deploy for that SHA.
|
||||
- The real deploy job uses the GitHub `Production` environment for deploy secrets, but it does not wait for a separate approval.
|
||||
- Required `Production` environment secret: `CONVEX_DEPLOY_KEY`.
|
||||
- Optional `Production` environment secret: `PLAYWRIGHT_AUTH_STORAGE_STATE_JSON` for authenticated smoke coverage.
|
||||
|
||||
`deploy.yml` now fails in preflight if `CONVEX_DEPLOY_KEY` is missing. Web deploy
|
||||
verification no longer depends on a separate Vercel token in GitHub Actions.
|
||||
## CLI npm release
|
||||
|
||||
The `clawhub` CLI package is released separately from the app deploy.
|
||||
Only stable releases are supported here: `vX.Y.Z`.
|
||||
|
||||
Use the GitHub Actions workflow:
|
||||
|
||||
```bash
|
||||
gh workflow run clawhub-cli-npm-release.yml \
|
||||
--repo openclaw/clawhub \
|
||||
--ref main \
|
||||
-f tag=v0.10.0 \
|
||||
-f preflight_only=true
|
||||
```
|
||||
|
||||
Then rerun the same workflow from `main` with:
|
||||
|
||||
- the same `tag`
|
||||
- `preflight_only=false`
|
||||
- `preflight_run_id=<successful preflight run id>`
|
||||
|
||||
CLI release notes:
|
||||
|
||||
- Real publishes are manual-only and require the workflow to be started from `main`.
|
||||
- The publish job waits at the GitHub `npm-release` environment for approval.
|
||||
- npm auth is handled through npm trusted publishing, not an `NPM_TOKEN`.
|
||||
- npm trusted publisher must be configured for package `clawhub` with repository `openclaw/clawhub`, workflow `clawhub-cli-npm-release.yml`, and environment `npm-release`.
|
||||
|
||||
That workflow assumes Vercel Git integration is enabled for this repo. It does
|
||||
not run `vercel deploy` directly; instead it waits for the GitHub commit status
|
||||
`Vercel – clawhub` for the pushed SHA, then runs smoke tests against
|
||||
production.
|
||||
not run `vercel deploy` directly; frontend-related steps wait for the GitHub
|
||||
commit status `Vercel – clawhub` for the selected SHA, then run smoke tests
|
||||
against production.
|
||||
|
||||
Ensure Convex env is set (auth + embeddings):
|
||||
|
||||
|
||||
@@ -8,6 +8,24 @@ read_when:
|
||||
|
||||
# GitHub import (public repos)
|
||||
|
||||
## CLI
|
||||
|
||||
For plugin authors, the recommended GitHub import path is now the CLI:
|
||||
|
||||
```bash
|
||||
clawhub package publish owner/repo
|
||||
clawhub package publish owner/repo@v1.0.0
|
||||
clawhub package publish https://github.com/owner/repo
|
||||
|
||||
# Preview only
|
||||
clawhub package publish owner/repo --dry-run
|
||||
|
||||
# CI-friendly output
|
||||
clawhub package publish owner/repo --dry-run --json
|
||||
```
|
||||
|
||||
This keeps package metadata zero-config where possible and auto-populates GitHub provenance.
|
||||
|
||||
Goal: paste a GitHub URL → auto-detect skill → preview files → publish (selective) → persist provenance.
|
||||
|
||||
Non-goal (v1): private repos (no OAuth/PAT support).
|
||||
|
||||
@@ -237,6 +237,9 @@ Notes:
|
||||
|
||||
- If neither `version` nor `tag` is provided, uses the latest version.
|
||||
- Includes normalized verification status plus scanner-specific details.
|
||||
- `security.capabilityTags` includes deterministic capability/risk labels such as
|
||||
`crypto`, `requires-wallet`, `can-make-purchases`, `can-sign-transactions`,
|
||||
`requires-oauth-token`, and `posts-externally` when detected.
|
||||
- `security.hasScanResult` is `true` only when a scanner produced a definitive verdict (`clean`, `suspicious`, or `malicious`).
|
||||
- `moderation` is a current skill-level moderation snapshot derived from the latest version.
|
||||
- When querying a historical version, check `moderation.matchesRequestedVersion` and `moderation.sourceVersion` before treating `moderation` and `security` as the same version context.
|
||||
|
||||
@@ -47,9 +47,9 @@ read_when:
|
||||
- `SKILL.md`
|
||||
- `notes.md`
|
||||
- Publish:
|
||||
- `bun clawhub publish . --slug clawhub-manual-<ts> --name "Manual <ts>" --version 1.0.0 --tags latest`
|
||||
- `bun clawhub skill publish . --slug clawhub-manual-<ts> --name "Manual <ts>" --version 1.0.0 --tags latest`
|
||||
- Publish update with empty changelog:
|
||||
- `bun clawhub publish . --slug clawhub-manual-<ts> --name "Manual <ts>" --version 1.0.1 --tags latest`
|
||||
- `bun clawhub skill publish . --slug clawhub-manual-<ts> --name "Manual <ts>" --version 1.0.1 --tags latest`
|
||||
|
||||
## Delete / undelete (owner/admin)
|
||||
|
||||
|
||||
@@ -485,4 +485,3 @@ Add or update tests for:
|
||||
drift
|
||||
- Do not keep slug-only and scoped lookup logic equally primary; one must win
|
||||
- Prefer publisher abstraction over `ownerUserId | ownerOrgId` unions
|
||||
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ EOF
|
||||
Publish:
|
||||
|
||||
```bash
|
||||
bun clawhub publish . \
|
||||
bun clawhub skill publish . \
|
||||
--slug clawhub-demo-$(date +%s) \
|
||||
--name "Demo $(date +%s)" \
|
||||
--version 1.0.0 \
|
||||
|
||||
@@ -77,7 +77,7 @@ clawhub sync --root /path/to/skills
|
||||
- Options:
|
||||
- keep local edits; skip updating
|
||||
- overwrite: `clawhub update <slug> --force`
|
||||
- publish as fork: copy to new folder/slug then `clawhub publish ... --fork-of upstream@version`
|
||||
- publish as fork: copy to new folder/slug then `clawhub skill publish ... --fork-of upstream@version`
|
||||
|
||||
## `GET /api/*` works locally but not on Vercel
|
||||
|
||||
@@ -86,9 +86,8 @@ clawhub sync --root /path/to/skills
|
||||
|
||||
## `deploy.yml` fails before deploy or smoke runs
|
||||
|
||||
- Ensure GitHub Actions secrets exist for the repo:
|
||||
- Ensure GitHub Actions secrets exist on the `Production` environment:
|
||||
- `CONVEX_DEPLOY_KEY`
|
||||
- `VERCEL_TOKEN`
|
||||
- Optional: `PLAYWRIGHT_AUTH_STORAGE_STATE_JSON`
|
||||
- Missing required deploy secrets now fails the preflight job immediately.
|
||||
- If the optional Playwright auth secret is missing, authenticated smoke canaries will skip; deploy should still proceed.
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1SearchResponseSchema,
|
||||
ApiV1WhoamiResponseSchema,
|
||||
parseArk,
|
||||
} from "clawhub-schema";
|
||||
import { unzipSync } from "fflate";
|
||||
import { Agent, setGlobalDispatcher } from "undici";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readGlobalConfig } from "../packages/clawdhub/src/config";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
try {
|
||||
setGlobalDispatcher(
|
||||
new Agent({
|
||||
connect: { timeout: REQUEST_TIMEOUT_MS },
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// ignore dispatcher setup failures
|
||||
}
|
||||
|
||||
function mustGetToken() {
|
||||
const fromEnv = process.env.CLAWHUB_E2E_TOKEN?.trim() || process.env.CLAWDHUB_E2E_TOKEN?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRegistry() {
|
||||
return (
|
||||
process.env.CLAWHUB_REGISTRY?.trim() ||
|
||||
process.env.CLAWDHUB_REGISTRY?.trim() ||
|
||||
"https://clawhub.ai"
|
||||
);
|
||||
}
|
||||
|
||||
function getSite() {
|
||||
return (
|
||||
process.env.CLAWHUB_SITE?.trim() || process.env.CLAWDHUB_SITE?.trim() || "https://clawhub.ai"
|
||||
);
|
||||
}
|
||||
|
||||
async function makeTempConfig(registry: string, token: string | null) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "clawhub-e2e-"));
|
||||
const path = join(dir, "config.json");
|
||||
await writeFile(
|
||||
path,
|
||||
`${JSON.stringify({ registry, token: token || undefined }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return { dir, path };
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(new Error("Timeout")), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(input, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
describe("clawhub e2e", () => {
|
||||
it("prints CLI version via --cli-version", async () => {
|
||||
const result = spawnSync("bun", ["clawhub", "--cli-version"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
|
||||
it("search endpoint returns a results array (schema parse)", async () => {
|
||||
const registry = getRegistry();
|
||||
const url = new URL(ApiRoutes.search, registry);
|
||||
url.searchParams.set("q", "gif");
|
||||
url.searchParams.set("limit", "5");
|
||||
|
||||
const response = await fetchWithTimeout(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
const json = (await response.json()) as unknown;
|
||||
const parsed = parseArk(ApiV1SearchResponseSchema, json, "API response");
|
||||
expect(Array.isArray(parsed.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("cli search does not error on multi-result responses", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
try {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-workdir-"));
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"search",
|
||||
"gif",
|
||||
"--limit",
|
||||
"5",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/API response:/);
|
||||
} finally {
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("assumes a logged-in user (whoami succeeds)", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWHUB_E2E_TOKEN or run: bun clawhub auth login");
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
try {
|
||||
const whoamiUrl = new URL(ApiRoutes.whoami, registry);
|
||||
const whoamiRes = await fetchWithTimeout(whoamiUrl.toString(), {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(whoamiRes.ok).toBe(true);
|
||||
const whoami = parseArk(
|
||||
ApiV1WhoamiResponseSchema,
|
||||
(await whoamiRes.json()) as unknown,
|
||||
"Whoami",
|
||||
);
|
||||
expect(whoami.user).toBeTruthy();
|
||||
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["clawhub", "whoami", "--site", site, "--registry", registry],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/not logged in|unauthorized|error:/i);
|
||||
} finally {
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sync dry-run finds skills from an explicit root", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWHUB_E2E_TOKEN or run: bun clawhub auth login");
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
const root = await mkdtemp(join(tmpdir(), "clawhub-e2e-sync-"));
|
||||
try {
|
||||
const skillDir = join(root, "cool-skill");
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# Skill\n", "utf8");
|
||||
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"sync",
|
||||
"--dry-run",
|
||||
"--all",
|
||||
"--root",
|
||||
root,
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/error:/i);
|
||||
expect(result.stdout).toMatch(/Dry run/i);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sync dry-run finds skills from clawdbot.json roots", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWHUB_E2E_TOKEN or run: bun clawhub auth login");
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
const root = await mkdtemp(join(tmpdir(), "clawhub-e2e-clawdbot-"));
|
||||
const stateDir = join(root, "state");
|
||||
const configPath = join(root, "clawdbot.json");
|
||||
const workspace = join(root, "clawd-work");
|
||||
const skillsRoot = join(workspace, "skills");
|
||||
const skillDir = join(skillsRoot, "auto-skill");
|
||||
|
||||
try {
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# Skill\n", "utf8");
|
||||
|
||||
const config = `{
|
||||
// JSON5-style comments + trailing commas
|
||||
routing: {
|
||||
agents: {
|
||||
work: { name: 'Work', workspace: '${workspace}', },
|
||||
},
|
||||
},
|
||||
}`;
|
||||
await writeFile(configPath, config, "utf8");
|
||||
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["clawhub", "sync", "--dry-run", "--all", "--site", site, "--registry", registry],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWHUB_CONFIG_PATH: cfg.path,
|
||||
CLAWHUB_DISABLE_TELEMETRY: "1",
|
||||
CLAWDBOT_CONFIG_PATH: configPath,
|
||||
CLAWDBOT_STATE_DIR: stateDir,
|
||||
},
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/error:/i);
|
||||
expect(result.stdout).toMatch(/Dry run/i);
|
||||
expect(result.stdout).toMatch(/auto-skill/i);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes, deletes, and undeletes a skill (logged-in)", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWHUB_E2E_TOKEN or run: bun clawhub auth login");
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-publish-"));
|
||||
const installWorkdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-install-"));
|
||||
const slug = `e2e-${Date.now()}`;
|
||||
const skillDir = join(workdir, slug);
|
||||
|
||||
try {
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), `# ${slug}\n\nHello.\n`, "utf8");
|
||||
|
||||
const publish1 = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"publish",
|
||||
skillDir,
|
||||
"--slug",
|
||||
slug,
|
||||
"--name",
|
||||
`E2E ${slug}`,
|
||||
"--version",
|
||||
"1.0.0",
|
||||
"--tags",
|
||||
"latest",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(publish1.status).toBe(0);
|
||||
expect(publish1.stderr).not.toMatch(/changelog required/i);
|
||||
|
||||
const publish2 = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"publish",
|
||||
skillDir,
|
||||
"--slug",
|
||||
slug,
|
||||
"--name",
|
||||
`E2E ${slug}`,
|
||||
"--version",
|
||||
"1.0.1",
|
||||
"--tags",
|
||||
"latest",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(publish2.status).toBe(0);
|
||||
expect(publish2.stderr).not.toMatch(/changelog required/i);
|
||||
|
||||
const downloadUrl = new URL(ApiRoutes.download, registry);
|
||||
downloadUrl.searchParams.set("slug", slug);
|
||||
downloadUrl.searchParams.set("version", "1.0.1");
|
||||
const zipRes = await fetchWithTimeout(downloadUrl.toString());
|
||||
expect(zipRes.ok).toBe(true);
|
||||
const zipBytes = new Uint8Array(await zipRes.arrayBuffer());
|
||||
const unzipped = unzipSync(zipBytes);
|
||||
expect(Object.keys(unzipped)).toContain("SKILL.md");
|
||||
|
||||
const install = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"install",
|
||||
slug,
|
||||
"--version",
|
||||
"1.0.0",
|
||||
"--force",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
installWorkdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(install.status).toBe(0);
|
||||
|
||||
const list = spawnSync(
|
||||
"bun",
|
||||
["clawhub", "list", "--site", site, "--registry", registry, "--workdir", installWorkdir],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(list.status).toBe(0);
|
||||
expect(list.stdout).toMatch(new RegExp(`${slug}\\s+1\\.0\\.0`));
|
||||
|
||||
const update = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"update",
|
||||
slug,
|
||||
"--force",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
installWorkdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(update.status).toBe(0);
|
||||
|
||||
const metaUrl = new URL(`${ApiRoutes.skills}/${slug}`, registry);
|
||||
const metaRes = await fetchWithTimeout(metaUrl.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
expect(metaRes.status).toBe(200);
|
||||
|
||||
const del = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"delete",
|
||||
slug,
|
||||
"--yes",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(del.status).toBe(0);
|
||||
|
||||
const metaAfterDelete = await fetchWithTimeout(metaUrl.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
expect(metaAfterDelete.status).toBe(404);
|
||||
|
||||
const downloadAfterDelete = await fetchWithTimeout(downloadUrl.toString());
|
||||
expect(downloadAfterDelete.status).toBe(404);
|
||||
|
||||
const undelete = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"undelete",
|
||||
slug,
|
||||
"--yes",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(undelete.status).toBe(0);
|
||||
|
||||
const metaAfterUndelete = await fetchWithTimeout(metaUrl.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
expect(metaAfterUndelete.status).toBe(200);
|
||||
} finally {
|
||||
const cleanup = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"delete",
|
||||
slug,
|
||||
"--yes",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
if (cleanup.status !== 0) {
|
||||
// best-effort cleanup
|
||||
}
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
await rm(installWorkdir, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
it("delete returns proper error for non-existent skill", async () => {
|
||||
const registry = process.env.CLAWDHUB_REGISTRY?.trim() || "https://clawdhub.com";
|
||||
const site = process.env.CLAWDHUB_SITE?.trim() || "https://clawdhub.com";
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWDHUB_E2E_TOKEN or run: bun clawdhub auth login");
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawdhub-e2e-delete-"));
|
||||
const nonExistentSlug = `non-existent-skill-${Date.now()}`;
|
||||
|
||||
try {
|
||||
const del = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawdhub",
|
||||
"delete",
|
||||
nonExistentSlug,
|
||||
"--yes",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path, CLAWDHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
// Should fail with non-zero exit code
|
||||
expect(del.status).not.toBe(0);
|
||||
// Error should mention "not found" - not generic "Unauthorized"
|
||||
const output = (del.stdout + del.stderr).toLowerCase();
|
||||
expect(output).toMatch(/not found|404|does not exist/i);
|
||||
expect(output).not.toMatch(/unauthorized/i);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,658 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1SearchResponseSchema,
|
||||
ApiV1WhoamiResponseSchema,
|
||||
parseArk,
|
||||
} from "clawhub-schema";
|
||||
import { unzipSync } from "fflate";
|
||||
import { Agent, setGlobalDispatcher } from "undici";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readGlobalConfig } from "../packages/clawhub/src/config";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
try {
|
||||
setGlobalDispatcher(
|
||||
new Agent({
|
||||
connect: { timeout: REQUEST_TIMEOUT_MS },
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// ignore dispatcher setup failures
|
||||
}
|
||||
|
||||
function mustGetToken() {
|
||||
const fromEnv = process.env.CLAWHUB_E2E_TOKEN?.trim() || process.env.CLAWDHUB_E2E_TOKEN?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRegistry() {
|
||||
return (
|
||||
process.env.CLAWHUB_REGISTRY?.trim() ||
|
||||
process.env.CLAWDHUB_REGISTRY?.trim() ||
|
||||
"https://clawhub.ai"
|
||||
);
|
||||
}
|
||||
|
||||
function getSite() {
|
||||
return (
|
||||
process.env.CLAWHUB_SITE?.trim() || process.env.CLAWDHUB_SITE?.trim() || "https://clawhub.ai"
|
||||
);
|
||||
}
|
||||
|
||||
function buildE2ESkillMarkdown(slug: string) {
|
||||
return `# ${slug}
|
||||
|
||||
## What it does
|
||||
|
||||
This skill is used by the ClawHub CLI end-to-end suite to verify publish, install,
|
||||
update, delete, and undelete flows against a real registry.
|
||||
|
||||
## Usage
|
||||
|
||||
- Run the skill after installation to confirm the package can be discovered.
|
||||
- Use the published version history to verify update behavior.
|
||||
- Delete and undelete the listing to confirm ownership actions still work.
|
||||
|
||||
## Notes
|
||||
|
||||
This content is intentionally specific and non-templated so the publish pipeline
|
||||
accepts it during automated tests.
|
||||
`;
|
||||
}
|
||||
|
||||
function allowLiveMutations() {
|
||||
const value = process.env.CLAWHUB_E2E_ALLOW_MUTATIONS?.trim();
|
||||
return value === "1" || value?.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
const itIfLiveMutations = allowLiveMutations() ? it : it.skip;
|
||||
|
||||
async function makeTempConfig(registry: string, token: string | null) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "clawhub-e2e-"));
|
||||
const path = join(dir, "config.json");
|
||||
await writeFile(
|
||||
path,
|
||||
`${JSON.stringify({ registry, token: token || undefined }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return { dir, path };
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(new Error("Timeout")), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(input, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
describe("clawhub e2e", () => {
|
||||
it("prints CLI version via --cli-version", async () => {
|
||||
const result = spawnSync("bun", ["clawhub", "--cli-version"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
|
||||
it("search endpoint returns a results array (schema parse)", async () => {
|
||||
const registry = getRegistry();
|
||||
const url = new URL(ApiRoutes.search, registry);
|
||||
url.searchParams.set("q", "gif");
|
||||
url.searchParams.set("limit", "5");
|
||||
|
||||
const response = await fetchWithTimeout(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
const json = (await response.json()) as unknown;
|
||||
const parsed = parseArk(ApiV1SearchResponseSchema, json, "API response");
|
||||
expect(Array.isArray(parsed.results)).toBe(true);
|
||||
});
|
||||
|
||||
it("cli search does not error on multi-result responses", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
try {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-workdir-"));
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"search",
|
||||
"gif",
|
||||
"--limit",
|
||||
"5",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/API response:/);
|
||||
} finally {
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("assumes a logged-in user (whoami succeeds)", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWHUB_E2E_TOKEN or run: bun clawhub auth login");
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
try {
|
||||
const whoamiUrl = new URL(ApiRoutes.whoami, registry);
|
||||
const whoamiRes = await fetchWithTimeout(whoamiUrl.toString(), {
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(whoamiRes.ok).toBe(true);
|
||||
const whoami = parseArk(
|
||||
ApiV1WhoamiResponseSchema,
|
||||
(await whoamiRes.json()) as unknown,
|
||||
"Whoami",
|
||||
);
|
||||
expect(whoami.user).toBeTruthy();
|
||||
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["clawhub", "whoami", "--site", site, "--registry", registry],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/not logged in|unauthorized|error:/i);
|
||||
} finally {
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sync dry-run finds skills from an explicit root", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWHUB_E2E_TOKEN or run: bun clawhub auth login");
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
const root = await mkdtemp(join(tmpdir(), "clawhub-e2e-sync-"));
|
||||
try {
|
||||
const skillDir = join(root, "cool-skill");
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# Skill\n", "utf8");
|
||||
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"sync",
|
||||
"--dry-run",
|
||||
"--all",
|
||||
"--root",
|
||||
root,
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/error:/i);
|
||||
expect(result.stdout).toMatch(/Dry run/i);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sync dry-run finds skills from clawdbot.json roots", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWHUB_E2E_TOKEN or run: bun clawhub auth login");
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
const root = await mkdtemp(join(tmpdir(), "clawhub-e2e-clawdbot-"));
|
||||
const stateDir = join(root, "state");
|
||||
const configPath = join(root, "clawdbot.json");
|
||||
const workspace = join(root, "clawd-work");
|
||||
const skillsRoot = join(workspace, "skills");
|
||||
const skillDir = join(skillsRoot, "auto-skill");
|
||||
|
||||
try {
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# Skill\n", "utf8");
|
||||
|
||||
const config = `{
|
||||
// JSON5-style comments + trailing commas
|
||||
routing: {
|
||||
agents: {
|
||||
work: { name: 'Work', workspace: '${workspace}', },
|
||||
},
|
||||
},
|
||||
}`;
|
||||
await writeFile(configPath, config, "utf8");
|
||||
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["clawhub", "sync", "--dry-run", "--all", "--site", site, "--registry", registry],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWHUB_CONFIG_PATH: cfg.path,
|
||||
CLAWHUB_DISABLE_TELEMETRY: "1",
|
||||
CLAWDBOT_CONFIG_PATH: configPath,
|
||||
CLAWDBOT_STATE_DIR: stateDir,
|
||||
},
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/error:/i);
|
||||
expect(result.stdout).toMatch(/Dry run/i);
|
||||
expect(result.stdout).toMatch(/auto-skill/i);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("package publish --dry-run from a GitHub repo shows a summary", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"package",
|
||||
"publish",
|
||||
"pwrdrvr/openclaw-codex-app-server",
|
||||
"--dry-run",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/Dry run/i);
|
||||
expect(result.stdout).toMatch(/openclaw-codex-app-server/);
|
||||
expect(result.stdout).toMatch(/code-plugin/i);
|
||||
expect(result.stdout).toMatch(/openclaw\.plugin\.json/);
|
||||
}, 30_000);
|
||||
|
||||
it("package publish --dry-run --json from GitHub outputs valid JSON", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"package",
|
||||
"publish",
|
||||
"pwrdrvr/openclaw-codex-app-server",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const output = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
||||
expect(String(output.name)).toMatch(/openclaw-codex-app-server/);
|
||||
expect(output.family).toBe("code-plugin");
|
||||
expect(Number(output.files)).toBeGreaterThan(0);
|
||||
expect(output).not.toHaveProperty("releaseId");
|
||||
}, 30_000);
|
||||
|
||||
it("package publish help shows the new source argument and flags", async () => {
|
||||
const result = spawnSync("bun", ["clawhub", "package", "publish", "--help"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/<source>/);
|
||||
expect(result.stdout).toMatch(/--dry-run/);
|
||||
expect(result.stdout).toMatch(/--json/);
|
||||
});
|
||||
|
||||
itIfLiveMutations(
|
||||
"publishes, deletes, and undeletes a skill (logged-in)",
|
||||
async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWHUB_E2E_TOKEN or run: bun clawhub auth login");
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-publish-"));
|
||||
const installWorkdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-install-"));
|
||||
const slug = `e2e-${Date.now()}`;
|
||||
const skillDir = join(workdir, slug);
|
||||
|
||||
try {
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), buildE2ESkillMarkdown(slug), "utf8");
|
||||
|
||||
const publish1 = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"publish",
|
||||
skillDir,
|
||||
"--slug",
|
||||
slug,
|
||||
"--name",
|
||||
`E2E ${slug}`,
|
||||
"--version",
|
||||
"1.0.0",
|
||||
"--tags",
|
||||
"latest",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(publish1.status).toBe(0);
|
||||
expect(publish1.stderr).not.toMatch(/changelog required/i);
|
||||
|
||||
const publish2 = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"publish",
|
||||
skillDir,
|
||||
"--slug",
|
||||
slug,
|
||||
"--name",
|
||||
`E2E ${slug}`,
|
||||
"--version",
|
||||
"1.0.1",
|
||||
"--tags",
|
||||
"latest",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(publish2.status).toBe(0);
|
||||
expect(publish2.stderr).not.toMatch(/changelog required/i);
|
||||
|
||||
const downloadUrl = new URL(ApiRoutes.download, registry);
|
||||
downloadUrl.searchParams.set("slug", slug);
|
||||
downloadUrl.searchParams.set("version", "1.0.1");
|
||||
const zipRes = await fetchWithTimeout(downloadUrl.toString());
|
||||
expect(zipRes.ok).toBe(true);
|
||||
const zipBytes = new Uint8Array(await zipRes.arrayBuffer());
|
||||
const unzipped = unzipSync(zipBytes);
|
||||
expect(Object.keys(unzipped)).toContain("SKILL.md");
|
||||
|
||||
const install = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"install",
|
||||
slug,
|
||||
"--version",
|
||||
"1.0.0",
|
||||
"--force",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
installWorkdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(install.status).toBe(0);
|
||||
|
||||
const list = spawnSync(
|
||||
"bun",
|
||||
["clawhub", "list", "--site", site, "--registry", registry, "--workdir", installWorkdir],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(list.status).toBe(0);
|
||||
expect(list.stdout).toMatch(new RegExp(`${slug}\\s+1\\.0\\.0`));
|
||||
|
||||
const update = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"update",
|
||||
slug,
|
||||
"--force",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
installWorkdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(update.status).toBe(0);
|
||||
|
||||
const metaUrl = new URL(`${ApiRoutes.skills}/${slug}`, registry);
|
||||
const metaRes = await fetchWithTimeout(metaUrl.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
expect(metaRes.status).toBe(200);
|
||||
|
||||
const del = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"delete",
|
||||
slug,
|
||||
"--yes",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(del.status).toBe(0);
|
||||
|
||||
const metaAfterDelete = await fetchWithTimeout(metaUrl.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
expect(metaAfterDelete.status).toBe(404);
|
||||
|
||||
const downloadAfterDelete = await fetchWithTimeout(downloadUrl.toString());
|
||||
expect(downloadAfterDelete.status).toBe(404);
|
||||
|
||||
const undelete = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"undelete",
|
||||
slug,
|
||||
"--yes",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
expect(undelete.status).toBe(0);
|
||||
|
||||
const metaAfterUndelete = await fetchWithTimeout(metaUrl.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
expect(metaAfterUndelete.status).toBe(200);
|
||||
} finally {
|
||||
const cleanup = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"delete",
|
||||
slug,
|
||||
"--yes",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
if (cleanup.status !== 0) {
|
||||
// best-effort cleanup
|
||||
}
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
await rm(installWorkdir, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
|
||||
it("delete returns proper error for non-existent skill", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWHUB_E2E_TOKEN or run: bun clawhub auth login");
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-delete-"));
|
||||
const nonExistentSlug = `non-existent-skill-${Date.now()}`;
|
||||
|
||||
try {
|
||||
const del = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"delete",
|
||||
nonExistentSlug,
|
||||
"--yes",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
"--workdir",
|
||||
workdir,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
// Should fail with non-zero exit code
|
||||
expect(del.status).not.toBe(0);
|
||||
// Error should mention "not found" - not generic "Unauthorized"
|
||||
const output = (del.stdout + del.stderr).toLowerCase();
|
||||
expect(output).toMatch(/not found|404|does not exist/i);
|
||||
expect(output).not.toMatch(/unauthorized/i);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
await rm(cfg.dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -69,7 +69,7 @@ describe("prod http smoke", () => {
|
||||
|
||||
expect(html).toContain("<title>ClawHub");
|
||||
expect(html).toContain('href="/skills"');
|
||||
expect(html).toContain('href="/upload"');
|
||||
expect(html).toContain('href="/publish-skill"');
|
||||
expect(html).not.toContain("Something went wrong!");
|
||||
});
|
||||
|
||||
|
||||
+36
-12
@@ -7,17 +7,21 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun --bun vite build && bun scripts/copy-og-assets.ts",
|
||||
"check": "bun run lint",
|
||||
"check:peers": "bun scripts/check-peer-deps.ts",
|
||||
"check:secrets": "bun scripts/check-staged-secrets.mjs",
|
||||
"convex:deploy": "bunx convex deploy --typecheck=disable --yes",
|
||||
"coverage": "vitest run --coverage",
|
||||
"dev": "bun --bun vite dev --port 3000",
|
||||
"docs:list": "bun scripts/docs-list.ts",
|
||||
"format": "oxfmt --write",
|
||||
"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/clawdhub/src ./packages/schema/src --fix && bun run format",
|
||||
"lint:oxlint": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex ./packages/clawdhub/src ./packages/schema/src",
|
||||
"lint:fix": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex ./packages/clawhub/src ./packages/schema/src --fix && bun run format",
|
||||
"lint:oxlint": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex ./packages/clawhub/src ./packages/schema/src",
|
||||
"preinstall": "bunx only-allow bun",
|
||||
"preview": "bun --bun vite preview",
|
||||
"release:clawhub:cli:npm:check": "node scripts/clawhub-cli-npm-release-check.mjs",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "vitest run -c vitest.e2e.config.ts",
|
||||
"test:e2e:local": "bash scripts/run-playwright-local.sh",
|
||||
@@ -29,59 +33,79 @@
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.37.4",
|
||||
"@convex-dev/auth": "^0.0.91",
|
||||
"@create-markdown/core": "^2.0.2",
|
||||
"@create-markdown/preview": "^2.0.2",
|
||||
"@fontsource/bricolage-grotesque": "^5.2.10",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/manrope": "^5.2.8",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@resvg/resvg-wasm": "^2.6.2",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/react-devtools": "0.10.0",
|
||||
"@tanstack/react-router": "1.168.1",
|
||||
"@tanstack/react-router-devtools": "1.166.10",
|
||||
"@tanstack/react-start": "1.167.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/router-plugin": "1.167.2",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clawhub-schema": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"convex": "^1.34.0",
|
||||
"convex": "^1.34.1",
|
||||
"convex-helpers": "^0.1.114",
|
||||
"fflate": "^0.8.2",
|
||||
"h3": "2.0.1-rc.18",
|
||||
"ignore": "^7.0.5",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "^16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"nitro": "3.0.260311-beta",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.4",
|
||||
"shiki": "^4.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"yaml": "^2.8.3"
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tanstack/devtools-vite": "0.6.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"jsdom": "^29.0.1",
|
||||
"only-allow": "^1.2.2",
|
||||
"oxfmt": "0.41.0",
|
||||
"oxlint": "^1.56.0",
|
||||
"oxlint-tsgolint": "^0.17.1",
|
||||
"oxlint": "^1.58.0",
|
||||
"oxlint-tsgolint": "^0.17.4",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.24.5",
|
||||
"vite": "8.0.1",
|
||||
"vitest": "^4.1.0"
|
||||
"undici": "^7.24.7",
|
||||
"vite": "8.0.5",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# `clawhub`
|
||||
|
||||
ClawHub CLI — install, update, search, and publish agent skills plus OpenClaw packages.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# From this repo (shortcut script at repo root)
|
||||
bun clawhub --help
|
||||
|
||||
# Once published to npm
|
||||
# npm i -g clawhub
|
||||
```
|
||||
|
||||
## Auth (publish)
|
||||
|
||||
```bash
|
||||
clawhub login
|
||||
# or
|
||||
clawhub auth login
|
||||
|
||||
# Headless / token paste
|
||||
# or (token paste / headless)
|
||||
clawhub login --token clh_...
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Browser login opens `https://clawhub.ai/cli/auth` and completes via a loopback callback.
|
||||
- Token stored in `~/Library/Application Support/clawhub/config.json` on macOS (override via `CLAWHUB_CONFIG_PATH`, legacy `CLAWDHUB_CONFIG_PATH`).
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
clawhub search "postgres backups"
|
||||
clawhub install my-skill-pack
|
||||
clawhub update --all
|
||||
clawhub update --all --no-input --force
|
||||
clawhub publish ./my-skill-pack --slug my-skill-pack --name "My Skill Pack" --version 1.2.0 --changelog "Fixes + docs"
|
||||
clawhub package explore --family skill
|
||||
clawhub package explore --family code-plugin
|
||||
clawhub package inspect @openclaw/example-plugin
|
||||
clawhub package publish ./example-plugin --owner openclaw --source-repo openclaw/example-plugin --source-commit abc123
|
||||
```
|
||||
|
||||
## Sync (upload local skills)
|
||||
|
||||
```bash
|
||||
# Start anywhere; scans workdir first, then legacy Clawdis/Clawd/OpenClaw/Moltbot locations.
|
||||
clawhub sync
|
||||
|
||||
# Explicit roots + non-interactive dry-run
|
||||
clawhub sync --root ../clawdis/skills --all --dry-run
|
||||
```
|
||||
|
||||
## Defaults
|
||||
|
||||
- Site: `https://clawhub.ai` (override via `--site` or `CLAWHUB_SITE`, legacy `CLAWDHUB_SITE`)
|
||||
- Registry: discovered from `/.well-known/clawhub.json` on the site (legacy `/.well-known/clawdhub.json`; override via `--registry` or `CLAWHUB_REGISTRY`)
|
||||
- Workdir: current directory (falls back to Clawdbot workspace if configured; override via `--workdir` or `CLAWHUB_WORKDIR`)
|
||||
- Install dir: `./skills` under workdir (override via `--dir`)
|
||||
@@ -1,91 +0,0 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: vi.fn(async () => "https://clawhub.ai"),
|
||||
}));
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
mockApiRequest(registry, args, schema),
|
||||
}));
|
||||
|
||||
const mockFail = vi.fn((message: string) => {
|
||||
throw new Error(message);
|
||||
});
|
||||
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => ({ succeed: vi.fn(), fail: vi.fn() })),
|
||||
fail: (message: string) => mockFail(message),
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => false,
|
||||
promptConfirm: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const { cmdDeleteSkill, cmdHideSkill, cmdUndeleteSkill, cmdUnhideSkill } = await import("./delete");
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("delete/undelete", () => {
|
||||
it("requires --yes when input is disabled", async () => {
|
||||
await expect(cmdDeleteSkill(makeOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdUndeleteSkill(makeOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdHideSkill(makeOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdUnhideSkill(makeOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
});
|
||||
|
||||
it("calls delete endpoint with --yes", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true });
|
||||
await cmdDeleteSkill(makeOpts(), "demo", { yes: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "DELETE", path: "/api/v1/skills/demo" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls undelete endpoint with --yes", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true });
|
||||
await cmdUndeleteSkill(makeOpts(), "demo", { yes: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/undelete" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports hide/unhide aliases", async () => {
|
||||
mockApiRequest.mockResolvedValue({ ok: true });
|
||||
await cmdHideSkill(makeOpts(), "demo", { yes: true }, false);
|
||||
await cmdUnhideSkill(makeOpts(), "demo", { yes: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "DELETE", path: "/api/v1/skills/demo" }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/undelete" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: vi.fn(async () => "https://clawhub.ai"),
|
||||
}));
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
mockApiRequest(registry, args, schema),
|
||||
}));
|
||||
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => ({ succeed: vi.fn(), fail: vi.fn() })),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => false,
|
||||
promptConfirm: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const { cmdMergeSkill, cmdRenameSkill } = await import("./ownership");
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ownership commands", () => {
|
||||
it("rename requires --yes when input is disabled", async () => {
|
||||
await expect(cmdRenameSkill(makeOpts(), "demo", "demo-new", {}, false)).rejects.toThrow(
|
||||
/--yes/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("rename calls rename endpoint", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "demo-new",
|
||||
previousSlug: "demo",
|
||||
});
|
||||
|
||||
await cmdRenameSkill(makeOpts(), "Demo", "Demo-New", { yes: true }, false);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/skills/demo/rename",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
const requestArgs = mockApiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
expect(requestArgs.body).toContain('"newSlug":"demo-new"');
|
||||
});
|
||||
|
||||
it("merge calls merge endpoint", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
sourceSlug: "demo-old",
|
||||
targetSlug: "demo",
|
||||
});
|
||||
|
||||
await cmdMergeSkill(makeOpts(), "Demo-Old", "Demo", { yes: true }, false);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/skills/demo-old/merge",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
const requestArgs = mockApiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
expect(requestArgs.body).toContain('"targetSlug":"demo"');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user