mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 17:02:11 +00:00
Compare commits
115
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
5395d9d159 | ||
|
|
87c236e8ce |
@@ -31,11 +31,14 @@ jobs:
|
||||
- name: Coverage
|
||||
run: bun run coverage
|
||||
|
||||
- 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,53 @@
|
||||
# Changelog
|
||||
|
||||
## 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`).
|
||||
|
||||
@@ -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,29 +1,39 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"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-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",
|
||||
"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",
|
||||
@@ -36,36 +46,39 @@
|
||||
"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",
|
||||
"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",
|
||||
"undici": "^7.24.7",
|
||||
"vite": "8.0.1",
|
||||
"vitest": "^4.1.0",
|
||||
"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 +175,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=="],
|
||||
@@ -320,69 +337,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 +419,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 +429,26 @@
|
||||
|
||||
"@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-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.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-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,12 +457,18 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -464,6 +505,22 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -536,6 +593,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 +617,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 +645,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 +663,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 +743,7 @@
|
||||
|
||||
"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"],
|
||||
"clawhub": ["clawhub@workspace:packages/clawhub"],
|
||||
|
||||
"clawhub-schema": ["clawhub-schema@workspace:packages/schema"],
|
||||
|
||||
@@ -700,7 +761,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 +873,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 +887,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=="],
|
||||
@@ -1052,15 +1117,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 +1149,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 +1185,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=="],
|
||||
@@ -1150,6 +1225,8 @@
|
||||
|
||||
"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 +1235,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=="],
|
||||
@@ -1232,7 +1311,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1272,7 +1351,7 @@
|
||||
|
||||
"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 +1383,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 +1393,64 @@
|
||||
|
||||
"@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-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-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-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@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-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-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 +1465,32 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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
+6
@@ -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";
|
||||
@@ -92,11 +93,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 +164,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;
|
||||
@@ -205,11 +209,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
|
||||
|
||||
@@ -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;
|
||||
@@ -2994,7 +3088,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 +3366,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 +3383,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 +3437,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 +3458,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 +3490,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,
|
||||
|
||||
+458
-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);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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";
|
||||
@@ -101,6 +101,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);
|
||||
|
||||
@@ -81,6 +81,7 @@ type PublicSkillVersionResponse = {
|
||||
sha256hash?: string;
|
||||
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
|
||||
llmAnalysis?: Doc<"skillVersions">["llmAnalysis"];
|
||||
capabilityTags?: string[];
|
||||
};
|
||||
|
||||
type ModerationEvidence = {
|
||||
@@ -189,6 +190,7 @@ type SkillSecuritySnapshot = {
|
||||
hasScanResult: boolean;
|
||||
sha256hash: string | null;
|
||||
virustotalUrl: string | null;
|
||||
capabilityTags: string[];
|
||||
scanners: {
|
||||
vt: {
|
||||
status: string;
|
||||
@@ -271,13 +273,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 +311,7 @@ function buildSkillSecuritySnapshot(
|
||||
hasScanResult,
|
||||
sha256hash,
|
||||
virustotalUrl: sha256hash ? `https://www.virustotal.com/gui/file/${sha256hash}` : null,
|
||||
capabilityTags,
|
||||
scanners: {
|
||||
vt: vt
|
||||
? {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,10 +145,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,
|
||||
|
||||
+18
-21
@@ -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 {
|
||||
@@ -111,7 +105,12 @@ export async function getUserByHandleOrPersonalPublisher(
|
||||
if (user) return user;
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, normalized);
|
||||
if (!publisher || !isPublisherActive(publisher) || publisher.kind !== "user" || !publisher.linkedUserId) {
|
||||
if (
|
||||
!publisher ||
|
||||
!isPublisherActive(publisher) ||
|
||||
publisher.kind !== "user" ||
|
||||
!publisher.linkedUserId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -127,10 +126,7 @@ export async function getActiveUserByHandleOrPersonalPublisher(
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getPersonalPublisherForUser(
|
||||
ctx: DbCtx,
|
||||
userId: Id<"users">,
|
||||
) {
|
||||
export async function getPersonalPublisherForUser(ctx: DbCtx, userId: Id<"users">) {
|
||||
try {
|
||||
return await ctx.db
|
||||
.query("publishers")
|
||||
@@ -149,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);
|
||||
@@ -240,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", {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -8,6 +8,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 +36,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;
|
||||
@@ -246,6 +247,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 +307,7 @@ export async function publishVersionForUser(
|
||||
clawdis,
|
||||
license: PLATFORM_SKILL_LICENSE,
|
||||
},
|
||||
capabilityTags,
|
||||
summary,
|
||||
staticScan,
|
||||
embedding,
|
||||
|
||||
@@ -22,6 +22,7 @@ const SHARED_KEYS = [
|
||||
"latestVersionId",
|
||||
"latestVersionSummary",
|
||||
"tags",
|
||||
"capabilityTags",
|
||||
"badges",
|
||||
"stats",
|
||||
"statsDownloads",
|
||||
@@ -121,8 +122,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();
|
||||
});
|
||||
});
|
||||
|
||||
+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" };
|
||||
}
|
||||
|
||||
+762
-243
File diff suppressed because it is too large
Load Diff
+215
-173
@@ -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 = (
|
||||
@@ -232,98 +234,115 @@ describe("publishers membership controls", () => {
|
||||
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,
|
||||
),
|
||||
};
|
||||
}),
|
||||
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;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
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;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
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}`);
|
||||
@@ -526,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") {
|
||||
@@ -636,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,
|
||||
|
||||
+10
-9
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -14,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])?$/;
|
||||
|
||||
@@ -88,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
|
||||
@@ -356,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);
|
||||
@@ -418,9 +419,9 @@ export const listMine = query({
|
||||
if (!publicPublisher) return null;
|
||||
return {
|
||||
publisher: publicPublisher,
|
||||
role: membership.role,
|
||||
};
|
||||
}),
|
||||
role: membership.role,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const visiblePublishers = publishers.filter(
|
||||
(
|
||||
|
||||
+70
-7
@@ -40,7 +40,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 +189,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 +246,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,
|
||||
@@ -425,6 +444,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 +586,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 +740,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 +749,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 +910,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 +1316,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,411 @@
|
||||
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;
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
// 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: Math.floor(Math.random() * 8) + 1,
|
||||
comments: Math.floor(Math.random() * 15),
|
||||
},
|
||||
statsDownloads: s.downloads,
|
||||
statsStars: s.stars,
|
||||
statsInstallsCurrent: Math.floor(s.installs * 0.3),
|
||||
statsInstallsAllTime: s.installs,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
moderationFlags: undefined,
|
||||
moderationReason: undefined,
|
||||
isSuspicious: false,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
},
|
||||
});
|
||||
@@ -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: "" });
|
||||
});
|
||||
});
|
||||
|
||||
+268
-62
@@ -66,6 +66,7 @@ import {
|
||||
reserveSlugForHardDeleteFinalize,
|
||||
upsertReservedSlugForRightfulOwner,
|
||||
} from "./lib/reservedSlugs";
|
||||
import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags";
|
||||
import {
|
||||
fetchText,
|
||||
type PublishResult,
|
||||
@@ -74,7 +75,12 @@ import {
|
||||
} from "./lib/skillPublish";
|
||||
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 schema from "./schema";
|
||||
|
||||
export { publishVersionForUser } from "./lib/skillPublish";
|
||||
@@ -111,6 +117,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 +323,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,
|
||||
@@ -1041,6 +1050,7 @@ type PublicSkillVersion = {
|
||||
createdBy?: Id<"users">;
|
||||
createdAt?: number;
|
||||
softDeletedAt?: number;
|
||||
capabilityTags?: string[];
|
||||
sha256hash?: string;
|
||||
vtAnalysis?: Doc<"skillVersions">["vtAnalysis"];
|
||||
llmAnalysis?: Doc<"skillVersions">["llmAnalysis"];
|
||||
@@ -1218,6 +1228,7 @@ function toPublicSkillVersion(
|
||||
createdBy: version.createdBy,
|
||||
createdAt: version.createdAt,
|
||||
softDeletedAt: version.softDeletedAt,
|
||||
capabilityTags: version.capabilityTags,
|
||||
sha256hash: version.sha256hash,
|
||||
vtAnalysis: version.vtAnalysis,
|
||||
llmAnalysis: version.llmAnalysis,
|
||||
@@ -2069,7 +2080,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")
|
||||
@@ -2684,8 +2696,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);
|
||||
@@ -2698,6 +2714,7 @@ export const listPublicPageV4 = query({
|
||||
sort,
|
||||
dir,
|
||||
numItems,
|
||||
capabilityTag: args.capabilityTag,
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly ?? false,
|
||||
});
|
||||
}
|
||||
@@ -2714,46 +2731,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;
|
||||
@@ -2789,12 +2873,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 {
|
||||
@@ -2828,12 +2913,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;
|
||||
}
|
||||
|
||||
@@ -2851,7 +2937,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,
|
||||
};
|
||||
@@ -2876,16 +2962,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: "" };
|
||||
}
|
||||
|
||||
@@ -2908,7 +3003,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;
|
||||
@@ -2962,7 +3059,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()),
|
||||
@@ -2970,7 +3069,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 }> = [];
|
||||
@@ -3034,6 +3134,7 @@ async function fetchHighlightedPage(
|
||||
sort: SortKey;
|
||||
dir: "asc" | "desc";
|
||||
numItems: number;
|
||||
capabilityTag?: string;
|
||||
nonSuspiciousOnly: boolean;
|
||||
},
|
||||
) {
|
||||
@@ -3053,6 +3154,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);
|
||||
}
|
||||
|
||||
@@ -3079,23 +3181,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 };
|
||||
@@ -4741,6 +4829,7 @@ export const updateTags = mutation({
|
||||
changelogSource: version.changelogSource,
|
||||
clawdis: version.parsed?.clawdis,
|
||||
};
|
||||
patch.capabilityTags = version.capabilityTags;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4764,6 +4853,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) => {
|
||||
@@ -4938,7 +5059,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);
|
||||
@@ -4946,9 +5071,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,
|
||||
@@ -4965,7 +5097,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,
|
||||
});
|
||||
},
|
||||
@@ -5644,6 +5780,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) => {
|
||||
@@ -5705,6 +5907,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({
|
||||
@@ -5956,6 +6159,7 @@ export const insertVersion = internalMutation({
|
||||
forkOf,
|
||||
latestVersionId: undefined,
|
||||
tags: {},
|
||||
capabilityTags: args.capabilityTags,
|
||||
softDeletedAt: undefined,
|
||||
badges: {
|
||||
redactionApproved: undefined,
|
||||
@@ -6024,6 +6228,7 @@ export const insertVersion = internalMutation({
|
||||
changelogSource: args.changelogSource,
|
||||
files: args.files,
|
||||
parsed: args.parsed,
|
||||
capabilityTags: args.capabilityTags,
|
||||
staticScan: args.staticScan,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
@@ -6069,6 +6274,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,
|
||||
|
||||
+10
-2
@@ -23,7 +23,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 +95,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) => {
|
||||
|
||||
+347
-9
@@ -33,9 +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;
|
||||
const getByHandleHandler = (getByHandle as unknown as WrappedHandler<{ handle: string }, unknown>)
|
||||
._handler;
|
||||
|
||||
function makeCtx() {
|
||||
const patch = vi.fn();
|
||||
@@ -120,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,
|
||||
@@ -339,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) => {
|
||||
@@ -399,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 = {
|
||||
@@ -502,6 +664,19 @@ 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", () => {
|
||||
@@ -746,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 = {
|
||||
@@ -951,6 +1129,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",
|
||||
@@ -1058,6 +1275,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 = (
|
||||
@@ -1080,7 +1418,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,
|
||||
|
||||
+101
-34
@@ -1,10 +1,9 @@
|
||||
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,
|
||||
@@ -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) {
|
||||
|
||||
+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`
|
||||
|
||||
+63
-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:
|
||||
|
||||
@@ -132,12 +132,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 +209,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!");
|
||||
});
|
||||
|
||||
|
||||
+28
-11
@@ -8,16 +8,19 @@
|
||||
"scripts": {
|
||||
"build": "bun --bun vite build && bun scripts/copy-og-assets.ts",
|
||||
"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,23 +32,34 @@
|
||||
"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-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",
|
||||
"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",
|
||||
@@ -58,30 +72,33 @@
|
||||
"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",
|
||||
"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",
|
||||
"undici": "^7.24.7",
|
||||
"vite": "8.0.1",
|
||||
"vitest": "^4.1.0"
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
const mockApiRequestForm = vi.fn();
|
||||
const mockFetchText = vi.fn();
|
||||
const mockRegistryUrl = vi.fn((path: string, registry: string) => {
|
||||
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
||||
const relative = path.startsWith("/") ? path.slice(1) : path;
|
||||
return new URL(relative, base);
|
||||
});
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
|
||||
apiRequestForm: (...args: unknown[]) => mockApiRequestForm(...args),
|
||||
fetchText: (...args: unknown[]) => mockFetchText(...args),
|
||||
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
|
||||
}));
|
||||
|
||||
const mockGetRegistry = vi.fn(async (_opts?: unknown, _params?: unknown) => "https://clawhub.ai");
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: (opts: unknown, params?: unknown) => mockGetRegistry(opts, params),
|
||||
}));
|
||||
|
||||
const mockGetOptionalAuthToken = vi.fn(async () => undefined as string | undefined);
|
||||
const mockRequireAuthToken = vi.fn(async () => "tkn");
|
||||
vi.mock("../authToken.js", () => ({
|
||||
getOptionalAuthToken: () => mockGetOptionalAuthToken(),
|
||||
requireAuthToken: () => mockRequireAuthToken(),
|
||||
}));
|
||||
|
||||
const mockSpinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
succeed: vi.fn(),
|
||||
start: vi.fn(),
|
||||
isSpinning: false,
|
||||
text: "",
|
||||
};
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
}));
|
||||
|
||||
const { cmdExplorePackages, cmdInspectPackage, cmdPublishPackage } = await import("./packages");
|
||||
|
||||
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const mockWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
function makeOpts(workdir = "/work"): GlobalOpts {
|
||||
return {
|
||||
workdir,
|
||||
dir: join(workdir, "skills"),
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
async function makeTmpWorkdir() {
|
||||
return await mkdtemp(join(tmpdir(), "clawhub-package-"));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLog.mockClear();
|
||||
mockWrite.mockClear();
|
||||
});
|
||||
|
||||
describe("package commands", () => {
|
||||
it("searches package catalog via /api/v1/packages/search", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
results: [
|
||||
{
|
||||
score: 10,
|
||||
package: {
|
||||
name: "@scope/demo",
|
||||
displayName: "Demo",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
summary: "Demo plugin",
|
||||
latestVersion: "1.2.3",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await cmdExplorePackages(makeOpts(), "demo plugin", {
|
||||
family: "code-plugin",
|
||||
executesCode: true,
|
||||
});
|
||||
|
||||
const request = mockApiRequest.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
const url = new URL(String(request?.url));
|
||||
expect(url.pathname).toBe("/api/v1/packages/search");
|
||||
expect(url.searchParams.get("q")).toBe("demo plugin");
|
||||
expect(url.searchParams.get("family")).toBe("code-plugin");
|
||||
expect(url.searchParams.get("executesCode")).toBe("true");
|
||||
});
|
||||
|
||||
it("supports skill family package browse requests", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
await cmdExplorePackages(makeOpts(), "", { family: "skill", limit: 7 });
|
||||
|
||||
const request = mockApiRequest.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
const url = new URL(String(request?.url));
|
||||
expect(url.pathname).toBe("/api/v1/packages");
|
||||
expect(url.searchParams.get("family")).toBe("skill");
|
||||
expect(url.searchParams.get("limit")).toBe("7");
|
||||
});
|
||||
|
||||
it("uses tag param when fetching a package file", async () => {
|
||||
mockApiRequest
|
||||
.mockResolvedValueOnce({
|
||||
package: {
|
||||
name: "demo",
|
||||
displayName: "Demo",
|
||||
family: "code-plugin",
|
||||
runtimeId: "demo.plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
summary: null,
|
||||
latestVersion: "2.0.0",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
tags: { latest: "2.0.0" },
|
||||
compatibility: null,
|
||||
capabilities: { executesCode: true },
|
||||
verification: {
|
||||
tier: "structural",
|
||||
scope: "artifact-only",
|
||||
},
|
||||
},
|
||||
owner: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
package: { name: "demo", displayName: "Demo", family: "code-plugin" },
|
||||
version: {
|
||||
version: "2.0.0",
|
||||
createdAt: 3,
|
||||
changelog: "init",
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
mockFetchText.mockResolvedValue("content");
|
||||
|
||||
await cmdInspectPackage(makeOpts(), "demo", { file: "README.md", tag: "latest" });
|
||||
|
||||
const fetchArgs = mockFetchText.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
const url = new URL(String(fetchArgs?.url));
|
||||
expect(url.pathname).toBe("/api/v1/packages/demo/file");
|
||||
expect(url.searchParams.get("path")).toBe("README.md");
|
||||
expect(url.searchParams.get("tag")).toBe("latest");
|
||||
expect(url.searchParams.get("version")).toBeNull();
|
||||
});
|
||||
|
||||
it("publishes a code plugin package with source metadata", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@scope/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, ".gitignore"), "dist/\n", "utf8");
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "demo.plugin" }), "utf8");
|
||||
await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8");
|
||||
|
||||
mockApiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "pkg_1",
|
||||
releaseId: "rel_1",
|
||||
});
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-plugin", {
|
||||
owner: "@openclaw",
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
sourceCommit: "abc123",
|
||||
sourceRef: "refs/tags/v1.0.0",
|
||||
});
|
||||
|
||||
const publishCall = mockApiRequestForm.mock.calls.find((call) => {
|
||||
const req = call[1] as { path?: string } | undefined;
|
||||
return req?.path === "/api/v1/packages";
|
||||
});
|
||||
if (!publishCall) throw new Error("Missing publish call");
|
||||
const publishForm = (publishCall[1] as { form?: FormData }).form as FormData;
|
||||
const payloadEntry = publishForm.get("payload");
|
||||
if (typeof payloadEntry !== "string") throw new Error("Missing publish payload");
|
||||
const payload = JSON.parse(payloadEntry);
|
||||
expect(payload.name).toBe("@scope/demo-plugin");
|
||||
expect(payload.ownerHandle).toBe("openclaw");
|
||||
expect(payload.family).toBe("code-plugin");
|
||||
expect(payload.version).toBe("1.0.0");
|
||||
expect(payload.source).toMatchObject({
|
||||
repo: "openclaw/demo-plugin",
|
||||
commit: "abc123",
|
||||
ref: "refs/tags/v1.0.0",
|
||||
});
|
||||
const files = publishForm.getAll("files") as Array<Blob & { name?: string }>;
|
||||
expect(files.map((file) => String(file.name ?? "")).sort()).toEqual([
|
||||
".gitignore",
|
||||
"dist/index.js",
|
||||
"openclaw.plugin.json",
|
||||
"package.json",
|
||||
]);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects code-plugin publish without source metadata", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
JSON.stringify({ name: "demo-plugin", version: "1.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "demo.plugin" }), "utf8");
|
||||
|
||||
await expect(cmdPublishPackage(makeOpts(workdir), "demo-plugin", {})).rejects.toThrow(
|
||||
"--source-repo and --source-commit required for code plugins",
|
||||
);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const chmodMock = vi.fn();
|
||||
const mkdirMock = vi.fn();
|
||||
const readFileMock = vi.fn();
|
||||
const writeFileMock = vi.fn();
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
chmod: (...args: unknown[]) => chmodMock(...args),
|
||||
mkdir: (...args: unknown[]) => mkdirMock(...args),
|
||||
readFile: (...args: unknown[]) => readFileMock(...args),
|
||||
writeFile: (...args: unknown[]) => writeFileMock(...args),
|
||||
}));
|
||||
|
||||
const { writeGlobalConfig } = await import("./config");
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
const testConfigPath = "/tmp/clawhub-config-test/config.json";
|
||||
|
||||
function makeErr(code: string): NodeJS.ErrnoException {
|
||||
const error = new Error(code) as NodeJS.ErrnoException;
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CLAWHUB_CONFIG_PATH", testConfigPath);
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
chmodMock.mockResolvedValue(undefined);
|
||||
mkdirMock.mockResolvedValue(undefined);
|
||||
readFileMock.mockResolvedValue("");
|
||||
writeFileMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("writeGlobalConfig", () => {
|
||||
it("writes config with restricted modes", async () => {
|
||||
await writeGlobalConfig({ registry: "https://example.com", token: "clh_test" });
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith("/tmp/clawhub-config-test", {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
expect(writeFileMock).toHaveBeenCalledWith(
|
||||
testConfigPath,
|
||||
expect.stringContaining('"token": "clh_test"'),
|
||||
{
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
expect(chmodMock).toHaveBeenCalledWith(testConfigPath, 0o600);
|
||||
});
|
||||
|
||||
it("ignores non-fatal chmod errors", async () => {
|
||||
chmodMock.mockRejectedValueOnce(makeErr("ENOTSUP"));
|
||||
|
||||
await expect(writeGlobalConfig({ registry: "https://example.com" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rethrows unexpected chmod errors", async () => {
|
||||
chmodMock.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
await expect(writeGlobalConfig({ registry: "https://example.com" })).rejects.toThrow("boom");
|
||||
});
|
||||
});
|
||||
@@ -1,303 +0,0 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const bunRuntimeMocks = vi.hoisted(() => {
|
||||
const originalBunVersion = (process.versions as Record<string, string | undefined>).bun;
|
||||
Object.defineProperty(process.versions, "bun", {
|
||||
value: "1.2.3",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
return {
|
||||
originalBunVersion,
|
||||
spawnSync: vi.fn(),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
mkdtemp: vi.fn(async () => "/tmp/clawhub-test"),
|
||||
rm: vi.fn(async () => undefined),
|
||||
writeFile: vi.fn(async () => undefined),
|
||||
readFile: vi.fn(async () => Buffer.from([1, 2, 3]) as Buffer<ArrayBuffer>),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawnSync: bunRuntimeMocks.spawnSync,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
mkdir: bunRuntimeMocks.mkdir,
|
||||
mkdtemp: bunRuntimeMocks.mkdtemp,
|
||||
rm: bunRuntimeMocks.rm,
|
||||
writeFile: bunRuntimeMocks.writeFile,
|
||||
readFile: bunRuntimeMocks.readFile,
|
||||
}));
|
||||
|
||||
import * as http from "./http";
|
||||
|
||||
function restoreBunRuntime() {
|
||||
if (bunRuntimeMocks.originalBunVersion === undefined) {
|
||||
Reflect.deleteProperty(process.versions, "bun");
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(process.versions, "bun", {
|
||||
value: bunRuntimeMocks.originalBunVersion,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function mockImmediateTimeouts() {
|
||||
const setTimeoutMock = vi.fn((callback: () => void) => {
|
||||
callback();
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutMock = vi.fn();
|
||||
vi.stubGlobal("setTimeout", setTimeoutMock as unknown as typeof setTimeout);
|
||||
vi.stubGlobal("clearTimeout", clearTimeoutMock as typeof clearTimeout);
|
||||
return { setTimeoutMock, clearTimeoutMock };
|
||||
}
|
||||
|
||||
type SpawnImpl = (...args: unknown[]) => unknown;
|
||||
|
||||
async function loadHttpModuleWithBunMocks(opts?: {
|
||||
spawnImpl?: SpawnImpl;
|
||||
mkdtempValue?: string;
|
||||
readFileValue?: Buffer | null;
|
||||
}) {
|
||||
const spawnSync: SpawnImpl = opts?.spawnImpl ?? vi.fn();
|
||||
bunRuntimeMocks.spawnSync.mockImplementation((...args: unknown[]) => spawnSync(...args));
|
||||
bunRuntimeMocks.mkdir.mockImplementation(async () => undefined);
|
||||
bunRuntimeMocks.mkdtemp.mockImplementation(async () => opts?.mkdtempValue ?? "/tmp/clawhub-test");
|
||||
bunRuntimeMocks.rm.mockImplementation(async () => undefined);
|
||||
bunRuntimeMocks.writeFile.mockImplementation(async () => undefined);
|
||||
bunRuntimeMocks.readFile.mockImplementation(
|
||||
async () => (opts?.readFileValue ?? Buffer.from([1, 2, 3])) as Buffer<ArrayBuffer>,
|
||||
);
|
||||
|
||||
return {
|
||||
http,
|
||||
spawnSync: bunRuntimeMocks.spawnSync,
|
||||
mkdir: bunRuntimeMocks.mkdir,
|
||||
mkdtemp: bunRuntimeMocks.mkdtemp,
|
||||
rm: bunRuntimeMocks.rm,
|
||||
writeFile: bunRuntimeMocks.writeFile,
|
||||
readFile: bunRuntimeMocks.readFile,
|
||||
};
|
||||
}
|
||||
|
||||
describe("http bun runtime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(process.versions, "bun", {
|
||||
value: "1.2.3",
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreBunRuntime();
|
||||
});
|
||||
|
||||
it("uses curl for apiRequest GET and parses JSON", async () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: '{"ok":true}\n200',
|
||||
stderr: "",
|
||||
});
|
||||
const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync });
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await httpClient.apiRequest<{ ok: boolean }>("https://registry.example", {
|
||||
method: "GET",
|
||||
path: "/v1/ping",
|
||||
token: "clh_token",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(spawnSync).toHaveBeenCalledTimes(1);
|
||||
const [, args] = spawnSync.mock.calls[0] as [string, string[]];
|
||||
expect(args).toContain("GET");
|
||||
expect(args).toContain("https://registry.example/v1/ping");
|
||||
expect(args).toContain("Accept: application/json");
|
||||
expect(args).toContain("Authorization: Bearer clh_token");
|
||||
}, 10_000);
|
||||
|
||||
it("uses curl for apiRequest POST with json body", async () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: '{"ok":true}\n200',
|
||||
stderr: "",
|
||||
});
|
||||
const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync });
|
||||
|
||||
await httpClient.apiRequest("https://registry.example", {
|
||||
method: "POST",
|
||||
path: "/v1/ping",
|
||||
body: { a: 1 },
|
||||
});
|
||||
|
||||
const [, args] = spawnSync.mock.calls[0] as [string, string[]];
|
||||
expect(args).toContain("Content-Type: application/json");
|
||||
expect(args).toContain("--data-binary");
|
||||
expect(args).toContain('{"a":1}');
|
||||
});
|
||||
|
||||
it("retries bun apiRequest on 429 errors", async () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: "rate limited\n429",
|
||||
stderr: "",
|
||||
});
|
||||
const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync });
|
||||
|
||||
await expect(
|
||||
httpClient.apiRequest("https://registry.example", {
|
||||
method: "GET",
|
||||
path: "/v1/ping",
|
||||
}),
|
||||
).rejects.toThrow("rate limited");
|
||||
|
||||
expect(spawnSync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("includes rate-limit guidance from curl metadata on 429", async () => {
|
||||
mockImmediateTimeouts();
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: "rate limited\n__CLAWHUB_CURL_META__\n429\n20\n0\n1771404540\n20\n0\n34\n34\n",
|
||||
stderr: "",
|
||||
});
|
||||
const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync });
|
||||
|
||||
await expect(
|
||||
httpClient.apiRequest("https://registry.example", {
|
||||
method: "GET",
|
||||
path: "/v1/ping",
|
||||
}),
|
||||
).rejects.toThrow(/retry in 34s.*remaining: 0\/20.*reset in 34s/i);
|
||||
|
||||
expect(spawnSync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not retry bun apiRequest on 404 errors", async () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: "missing\n404",
|
||||
stderr: "",
|
||||
});
|
||||
const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync });
|
||||
|
||||
await expect(
|
||||
httpClient.apiRequest("https://registry.example", {
|
||||
method: "GET",
|
||||
path: "/v1/ping",
|
||||
}),
|
||||
).rejects.toThrow("missing");
|
||||
|
||||
expect(spawnSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("supports fetchText bun path and propagates status fallback", async () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "hello world\n200",
|
||||
stderr: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "\n400",
|
||||
stderr: "",
|
||||
});
|
||||
const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync });
|
||||
|
||||
const text = await httpClient.fetchText("https://registry.example", { path: "/v1/readme" });
|
||||
expect(text).toBe("hello world");
|
||||
|
||||
await expect(
|
||||
httpClient.fetchText("https://registry.example", { path: "/v1/readme" }),
|
||||
).rejects.toThrow("HTTP 400");
|
||||
});
|
||||
|
||||
it("handles downloadZip bun path and cleans up temp dir", async () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "200",
|
||||
stderr: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "404",
|
||||
stderr: "",
|
||||
});
|
||||
const {
|
||||
http: httpClient,
|
||||
rm,
|
||||
readFile,
|
||||
} = await loadHttpModuleWithBunMocks({
|
||||
spawnImpl: spawnSync,
|
||||
mkdtempValue: "/tmp/clawhub-download-abc",
|
||||
readFileValue: Buffer.from("not found"),
|
||||
});
|
||||
|
||||
const bytes = await httpClient.downloadZip("https://registry.example", {
|
||||
slug: "demo",
|
||||
token: "t",
|
||||
});
|
||||
expect(Array.from(bytes)).toEqual(Array.from(Buffer.from("not found")));
|
||||
|
||||
await expect(
|
||||
httpClient.downloadZip("https://registry.example", { slug: "demo", token: "t" }),
|
||||
).rejects.toThrow("not found");
|
||||
|
||||
expect(readFile).toHaveBeenCalled();
|
||||
expect(rm).toHaveBeenCalledWith("/tmp/clawhub-download-abc", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("posts multipart form via curl in bun path", async () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: '{"ok":true}\n200',
|
||||
stderr: "",
|
||||
});
|
||||
const {
|
||||
http: httpClient,
|
||||
mkdir,
|
||||
writeFile,
|
||||
rm,
|
||||
} = await loadHttpModuleWithBunMocks({
|
||||
spawnImpl: spawnSync,
|
||||
mkdtempValue: "/tmp/clawhub-upload-abc",
|
||||
});
|
||||
|
||||
const form = new FormData();
|
||||
form.append("name", "demo");
|
||||
form.append("file", new Blob(["abc"], { type: "text/plain" }), "dist/demo.txt");
|
||||
|
||||
const result = await httpClient.apiRequestForm<{ ok: boolean }>("https://registry.example", {
|
||||
method: "POST",
|
||||
path: "/upload",
|
||||
form,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mkdir).toHaveBeenCalledWith("/tmp/clawhub-upload-abc/dist", { recursive: true });
|
||||
expect(writeFile).toHaveBeenCalled();
|
||||
expect(rm).toHaveBeenCalledWith("/tmp/clawhub-upload-abc", { recursive: true, force: true });
|
||||
const [, args] = spawnSync.mock.calls[0] as [string, string[]];
|
||||
expect(args).toContain("-F");
|
||||
expect(args.some((arg) => arg.includes("name=demo"))).toBe(true);
|
||||
expect(args.some((arg) => arg.includes("file=@/tmp/clawhub-upload-abc/dist/demo.txt"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,397 +0,0 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
apiRequest,
|
||||
apiRequestForm,
|
||||
downloadZip,
|
||||
fetchText,
|
||||
registryUrl,
|
||||
shouldUseProxyFromEnv,
|
||||
} from "./http";
|
||||
import { ApiV1WhoamiResponseSchema } from "./schema/index.js";
|
||||
|
||||
function mockImmediateTimeouts() {
|
||||
const setTimeoutMock = vi.fn((callback: () => void, _ms?: number) => {
|
||||
callback();
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutMock = vi.fn();
|
||||
vi.stubGlobal("setTimeout", setTimeoutMock as unknown as typeof setTimeout);
|
||||
vi.stubGlobal("clearTimeout", clearTimeoutMock as typeof clearTimeout);
|
||||
return { setTimeoutMock, clearTimeoutMock };
|
||||
}
|
||||
|
||||
function createAbortingFetchMock() {
|
||||
return vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const signal = init?.signal;
|
||||
if (!signal || !(signal instanceof AbortSignal)) {
|
||||
throw new Error("Missing abort signal");
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw signal.reason;
|
||||
}
|
||||
return await new Promise<Response>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("shouldUseProxyFromEnv", () => {
|
||||
it("detects standard proxy variables", () => {
|
||||
expect(
|
||||
shouldUseProxyFromEnv({
|
||||
HTTPS_PROXY: "http://proxy.example:3128",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseProxyFromEnv({
|
||||
HTTP_PROXY: "http://proxy.example:3128",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseProxyFromEnv({
|
||||
https_proxy: "http://proxy.example:3128",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores NO_PROXY-only configs", () => {
|
||||
expect(
|
||||
shouldUseProxyFromEnv({
|
||||
NO_PROXY: "localhost,127.0.0.1",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe(false);
|
||||
expect(shouldUseProxyFromEnv({} as NodeJS.ProcessEnv)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registryUrl", () => {
|
||||
it("works with a plain-origin registry (no base path)", () => {
|
||||
expect(registryUrl("/api/v1/skills", "https://clawhub.ai").toString()).toBe(
|
||||
"https://clawhub.ai/api/v1/skills",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the registry base path", () => {
|
||||
const base = "http://localhost:8081/custom/registry/path";
|
||||
expect(registryUrl("/api/v1/skills", base).toString()).toBe(
|
||||
"http://localhost:8081/custom/registry/path/api/v1/skills",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a trailing slash on the registry", () => {
|
||||
const base = "http://localhost:8081/custom/registry/path/";
|
||||
expect(registryUrl("/api/v1/skills", base).toString()).toBe(
|
||||
"http://localhost:8081/custom/registry/path/api/v1/skills",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles paths without a leading slash", () => {
|
||||
expect(registryUrl("api/v1/skills", "https://clawhub.ai").toString()).toBe(
|
||||
"https://clawhub.ai/api/v1/skills",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles compound paths with encoded segments", () => {
|
||||
const base = "http://localhost:8081/base";
|
||||
const path = `/api/v1/skills/${encodeURIComponent("my-skill")}/versions`;
|
||||
expect(registryUrl(path, base).toString()).toBe(
|
||||
"http://localhost:8081/base/api/v1/skills/my-skill/versions",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiRequest", () => {
|
||||
it("adds bearer token and parses json", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ user: { handle: null } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const result = await apiRequest(
|
||||
"https://example.com",
|
||||
{ method: "GET", path: "/x", token: "clh_token" },
|
||||
ApiV1WhoamiResponseSchema,
|
||||
);
|
||||
expect(result.user.handle).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer clh_token");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("posts json body", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await apiRequest("https://example.com", {
|
||||
method: "POST",
|
||||
path: "/x",
|
||||
body: { a: 1 },
|
||||
});
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://example.com/x");
|
||||
expect(init.body).toBe(JSON.stringify({ a: 1 }));
|
||||
expect((init.headers as Record<string, string>)["Content-Type"]).toBe("application/json");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("throws text body on non-200", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => "bad",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
"bad",
|
||||
);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("includes rate-limit guidance from headers on 429", async () => {
|
||||
mockImmediateTimeouts();
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: new Headers({
|
||||
"Retry-After": "34",
|
||||
"X-RateLimit-Limit": "20",
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": "1771404540",
|
||||
}),
|
||||
text: async () => "Rate limit exceeded",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
/retry in 34s.*remaining: 0\/20.*reset in 34s/i,
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("interprets legacy epoch Retry-After values as reset delays", async () => {
|
||||
mockImmediateTimeouts();
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_771_404_500_000);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: new Headers({
|
||||
"Retry-After": "1771404540",
|
||||
"X-RateLimit-Limit": "20",
|
||||
"X-RateLimit-Remaining": "0",
|
||||
}),
|
||||
text: async () => "Rate limit exceeded",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
/retry in 40s.*remaining: 0\/20/i,
|
||||
);
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("falls back to HTTP status when body is empty", async () => {
|
||||
mockImmediateTimeouts();
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(
|
||||
apiRequest("https://example.com", { method: "GET", url: "https://example.com/x" }),
|
||||
).rejects.toThrow("HTTP 500");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("downloads zip bytes", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const bytes = await downloadZip("https://example.com", {
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
token: "clh_token",
|
||||
});
|
||||
expect(Array.from(bytes)).toEqual([1, 2, 3]);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("slug=demo");
|
||||
expect(url).toContain("version=1.0.0");
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer clh_token");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not retry on non-retryable errors", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => "nope",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(downloadZip("https://example.com", { slug: "demo" })).rejects.toThrow("nope");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("aborts with Error timeouts and retries", async () => {
|
||||
const { clearTimeoutMock } = mockImmediateTimeouts();
|
||||
const fetchMock = createAbortingFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await apiRequest("https://example.com", { method: "GET", path: "/x" });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toMatch(/timed out/);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(clearTimeoutMock.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiRequestForm", () => {
|
||||
it("posts form data and returns json", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const form = new FormData();
|
||||
form.append("x", "1");
|
||||
const result = await apiRequestForm("https://example.com", {
|
||||
method: "POST",
|
||||
path: "/upload",
|
||||
token: "clh_token",
|
||||
form,
|
||||
});
|
||||
expect(result).toEqual({ ok: true });
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe(form);
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer clh_token");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("retries on 429", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: async () => "rate limited",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(
|
||||
apiRequestForm("https://example.com", {
|
||||
method: "POST",
|
||||
path: "/upload",
|
||||
form: new FormData(),
|
||||
}),
|
||||
).rejects.toThrow("rate limited");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("falls back to HTTP status when body cannot be read", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(
|
||||
apiRequestForm("https://example.com", {
|
||||
method: "POST",
|
||||
path: "/upload",
|
||||
form: new FormData(),
|
||||
}),
|
||||
).rejects.toThrow("HTTP 400");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses the longer upload timeout for multipart requests", async () => {
|
||||
const { setTimeoutMock, clearTimeoutMock } = mockImmediateTimeouts();
|
||||
const fetchMock = createAbortingFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await apiRequestForm("https://example.com", {
|
||||
method: "POST",
|
||||
path: "/upload",
|
||||
form: new FormData(),
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toMatch(/timed out after 120s/i);
|
||||
expect(setTimeoutMock).toHaveBeenCalled();
|
||||
expect(setTimeoutMock.mock.calls[0]?.[1]).toBe(120_000);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(clearTimeoutMock.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchText", () => {
|
||||
it("aborts with Error timeouts and retries", async () => {
|
||||
const { clearTimeoutMock } = mockImmediateTimeouts();
|
||||
const fetchMock = createAbortingFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await fetchText("https://example.com", { path: "/x" });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toMatch(/timed out/);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(clearTimeoutMock.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchWithTimeout — non-Error normalization", () => {
|
||||
it("wraps DOMException-like non-Error throws into proper Error instances", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
// Simulate a runtime that throws a non-Error object on abort
|
||||
throw { message: "The operation was aborted", name: "AbortError" };
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await apiRequest("https://example.com", { method: "GET", path: "/x" });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toContain("The operation was aborted");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -36,13 +36,46 @@ 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 skill 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
|
||||
clawhub package publish openclaw/example-plugin
|
||||
clawhub package publish openclaw/example-plugin@v1.0.0
|
||||
clawhub package publish https://github.com/openclaw/example-plugin --dry-run
|
||||
clawhub package publish ./example-plugin
|
||||
```
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
This repo also provides an official reusable workflow for plugin repos:
|
||||
|
||||
- [`.github/workflows/package-publish.yml`](../../.github/workflows/package-publish.yml)
|
||||
|
||||
Use `dry_run: true` on pull requests and reserve real publishes for trusted events
|
||||
such as `workflow_dispatch` or tag pushes with a `CLAWHUB_TOKEN` secret.
|
||||
|
||||
## Maintainers
|
||||
|
||||
The `clawhub` npm package is released separately from the ClawHub app deploy.
|
||||
|
||||
- Release workflow: [`.github/workflows/clawhub-cli-npm-release.yml`](../../.github/workflows/clawhub-cli-npm-release.yml)
|
||||
- Release model: manual-only, stable tags only (`vX.Y.Z`), with a preflight run before the real publish
|
||||
- Publish auth: npm trusted publishing through the `npm-release` GitHub environment
|
||||
|
||||
## Development
|
||||
|
||||
The supported verification flow for this package is package-local:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
`test` runs source tests only. `test:artifact` builds `dist/` and runs a small smoke suite against the built CLI entrypoint.
|
||||
|
||||
## Sync (upload local skills)
|
||||
|
||||
```bash
|
||||
@@ -1,8 +1,17 @@
|
||||
{
|
||||
"name": "clawhub",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"description": "ClawHub CLI \\u2014 install, update, search, and publish skills plus OpenClaw packages.",
|
||||
"homepage": "https://clawhub.ai",
|
||||
"bugs": {
|
||||
"url": "https://github.com/openclaw/clawhub/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/clawhub.git",
|
||||
"directory": "packages/clawhub"
|
||||
},
|
||||
"bin": {
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
"clawhub": "bin/clawdhub.js"
|
||||
@@ -14,10 +23,18 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"build": "node ./scripts/build.mjs",
|
||||
"dev": "node --enable-source-maps dist/cli.js",
|
||||
"prepublishOnly": "npm run build"
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "bun run test:src",
|
||||
"test:artifact": "bun run build && vitest run -c vitest.artifact.config.ts",
|
||||
"test:src": "vitest run -c vitest.config.ts",
|
||||
"verify": "bun run test:src && bun run verify:build && bun run test:artifact",
|
||||
"verify:build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
@@ -0,0 +1,19 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const distDir = resolve(packageRoot, "dist");
|
||||
|
||||
await rm(distDir, { recursive: true, force: true });
|
||||
|
||||
const tscBin = require.resolve("typescript/bin/tsc");
|
||||
const result = spawnSync(process.execPath, [tscBin, "-p", "tsconfig.json"], {
|
||||
cwd: packageRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -16,8 +16,11 @@ import { cmdBanUser, cmdSetRole } from "./cli/commands/moderation.js";
|
||||
import { cmdMergeSkill, cmdRenameSkill } from "./cli/commands/ownership.js";
|
||||
import {
|
||||
cmdExplorePackages,
|
||||
cmdGetPackageTrustedPublisher,
|
||||
cmdInspectPackage,
|
||||
cmdDeletePackageTrustedPublisher,
|
||||
cmdPublishPackage,
|
||||
cmdSetPackageTrustedPublisher,
|
||||
} from "./cli/commands/packages.js";
|
||||
import { cmdPublish } from "./cli/commands/publish.js";
|
||||
import {
|
||||
@@ -276,7 +279,7 @@ program
|
||||
|
||||
program
|
||||
.command("publish")
|
||||
.description("Publish skill from folder")
|
||||
.description("Legacy alias: publish a skill from folder")
|
||||
.argument("<path>", "Skill folder path")
|
||||
.option("--slug <slug>", "Skill slug")
|
||||
.option("--name <name>", "Display name")
|
||||
@@ -330,9 +333,22 @@ program
|
||||
});
|
||||
|
||||
const skill = program.command("skill").description("Manage published skills");
|
||||
const packageCmd = program
|
||||
.command("package")
|
||||
.description("Browse and publish OpenClaw packages");
|
||||
skill
|
||||
.command("publish")
|
||||
.description("Publish a skill from folder")
|
||||
.argument("<path>", "Skill folder path")
|
||||
.option("--slug <slug>", "Skill slug")
|
||||
.option("--name <name>", "Display name")
|
||||
.option("--version <version>", "Version (semver)")
|
||||
.option("--fork-of <slug[@version]>", "Mark as a fork of an existing skill")
|
||||
.option("--changelog <text>", "Changelog text")
|
||||
.option("--tags <tags>", "Comma-separated tags", "latest")
|
||||
.action(async (folder, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPublish(opts, folder, options);
|
||||
});
|
||||
|
||||
const packageCmd = program.command("package").description("Browse and publish OpenClaw packages");
|
||||
|
||||
packageCmd
|
||||
.command("explore")
|
||||
@@ -372,24 +388,67 @@ packageCmd
|
||||
|
||||
packageCmd
|
||||
.command("publish")
|
||||
.description("Publish a code plugin or bundle plugin from folder")
|
||||
.argument("<path>", "Package folder path")
|
||||
.description("Publish a code plugin or bundle plugin from a folder or GitHub source")
|
||||
.argument("<source>", "Package folder path, GitHub repo (owner/repo[@ref]), or URL")
|
||||
.option("--family <family>", "code-plugin|bundle-plugin")
|
||||
.option("--name <name>", "Package name")
|
||||
.option("--display-name <name>", "Display name")
|
||||
.option("--owner <handle>", "Publish under this owner handle (admin only)")
|
||||
.option("--version <version>", "Version")
|
||||
.option("--changelog <text>", "Changelog text")
|
||||
.option(
|
||||
"--manual-override-reason <reason>",
|
||||
"Required for manual publish when trusted publisher config exists",
|
||||
)
|
||||
.option("--tags <tags>", "Comma-separated tags", "latest")
|
||||
.option("--bundle-format <format>", "Bundle format")
|
||||
.option("--host-targets <targets>", "Comma-separated bundle host targets")
|
||||
.option("--source-repo <repo>", "GitHub repo (owner/repo or URL)")
|
||||
.option("--source-commit <sha>", "Git commit SHA")
|
||||
.option("--source-ref <ref>", "Git ref/tag/branch")
|
||||
.option("--source-path <path>", "Repo subpath", ".")
|
||||
.action(async (folder, options) => {
|
||||
.option("--source-path <path>", "Repo subpath")
|
||||
.option("--dry-run", "Preview what would be published without uploading")
|
||||
.option("--json", "Output JSON (for CI pipelines)")
|
||||
.action(async (source, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPublishPackage(opts, folder, options);
|
||||
await cmdPublishPackage(opts, source, options);
|
||||
});
|
||||
|
||||
const trustedPublisherCmd = packageCmd
|
||||
.command("trusted-publisher")
|
||||
.description("Manage package trusted publisher config");
|
||||
|
||||
trustedPublisherCmd
|
||||
.command("get")
|
||||
.description("Show trusted publisher config for a package")
|
||||
.argument("<name>", "Package name")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (name, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdGetPackageTrustedPublisher(opts, name, options);
|
||||
});
|
||||
|
||||
trustedPublisherCmd
|
||||
.command("set")
|
||||
.description("Attach or replace trusted publisher config for a package")
|
||||
.argument("<name>", "Package name")
|
||||
.requiredOption("--repository <repo>", "GitHub repo (owner/repo or URL)")
|
||||
.requiredOption("--workflow-filename <file>", "Workflow filename, for example publish.yml")
|
||||
.option("--environment <name>", "Optional GitHub environment name to pin")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (name, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdSetPackageTrustedPublisher(opts, name, options);
|
||||
});
|
||||
|
||||
trustedPublisherCmd
|
||||
.command("delete")
|
||||
.description("Remove trusted publisher config from a package")
|
||||
.argument("<name>", "Package name")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (name, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdDeletePackageTrustedPublisher(opts, name, options);
|
||||
});
|
||||
|
||||
skill
|
||||
+6
-17
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import { createRegistryModuleMocks, makeGlobalOpts } from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
const mockReadGlobalConfig = vi.fn(
|
||||
async () => null as { registry?: string; token?: string } | null,
|
||||
@@ -12,25 +12,14 @@ vi.mock("../../config.js", () => ({
|
||||
writeGlobalConfig: (cfg: unknown) => mockWriteGlobalConfig(cfg),
|
||||
}));
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => "https://clawhub.ai");
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}));
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const mockGetRegistry = registryMocks.getRegistry;
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
|
||||
const { cmdLogout } = await import("./auth");
|
||||
|
||||
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLog.mockClear();
|
||||
@@ -40,7 +29,7 @@ describe("cmdLogout", () => {
|
||||
it("removes token and logs a clear message", async () => {
|
||||
mockReadGlobalConfig.mockResolvedValueOnce({ registry: "https://clawhub.ai", token: "tkn" });
|
||||
|
||||
await cmdLogout(makeOpts());
|
||||
await cmdLogout(makeGlobalOpts());
|
||||
|
||||
expect(mockWriteGlobalConfig).toHaveBeenCalledWith({
|
||||
registry: "https://clawhub.ai",
|
||||
@@ -56,7 +45,7 @@ describe("cmdLogout", () => {
|
||||
mockReadGlobalConfig.mockResolvedValueOnce({ token: "tkn" });
|
||||
mockGetRegistry.mockResolvedValueOnce("https://registry.example");
|
||||
|
||||
await cmdLogout(makeOpts());
|
||||
await cmdLogout(makeGlobalOpts());
|
||||
|
||||
expect(mockGetRegistry).toHaveBeenCalled();
|
||||
expect(mockWriteGlobalConfig).toHaveBeenCalledWith({
|
||||
@@ -0,0 +1,71 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdDeleteSkill, cmdHideSkill, cmdUndeleteSkill, cmdUnhideSkill } = await import("./delete");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("delete/undelete", () => {
|
||||
it("requires --yes when input is disabled", async () => {
|
||||
await expect(cmdDeleteSkill(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdUndeleteSkill(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdHideSkill(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdUnhideSkill(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
});
|
||||
|
||||
it("calls delete endpoint with --yes", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true });
|
||||
await cmdDeleteSkill(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "DELETE", path: "/api/v1/skills/demo" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls undelete endpoint with --yes", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true });
|
||||
await cmdUndeleteSkill(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/undelete" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports hide/unhide aliases", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValue({ ok: true });
|
||||
await cmdHideSkill(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
await cmdUnhideSkill(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "DELETE", path: "/api/v1/skills/demo" }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/undelete" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { zipSync } from "fflate";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchGitHubSource, resolveLocalGitInfo, resolveSourceInput } from "./github";
|
||||
|
||||
async function makeTmpDir() {
|
||||
return await mkdtemp(join(tmpdir(), "clawhub-github-test-"));
|
||||
}
|
||||
|
||||
function runGit(cwd: string, args: string[]) {
|
||||
const result = spawnSync("git", ["-C", cwd, ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mockGitHubCommitLookup(validRefs: string[]) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = input instanceof Request ? input.url : input.toString();
|
||||
const match = url.match(/\/repos\/owner\/repo\/commits\/(.+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}
|
||||
const ref = decodeURIComponent(match[1] ?? "");
|
||||
if (!validRefs.includes(ref)) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
return new Response(JSON.stringify({ sha: "0123456789abcdef0123456789abcdef01234567" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
Object.defineProperty(globalThis, "fetch", {
|
||||
value: fetchMock,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
return () => {
|
||||
Object.defineProperty(globalThis, "fetch", {
|
||||
value: originalFetch,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
describe("github publish source helpers", () => {
|
||||
it.each([
|
||||
[
|
||||
"owner/repo",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
[
|
||||
"owner/repo@v1.0.0",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
ref: "v1.0.0",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
[
|
||||
"owner/repo@main",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
ref: "main",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
[
|
||||
"https://github.com/owner/repo",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
[
|
||||
"https://github.com/owner/repo/tree/main",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
ref: "main",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
[
|
||||
"https://github.com/owner/repo/tree/main/plugins/demo",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
ref: "main",
|
||||
path: "plugins/demo",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
[
|
||||
"https://github.com/owner/repo/blob/main/plugins/demo/index.ts",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
ref: "main",
|
||||
path: "plugins/demo",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
[
|
||||
"https://github.com/owner/repo.git",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
])("parses %s as a GitHub source", async (input, expected) => {
|
||||
const workdir = await makeTmpDir();
|
||||
const restoreFetch =
|
||||
input.includes("/tree/") || input.includes("/blob/")
|
||||
? mockGitHubCommitLookup([String((expected as { ref?: string }).ref ?? "")])
|
||||
: null;
|
||||
try {
|
||||
await expect(resolveSourceInput(input, { workdir })).resolves.toEqual(expected);
|
||||
} finally {
|
||||
restoreFetch?.();
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("parses tree URLs whose refs contain slashes", async () => {
|
||||
const workdir = await makeTmpDir();
|
||||
const restoreFetch = mockGitHubCommitLookup(["feature/new-ui"]);
|
||||
try {
|
||||
await expect(
|
||||
resolveSourceInput("https://github.com/owner/repo/tree/feature/new-ui/plugins/demo", {
|
||||
workdir,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
ref: "feature/new-ui",
|
||||
path: "plugins/demo",
|
||||
url: "https://github.com/owner/repo",
|
||||
});
|
||||
} finally {
|
||||
restoreFetch();
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"./local-folder",
|
||||
"/absolute/path",
|
||||
"~/path",
|
||||
".",
|
||||
"@scope/package",
|
||||
"owner/repo/extra",
|
||||
])("treats %s as a local path", async (input) => {
|
||||
const workdir = await makeTmpDir();
|
||||
try {
|
||||
const resolved = await resolveSourceInput(input, { workdir });
|
||||
expect(resolved.kind).toBe("local");
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers an existing local directory over GitHub shorthand", async () => {
|
||||
const workdir = await makeTmpDir();
|
||||
try {
|
||||
const localDir = join(workdir, "owner", "repo");
|
||||
await mkdir(localDir, { recursive: true });
|
||||
|
||||
await expect(resolveSourceInput("owner/repo", { workdir })).resolves.toEqual({
|
||||
kind: "local",
|
||||
path: localDir,
|
||||
});
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves git metadata for a nested folder in a real git repo", async () => {
|
||||
const root = await makeTmpDir();
|
||||
try {
|
||||
const nested = join(root, "plugins", "demo");
|
||||
await mkdir(nested, { recursive: true });
|
||||
await writeFile(join(nested, "package.json"), '{"name":"demo"}\n', "utf8");
|
||||
|
||||
runGit(root, ["init", "-b", "main"]);
|
||||
runGit(root, ["remote", "add", "origin", "git@github.com:openclaw/demo-repo.git"]);
|
||||
runGit(root, ["add", "."]);
|
||||
runGit(root, [
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"-m",
|
||||
"init",
|
||||
]);
|
||||
const commit = runGit(root, ["rev-parse", "HEAD"]);
|
||||
const gitRoot = runGit(root, ["rev-parse", "--show-toplevel"]);
|
||||
runGit(root, ["-c", "tag.gpgSign=false", "tag", "v1.0.0"]);
|
||||
|
||||
expect(resolveLocalGitInfo(nested)).toEqual({
|
||||
root: gitRoot,
|
||||
path: "plugins/demo",
|
||||
repo: "openclaw/demo-repo",
|
||||
commit,
|
||||
ref: "v1.0.0",
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for a non-git folder", async () => {
|
||||
const workdir = await makeTmpDir();
|
||||
try {
|
||||
const folder = join(workdir, "not-a-repo");
|
||||
await mkdir(folder, { recursive: true });
|
||||
expect(resolveLocalGitInfo(folder)).toBeNull();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("extracts GitHub archives that contain explicit directory entries", async () => {
|
||||
const archiveBytes = zipSync({
|
||||
"repo-root/.agents/": new Uint8Array(),
|
||||
"repo-root/.agents/config.json": new TextEncoder().encode('{"ok":true}\n'),
|
||||
"repo-root/package.json": new TextEncoder().encode('{"name":"demo","version":"1.0.0"}\n'),
|
||||
"repo-root/openclaw.plugin.json": new TextEncoder().encode(
|
||||
'{"id":"demo","configSchema":{"type":"object"}}\n',
|
||||
),
|
||||
});
|
||||
const archiveBody = archiveBytes.buffer.slice(
|
||||
archiveBytes.byteOffset,
|
||||
archiveBytes.byteOffset + archiveBytes.byteLength,
|
||||
) as ArrayBuffer;
|
||||
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ default_branch: "main" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ sha: "0123456789abcdef0123456789abcdef01234567" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(archiveBody, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/zip" },
|
||||
}),
|
||||
);
|
||||
const originalFetch = globalThis.fetch;
|
||||
Object.defineProperty(globalThis, "fetch", {
|
||||
value: fetchMock,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const fetched = await fetchGitHubSource({
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
});
|
||||
|
||||
try {
|
||||
expect(await readFile(join(fetched.dir, ".agents", "config.json"), "utf8")).toContain(
|
||||
'"ok":true',
|
||||
);
|
||||
expect(await readFile(join(fetched.dir, "package.json"), "utf8")).toContain('"name":"demo"');
|
||||
} finally {
|
||||
await fetched.cleanup();
|
||||
Object.defineProperty(globalThis, "fetch", {
|
||||
value: originalFetch,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects GitHub archives with unsafe paths", async () => {
|
||||
const archiveBytes = zipSync({
|
||||
"repo-root/../../escape.txt": new TextEncoder().encode("bad\n"),
|
||||
"repo-root/package.json": new TextEncoder().encode('{"name":"demo","version":"1.0.0"}\n'),
|
||||
"repo-root/openclaw.plugin.json": new TextEncoder().encode(
|
||||
'{"id":"demo","configSchema":{"type":"object"}}\n',
|
||||
),
|
||||
});
|
||||
const archiveBody = archiveBytes.buffer.slice(
|
||||
archiveBytes.byteOffset,
|
||||
archiveBytes.byteOffset + archiveBytes.byteLength,
|
||||
) as ArrayBuffer;
|
||||
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ default_branch: "main" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ sha: "0123456789abcdef0123456789abcdef01234567" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(archiveBody, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/zip" },
|
||||
}),
|
||||
);
|
||||
const originalFetch = globalThis.fetch;
|
||||
Object.defineProperty(globalThis, "fetch", {
|
||||
value: fetchMock,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
fetchGitHubSource({
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
}),
|
||||
).rejects.toThrow(/Unsafe path in archive/i);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "fetch", {
|
||||
value: originalFetch,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user