mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-18 09:48:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83708f24ba | ||
|
|
6c60356b54 | ||
|
|
5b920dcd6f | ||
|
|
f285efa059 | ||
|
|
b9341202c7 | ||
|
|
d314617728 | ||
|
|
171178fb19 | ||
|
|
d701c2a33b | ||
|
|
79d17a91ed | ||
|
|
2f15202a68 | ||
|
|
342a2b1ca4 | ||
|
|
75915cd2b5 | ||
|
|
5395d9d159 | ||
|
|
87c236e8ce | ||
|
|
d8db9b99a2 | ||
|
|
d8e1f0daa1 | ||
|
|
8a350d953c | ||
|
|
3fb3150ee2 | ||
|
|
7dbc0fc3bb | ||
|
|
fc4f8644eb | ||
|
|
dda6d55fbf | ||
|
|
e014759b40 | ||
|
|
2186c41c48 | ||
|
|
c30c182478 | ||
|
|
3080567964 | ||
|
|
f304541561 | ||
|
|
91224ada13 | ||
|
|
d5fbaeef81 | ||
|
|
3706018b72 | ||
|
|
62e616f635 | ||
|
|
9013d324c8 | ||
|
|
c1363ec8d0 | ||
|
|
7e09196f92 | ||
|
|
807043b4b0 | ||
|
|
972fe35935 | ||
|
|
16ee540f5d | ||
|
|
f541882d55 | ||
|
|
95bc156747 | ||
|
|
bf7422022f | ||
|
|
48d0fc91f3 |
@@ -31,6 +31,9 @@ jobs:
|
||||
- name: Coverage
|
||||
run: bun run coverage
|
||||
|
||||
- name: ClawHub CLI Verify
|
||||
run: bun run --cwd packages/clawdhub verify
|
||||
|
||||
- name: Typecheck
|
||||
run: |
|
||||
bunx tsc --noEmit
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
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
|
||||
clawhub_version:
|
||||
description: CLI version to run.
|
||||
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
|
||||
outputs:
|
||||
publish_json: ${{ steps.capture.outputs.publish_json }}
|
||||
release_id: ${{ steps.capture.outputs.release_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Validate publish mode inputs
|
||||
env:
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
||||
run: |
|
||||
if [[ "$DRY_RUN" != "true" && -z "$CLAWHUB_TOKEN" ]]; then
|
||||
echo "::error::secrets.clawhub_token is required when dry_run is false."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Write ClawHub config
|
||||
if: secrets.clawhub_token != ''
|
||||
env:
|
||||
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
||||
CLAWHUB_REGISTRY: ${{ inputs.registry }}
|
||||
run: |
|
||||
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_JSON: ${{ inputs.json }}
|
||||
INPUT_OWNER: ${{ inputs.owner }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_TAGS: ${{ inputs.tags }}
|
||||
INPUT_SITE: ${{ inputs.site }}
|
||||
INPUT_REGISTRY: ${{ inputs.registry }}
|
||||
INPUT_CLAWHUB_VERSION: ${{ inputs.clawhub_version }}
|
||||
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"]
|
||||
ref = os.environ["INPUT_REF"].strip()
|
||||
if ref and "@" not in source and not source.startswith("http"):
|
||||
source = f"{source}@{ref}"
|
||||
|
||||
cmd = [
|
||||
"bunx",
|
||||
f"clawhub@{os.environ['INPUT_CLAWHUB_VERSION'].strip() or 'latest'}",
|
||||
"package",
|
||||
"publish",
|
||||
source,
|
||||
"--site",
|
||||
os.environ["INPUT_SITE"],
|
||||
"--registry",
|
||||
os.environ["INPUT_REGISTRY"],
|
||||
]
|
||||
|
||||
if os.environ["INPUT_DRY_RUN"] == "true":
|
||||
cmd.append("--dry-run")
|
||||
if os.environ["INPUT_JSON"] == "true":
|
||||
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]
|
||||
|
||||
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: |
|
||||
"$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
|
||||
@@ -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/clawdhub test
|
||||
bun run --cwd packages/clawdhub verify:build
|
||||
bun run --cwd packages/clawdhub test:artifact
|
||||
bun run --cwd packages/clawdhub verify
|
||||
```
|
||||
|
||||
`bun test packages/clawdhub/` 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/clawdhub 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).
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const packageRootPath = fileURLToPath(new URL('./packages/clawdhub/', import.meta.url))
|
||||
const distCliUrl = new URL('./packages/clawdhub/dist/cli.js', import.meta.url)
|
||||
const distCliPath = fileURLToPath(distCliUrl)
|
||||
const srcRootPath = fileURLToPath(new URL('./packages/clawdhub/src/', import.meta.url))
|
||||
@@ -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,6 +3,7 @@ import { existsSync } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const packageRootPath = fileURLToPath(new URL('./packages/clawdhub/', import.meta.url))
|
||||
const distCliUrl = new URL('./packages/clawdhub/dist/cli.js', import.meta.url)
|
||||
const distCliPath = fileURLToPath(distCliUrl)
|
||||
const srcRootPath = fileURLToPath(new URL('./packages/clawdhub/src/', import.meta.url))
|
||||
@@ -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
+2
@@ -66,6 +66,7 @@ import type * as lib_moderationReasonCodes from "../lib/moderationReasonCodes.js
|
||||
import type * as lib_openaiResponse from "../lib/openaiResponse.js";
|
||||
import type * as lib_packageRegistry from "../lib/packageRegistry.js";
|
||||
import type * as lib_packageSearchDigest from "../lib/packageSearchDigest.js";
|
||||
import type * as lib_packageSecurity from "../lib/packageSecurity.js";
|
||||
import type * as lib_public from "../lib/public.js";
|
||||
import type * as lib_publishLimits from "../lib/publishLimits.js";
|
||||
import type * as lib_publishers from "../lib/publishers.js";
|
||||
@@ -178,6 +179,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/openaiResponse": typeof lib_openaiResponse;
|
||||
"lib/packageRegistry": typeof lib_packageRegistry;
|
||||
"lib/packageSearchDigest": typeof lib_packageSearchDigest;
|
||||
"lib/packageSecurity": typeof lib_packageSecurity;
|
||||
"lib/public": typeof lib_public;
|
||||
"lib/publishLimits": typeof lib_publishLimits;
|
||||
"lib/publishers": typeof lib_publishers;
|
||||
|
||||
@@ -58,6 +58,13 @@ crons.interval("vt-cache-backfill", { minutes: 30 }, internal.vt.backfillActiveS
|
||||
batchSize: 100,
|
||||
});
|
||||
|
||||
crons.interval(
|
||||
"package-scan-backfill",
|
||||
{ minutes: 30 },
|
||||
internal.packages.backfillPackageReleaseScansInternal,
|
||||
{ batchSize: 100 },
|
||||
);
|
||||
|
||||
// Daily re-scan of all active skills at 3am UTC
|
||||
crons.daily("vt-daily-rescan", { hourUTC: 3, minuteUTC: 0 }, internal.vt.rescanActiveSkills, {});
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
listBundlePluginsV1Http,
|
||||
listCodePluginsV1Http,
|
||||
listPackagesV1Http,
|
||||
listPluginsV1Http,
|
||||
listSkillsV1Http,
|
||||
listSoulsV1Http,
|
||||
packagesGetRouterV1Http,
|
||||
pluginsGetRouterV1Http,
|
||||
publishSkillV1Http,
|
||||
publishPackageV1Http,
|
||||
publishSoulV1Http,
|
||||
@@ -74,6 +76,12 @@ http.route({
|
||||
handler: listPackagesV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.plugins,
|
||||
method: "GET",
|
||||
handler: listPluginsV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.codePlugins,
|
||||
method: "GET",
|
||||
@@ -98,6 +106,12 @@ http.route({
|
||||
handler: packagesGetRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
pathPrefix: `${ApiRoutes.plugins}/`,
|
||||
method: "GET",
|
||||
handler: pluginsGetRouterV1Http,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: ApiRoutes.skills,
|
||||
method: "POST",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { unzipSync } from "fflate";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { internal } from "./_generated/api";
|
||||
import { RATE_LIMITS } from "./lib/httpRateLimit";
|
||||
|
||||
vi.mock("@convex-dev/auth/server", () => ({
|
||||
getAuthUserId: vi.fn(),
|
||||
@@ -41,6 +42,10 @@ function hasSlugArgs(args: unknown): args is { slug: string } {
|
||||
return typeof value.slug === "string";
|
||||
}
|
||||
|
||||
function findRateLimitCallArgs(mock: ReturnType<typeof vi.fn>) {
|
||||
return mock.mock.calls.map(([, args]) => args).find(isRateLimitArgs);
|
||||
}
|
||||
|
||||
function makeCtx(partial: Record<string, unknown>) {
|
||||
const partialRunQuery =
|
||||
typeof partial.runQuery === "function"
|
||||
@@ -2294,13 +2299,10 @@ describe("httpApiV1 handlers", () => {
|
||||
capabilityTag: "tools",
|
||||
}),
|
||||
);
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: 120,
|
||||
}),
|
||||
);
|
||||
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: RATE_LIMITS.read.ip,
|
||||
});
|
||||
expect(response.headers.get("RateLimit-Limit")).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -2601,6 +2603,112 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("packages version detail returns security scan fields for plugins", async () => {
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args && !("version" in args)) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:demo-plugin",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: { latest: "packageReleases:1" },
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: { _id: "publishers:demo", handle: "demo" },
|
||||
};
|
||||
}
|
||||
if ("name" in args && "version" in args) {
|
||||
return {
|
||||
package: {
|
||||
_id: "packages:demo-plugin",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
_id: "packageReleases:1",
|
||||
packageId: "packages:demo-plugin",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "Initial release",
|
||||
distTags: ["latest"],
|
||||
files: [
|
||||
{
|
||||
path: "README.md",
|
||||
size: 10,
|
||||
sha256: "file-sha",
|
||||
storageId: "storage:1",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
verification: {
|
||||
tier: "source-linked",
|
||||
scope: "artifact-only",
|
||||
scanStatus: "clean",
|
||||
},
|
||||
sha256hash: "a".repeat(64),
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
checkedAt: 1,
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
summary: "Looks safe.",
|
||||
checkedAt: 1,
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
reasonCodes: [],
|
||||
findings: [],
|
||||
summary: "No issues",
|
||||
engineVersion: "1",
|
||||
checkedAt: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation }),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/versions/1.0.0"),
|
||||
);
|
||||
|
||||
if (response.status !== 200) throw new Error(await response.text());
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
package: {
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
},
|
||||
version: {
|
||||
version: "1.0.0",
|
||||
sha256hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
vtAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "benign",
|
||||
},
|
||||
llmAnalysis: {
|
||||
status: "clean",
|
||||
verdict: "clean",
|
||||
},
|
||||
staticScan: {
|
||||
status: "clean",
|
||||
summary: "No issues",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("treats /packages/search without q as a package detail route", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
@@ -2699,13 +2807,10 @@ describe("httpApiV1 handlers", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("RateLimit-Limit")).toBeTruthy();
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: RATE_LIMITS.download.ip,
|
||||
});
|
||||
});
|
||||
|
||||
it("package file uses read rate limiting", async () => {
|
||||
@@ -2762,13 +2867,66 @@ describe("httpApiV1 handlers", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("RateLimit-Limit")).toBeTruthy();
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: 120,
|
||||
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
|
||||
key: expect.stringMatching(/^ip:/),
|
||||
limit: RATE_LIMITS.read.ip,
|
||||
});
|
||||
});
|
||||
|
||||
it("package file resolves lowercase readme variants from the canonical request path", 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: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: null,
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
files: [
|
||||
{
|
||||
path: "readme.md",
|
||||
size: 5,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:1",
|
||||
contentType: "text/markdown",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: {
|
||||
get: vi.fn().mockResolvedValue(new Blob(["hello"], { type: "text/markdown" })),
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/file?path=README.md"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("hello");
|
||||
});
|
||||
|
||||
it("package download uses a package/ root without registry metadata", async () => {
|
||||
@@ -2905,7 +3063,7 @@ describe("httpApiV1 handlers", () => {
|
||||
expect(await response.text()).toBe("Missing stored file: dist/index.js");
|
||||
});
|
||||
|
||||
it("blocks package downloads while VT scan is pending", async () => {
|
||||
it("allows package downloads while VT scan is pending", async () => {
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if ("name" in args) {
|
||||
@@ -2933,19 +3091,86 @@ describe("httpApiV1 handlers", () => {
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
sha256hash: "a".repeat(64),
|
||||
files: [],
|
||||
files: [
|
||||
{
|
||||
path: "package.json",
|
||||
size: 2,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:1",
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const storageGet = vi.fn(async () => new Blob(['{"name":"demo-plugin"}']));
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation, storage: { get: storageGet } }),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/download"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("application/zip");
|
||||
expect(storageGet).toHaveBeenCalledWith("storage:1");
|
||||
});
|
||||
|
||||
it("allows package downloads when verification is clean even without cached vtAnalysis", 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: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
tags: {},
|
||||
latestReleaseId: "packageReleases:1",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
latestRelease: null,
|
||||
owner: null,
|
||||
};
|
||||
}
|
||||
if ("releaseId" in args) {
|
||||
return {
|
||||
_id: "packageReleases:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 1,
|
||||
changelog: "init",
|
||||
sha256hash: "a".repeat(64),
|
||||
verification: { scanStatus: "clean" },
|
||||
files: [
|
||||
{
|
||||
path: "package.json",
|
||||
size: 2,
|
||||
sha256: "a".repeat(64),
|
||||
storageId: "storage:1",
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const response = await __handlers.packagesGetRouterV1Handler(
|
||||
makeCtx({ runQuery, runMutation, storage: { get: vi.fn() } }),
|
||||
makeCtx({
|
||||
runQuery,
|
||||
runMutation,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(["{}"], { type: "application/json" })),
|
||||
},
|
||||
}),
|
||||
new Request("https://example.com/api/v1/packages/demo-plugin/download"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(423);
|
||||
expect(await response.text()).toContain("pending a security scan");
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("blocks package file access when release is malicious", async () => {
|
||||
@@ -3095,13 +3320,10 @@ describe("httpApiV1 handlers", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("RateLimit-Limit")).toBeTruthy();
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
key: "user:users:1",
|
||||
limit: 120,
|
||||
}),
|
||||
);
|
||||
expect(findRateLimitCallArgs(runMutation)).toMatchObject({
|
||||
key: "user:users:1",
|
||||
limit: RATE_LIMITS.write.key,
|
||||
});
|
||||
expect(runAction).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
listBundlePluginsV1Handler,
|
||||
listCodePluginsV1Handler,
|
||||
listPackagesV1Handler,
|
||||
listPluginsV1Handler,
|
||||
packagesGetRouterV1Handler,
|
||||
pluginsGetRouterV1Handler,
|
||||
publishPackageV1Handler,
|
||||
} from "./httpApiV1/packagesV1";
|
||||
import {
|
||||
@@ -28,7 +30,9 @@ import { usersListV1Handler, usersPostRouterV1Handler } from "./httpApiV1/usersV
|
||||
import { whoamiV1Handler } from "./httpApiV1/whoamiV1";
|
||||
|
||||
export const listPackagesV1Http = httpAction(listPackagesV1Handler);
|
||||
export const listPluginsV1Http = httpAction(listPluginsV1Handler);
|
||||
export const packagesGetRouterV1Http = httpAction(packagesGetRouterV1Handler);
|
||||
export const pluginsGetRouterV1Http = httpAction(pluginsGetRouterV1Handler);
|
||||
export const publishPackageV1Http = httpAction(publishPackageV1Handler);
|
||||
export const listCodePluginsV1Http = httpAction(listCodePluginsV1Handler);
|
||||
export const listBundlePluginsV1Http = httpAction(listBundlePluginsV1Handler);
|
||||
@@ -57,7 +61,9 @@ export const usersListV1Http = httpAction(usersListV1Handler);
|
||||
|
||||
export const __handlers = {
|
||||
listPackagesV1Handler,
|
||||
listPluginsV1Handler,
|
||||
packagesGetRouterV1Handler,
|
||||
pluginsGetRouterV1Handler,
|
||||
publishPackageV1Handler,
|
||||
listCodePluginsV1Handler,
|
||||
listBundlePluginsV1Handler,
|
||||
|
||||
+136
-101
@@ -5,6 +5,7 @@ import type { Doc, Id } from "../_generated/dataModel";
|
||||
import type { ActionCtx } from "../_generated/server";
|
||||
import { getOptionalApiTokenUserId } from "../lib/apiTokenAuth";
|
||||
import { corsHeaders, mergeHeaders } from "../lib/httpHeaders";
|
||||
import { getPackageDownloadSecurityBlock } from "../lib/packageSecurity";
|
||||
import { getPublishFileSizeError, MAX_PUBLISH_FILE_BYTES } from "../lib/publishLimits";
|
||||
import { applyRateLimit } from "../lib/httpRateLimit";
|
||||
import { buildDeterministicPackageZip } from "../lib/skillZip";
|
||||
@@ -139,24 +140,7 @@ function toVisibleRelease(release: ReleaseLike | null) {
|
||||
}
|
||||
|
||||
function getReleaseSecurityBlock(release: ReleaseLike) {
|
||||
if (
|
||||
release.vtAnalysis?.status === "malicious" ||
|
||||
release.verification?.scanStatus === "malicious" ||
|
||||
release.staticScan?.status === "malicious"
|
||||
) {
|
||||
return {
|
||||
status: 403,
|
||||
message: "Blocked: this package release has been flagged as malicious and cannot be downloaded.",
|
||||
};
|
||||
}
|
||||
const vtStatus = release.vtAnalysis?.status?.trim().toLowerCase();
|
||||
if (release.sha256hash && (!vtStatus || vtStatus === "pending")) {
|
||||
return {
|
||||
status: 423,
|
||||
message: "This package release is pending a security scan by VirusTotal. Please try again in a few minutes.",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
return getPackageDownloadSecurityBlock(release);
|
||||
}
|
||||
|
||||
async function resolvePackageTags(
|
||||
@@ -445,7 +429,12 @@ async function parseMultipartPackagePublish(ctx: ActionCtx, request: Request) {
|
||||
return parsePackagePublishBody({ ...payload, files });
|
||||
}
|
||||
|
||||
async function listPackages(ctx: ActionCtx, request: Request, family?: PackageListQueryArgs["family"]) {
|
||||
async function listPackages(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
family?: PackageListQueryArgs["family"],
|
||||
options?: { includeSkills?: boolean },
|
||||
) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
@@ -463,6 +452,7 @@ async function listPackages(ctx: ActionCtx, request: Request, family?: PackageLi
|
||||
(familyRaw === "skill" || familyRaw === "code-plugin" || familyRaw === "bundle-plugin"
|
||||
? familyRaw
|
||||
: undefined);
|
||||
const includeSkills = options?.includeSkills ?? effectiveFamily === undefined;
|
||||
const channel =
|
||||
channelRaw === "official" || channelRaw === "community" || channelRaw === "private"
|
||||
? channelRaw
|
||||
@@ -491,7 +481,7 @@ async function listPackages(ctx: ActionCtx, request: Request, family?: PackageLi
|
||||
);
|
||||
}
|
||||
|
||||
if (!effectiveFamily) {
|
||||
if (!effectiveFamily && includeSkills) {
|
||||
const packageSource = initCatalogSource(decodeUnifiedCatalogCursor(cursor).packages);
|
||||
const skillSource = initCatalogSource(decodeUnifiedCatalogCursor(cursor).skills);
|
||||
const pageSize = limit;
|
||||
@@ -588,7 +578,11 @@ async function listPackages(ctx: ActionCtx, request: Request, family?: PackageLi
|
||||
}
|
||||
|
||||
export async function listPackagesV1Handler(ctx: ActionCtx, request: Request) {
|
||||
return await listPackages(ctx, request);
|
||||
return await listPackages(ctx, request, undefined, { includeSkills: true });
|
||||
}
|
||||
|
||||
export async function listPluginsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
return await listPackages(ctx, request, undefined, { includeSkills: false });
|
||||
}
|
||||
|
||||
export async function listCodePluginsV1Handler(ctx: ActionCtx, request: Request) {
|
||||
@@ -686,6 +680,23 @@ function resolveSkillFilePath(version: SkillVersionLike, requestedPath: string)
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePackageFilePath(release: ReleaseLike, requestedPath: string) {
|
||||
const normalized = requestedPath.trim();
|
||||
const lower = normalized.toLowerCase();
|
||||
if (isReadmeVariantPath(normalized)) {
|
||||
return (
|
||||
release.files.find((file) => isReadmeVariantPath(file.path)) ??
|
||||
release.files.find((file) => file.path.toLowerCase() === lower) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
return (
|
||||
release.files.find((file) => file.path === normalized) ??
|
||||
release.files.find((file) => file.path.toLowerCase() === lower) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function getSkillDetailForRequest(ctx: ActionCtx, slug: string) {
|
||||
return (await runQueryRef(ctx, apiRefs.skills.getBySlug, { slug })) as
|
||||
| {
|
||||
@@ -725,96 +736,107 @@ async function getSkillVersionForRequest(
|
||||
})) as SkillVersionLike | null;
|
||||
}
|
||||
|
||||
export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/packages/");
|
||||
if (segments.length === 0) return text("Not found", 404);
|
||||
|
||||
const rateKind = segments[1] === "download" ? "download" : "read";
|
||||
const rate = await applyRateLimit(ctx, request, rateKind);
|
||||
async function searchPackages(
|
||||
ctx: ActionCtx,
|
||||
request: Request,
|
||||
options?: { includeSkills?: boolean },
|
||||
) {
|
||||
const rate = await applyRateLimit(ctx, request, "read");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
|
||||
const url = new URL(request.url);
|
||||
const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
|
||||
const queryText = url.searchParams.get("q")?.trim() ?? "";
|
||||
const limit = Math.max(1, Math.min(toOptionalNumber(url.searchParams.get("limit")) ?? 20, 100));
|
||||
const familyRaw = url.searchParams.get("family");
|
||||
const channelRaw = url.searchParams.get("channel");
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const family =
|
||||
familyRaw === "skill" || familyRaw === "code-plugin" || familyRaw === "bundle-plugin"
|
||||
? familyRaw
|
||||
: undefined;
|
||||
const channel =
|
||||
channelRaw === "official" || channelRaw === "community" || channelRaw === "private"
|
||||
? channelRaw
|
||||
: undefined;
|
||||
const isOfficial =
|
||||
isOfficialRaw === "true" ? true : isOfficialRaw === "false" ? false : undefined;
|
||||
const executesCode =
|
||||
executesCodeRaw === "true" ? true : executesCodeRaw === "false" ? false : undefined;
|
||||
const url = new URL(request.url);
|
||||
const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
|
||||
const queryText = url.searchParams.get("q")?.trim() ?? "";
|
||||
const limit = Math.max(1, Math.min(toOptionalNumber(url.searchParams.get("limit")) ?? 20, 100));
|
||||
const familyRaw = url.searchParams.get("family");
|
||||
const channelRaw = url.searchParams.get("channel");
|
||||
const isOfficialRaw = url.searchParams.get("isOfficial");
|
||||
const executesCodeRaw = url.searchParams.get("executesCode");
|
||||
const capabilityTag = url.searchParams.get("capabilityTag")?.trim() || undefined;
|
||||
const family =
|
||||
familyRaw === "skill" || familyRaw === "code-plugin" || familyRaw === "bundle-plugin"
|
||||
? familyRaw
|
||||
: undefined;
|
||||
const includeSkills = options?.includeSkills ?? family === undefined;
|
||||
const channel =
|
||||
channelRaw === "official" || channelRaw === "community" || channelRaw === "private"
|
||||
? channelRaw
|
||||
: undefined;
|
||||
const isOfficial =
|
||||
isOfficialRaw === "true" ? true : isOfficialRaw === "false" ? false : undefined;
|
||||
const executesCode =
|
||||
executesCodeRaw === "true" ? true : executesCodeRaw === "false" ? false : undefined;
|
||||
|
||||
let results: CatalogSearchEntry[];
|
||||
if (family === "skill") {
|
||||
results = await runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
let results: CatalogSearchEntry[];
|
||||
if (family === "skill") {
|
||||
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,
|
||||
});
|
||||
} else {
|
||||
const [packageResults, skillResults] = await Promise.all([
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, internalRefs.packages.searchForViewerInternal, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
});
|
||||
} else if (family) {
|
||||
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, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
viewerUserId: viewerUserId ?? undefined,
|
||||
}),
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
}),
|
||||
]);
|
||||
const seen = new Set<string>();
|
||||
results = [...packageResults, ...skillResults]
|
||||
.filter((entry) => {
|
||||
const key = `${entry.package.family}:${entry.package.name}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
compareCatalogItems(a.package, b.package),
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
return json({ results }, 200, rate.headers);
|
||||
}),
|
||||
runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
|
||||
query: queryText,
|
||||
limit,
|
||||
channel,
|
||||
isOfficial,
|
||||
executesCode,
|
||||
capabilityTag,
|
||||
}),
|
||||
]);
|
||||
const seen = new Set<string>();
|
||||
results = [...packageResults, ...skillResults]
|
||||
.filter((entry) => {
|
||||
const key = `${entry.package.family}:${entry.package.name}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
|
||||
compareCatalogItems(a.package, b.package),
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
return json({ results }, 200, rate.headers);
|
||||
}
|
||||
|
||||
export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/packages/");
|
||||
if (segments.length === 0) return text("Not found", 404);
|
||||
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
|
||||
return await searchPackages(ctx, request, { includeSkills: true });
|
||||
}
|
||||
|
||||
const rateKind = segments[1] === "download" ? "download" : "read";
|
||||
const rate = await applyRateLimit(ctx, request, rateKind);
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
const packageName = segments[0] ?? "";
|
||||
const viewerUserId = await getOptionalViewerUserIdForRequest(ctx, request);
|
||||
@@ -970,6 +992,10 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
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);
|
||||
}
|
||||
@@ -1002,7 +1028,7 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
if (!release) return text("Version not found", 404, rate.headers);
|
||||
const securityBlock = getReleaseSecurityBlock(release);
|
||||
if (securityBlock) return text(securityBlock.message, securityBlock.status, rate.headers);
|
||||
const file = release.files.find((entry) => entry.path === path);
|
||||
const file = resolvePackageFilePath(release, path);
|
||||
if (!file) 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);
|
||||
@@ -1065,6 +1091,15 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques
|
||||
return text("Not found", 404, rate.headers);
|
||||
}
|
||||
|
||||
export async function pluginsGetRouterV1Handler(ctx: ActionCtx, request: Request) {
|
||||
const segments = getPathSegments(request, "/api/v1/plugins/");
|
||||
if (segments.length === 0) return text("Not found", 404);
|
||||
if (segments[0] === "search" && new URL(request.url).searchParams.has("q")) {
|
||||
return await searchPackages(ctx, request, { includeSkills: false });
|
||||
}
|
||||
return text("Not found", 404);
|
||||
}
|
||||
|
||||
type PublicPackageDocLike = {
|
||||
_id: Id<"packages">;
|
||||
name: string;
|
||||
|
||||
@@ -5,9 +5,9 @@ import { corsHeaders, mergeHeaders } from "./httpHeaders";
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
export const RATE_LIMITS = {
|
||||
read: { ip: 120, key: 600 },
|
||||
write: { ip: 30, key: 120 },
|
||||
download: { ip: 20, key: 120 },
|
||||
read: { ip: 180, key: 900 },
|
||||
write: { ip: 45, key: 180 },
|
||||
download: { ip: 30, key: 180 },
|
||||
} as const;
|
||||
|
||||
type RateLimitResult = {
|
||||
|
||||
@@ -46,6 +46,7 @@ describe("packageRegistry", () => {
|
||||
|
||||
expect(result.runtimeId).toBe("demo.plugin");
|
||||
expect(result.compatibility?.pluginApiRange).toBe("^1.2.0");
|
||||
expect(result.compatibility?.minGatewayVersion).toBe("2026.3.0");
|
||||
expect(result.capabilities.executesCode).toBe(true);
|
||||
expect(result.capabilities.toolNames).toContain("demoTool");
|
||||
expect(result.verification.tier).toBe("source-linked");
|
||||
@@ -70,38 +71,60 @@ describe("packageRegistry", () => {
|
||||
).toThrow("source repo and commit");
|
||||
});
|
||||
|
||||
it("infers compatibility for legacy openclaw extension manifests", () => {
|
||||
const result = extractCodePluginArtifacts({
|
||||
packageName: "@openclaw/matrix",
|
||||
it("maps legacy minHostVersion to minGatewayVersion instead of pluginApiRange", () => {
|
||||
expect(() =>
|
||||
extractCodePluginArtifacts({
|
||||
packageName: "@openclaw/matrix",
|
||||
packageJson: {
|
||||
name: "@openclaw/matrix",
|
||||
version: "2026.3.13",
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
install: {
|
||||
npmSpec: "@openclaw/matrix",
|
||||
localPath: "extensions/matrix",
|
||||
defaultChoice: "npm",
|
||||
minHostVersion: "2026.3.13",
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginManifest: {
|
||||
id: "matrix",
|
||||
channels: ["matrix"],
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
source: {
|
||||
kind: "github",
|
||||
url: "https://github.com/openclaw/openclaw",
|
||||
repo: "openclaw/openclaw",
|
||||
ref: "refs/tags/v2026.3.13",
|
||||
commit: "abc123",
|
||||
path: "extensions/matrix",
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
).toThrow("package.json openclaw.compat.pluginApi is required");
|
||||
});
|
||||
|
||||
it("extracts legacy minHostVersion as minGatewayVersion while preserving build metadata", () => {
|
||||
const result = extractBundlePluginArtifacts({
|
||||
packageName: "@openclaw/matrix-bundle",
|
||||
packageJson: {
|
||||
name: "@openclaw/matrix",
|
||||
name: "@openclaw/matrix-bundle",
|
||||
version: "2026.3.13",
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
install: {
|
||||
npmSpec: "@openclaw/matrix",
|
||||
localPath: "extensions/matrix",
|
||||
defaultChoice: "npm",
|
||||
minHostVersion: "2026.3.13",
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginManifest: {
|
||||
id: "matrix",
|
||||
channels: ["matrix"],
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
source: {
|
||||
kind: "github",
|
||||
url: "https://github.com/openclaw/openclaw",
|
||||
repo: "openclaw/openclaw",
|
||||
ref: "refs/tags/v2026.3.13",
|
||||
commit: "abc123",
|
||||
path: "extensions/matrix",
|
||||
importedAt: Date.now(),
|
||||
bundleManifest: {
|
||||
hostTargets: ["openclaw"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.compatibility?.pluginApiRange).toBe(">=2026.3.13");
|
||||
expect(result.compatibility?.pluginApiRange).toBeUndefined();
|
||||
expect(result.compatibility?.minGatewayVersion).toBe("2026.3.13");
|
||||
expect(result.compatibility?.builtWithOpenClawVersion).toBe("2026.3.13");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
listMissingOpenClawExternalCodePluginFieldPaths,
|
||||
normalizeOpenClawExternalPluginCompatibility,
|
||||
} from "clawhub-schema";
|
||||
import type {
|
||||
BundlePublishMetadata,
|
||||
PackageCapabilitySummary,
|
||||
@@ -165,52 +169,8 @@ 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 peerDependencies = isRecord(packageJson?.peerDependencies)
|
||||
? packageJson.peerDependencies
|
||||
: undefined;
|
||||
const version =
|
||||
typeof packageJson?.version === "string" ? packageJson.version.trim() : undefined;
|
||||
const peerOpenClaw =
|
||||
typeof peerDependencies?.openclaw === "string" ? peerDependencies.openclaw.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();
|
||||
} else if (peerOpenClaw) {
|
||||
compatibility.pluginApiRange = peerOpenClaw;
|
||||
} else if (minHostVersion) {
|
||||
compatibility.pluginApiRange = minHostVersion;
|
||||
} else if (version) {
|
||||
compatibility.pluginApiRange = `>=${version}`;
|
||||
}
|
||||
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;
|
||||
return normalizeOpenClawExternalPluginCompatibility(packageJson);
|
||||
}
|
||||
|
||||
export function extractCodePluginArtifacts(params: {
|
||||
@@ -223,7 +183,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");
|
||||
@@ -234,11 +194,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([
|
||||
@@ -322,7 +280,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()) ||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getPackageDownloadSecurityBlock,
|
||||
isPackageBlockedFromPublic,
|
||||
resolvePackageReleaseScanStatus,
|
||||
} from "./packageSecurity";
|
||||
|
||||
describe("packageSecurity", () => {
|
||||
it("treats pending package scans as public", () => {
|
||||
expect(isPackageBlockedFromPublic("pending")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows package downloads while VT is pending", () => {
|
||||
expect(
|
||||
getPackageDownloadSecurityBlock({
|
||||
sha256hash: "a".repeat(64),
|
||||
} as never),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("still resolves sha256-only releases to pending", () => {
|
||||
expect(
|
||||
resolvePackageReleaseScanStatus({
|
||||
sha256hash: "a".repeat(64),
|
||||
} as never),
|
||||
).toBe("pending");
|
||||
});
|
||||
|
||||
it("still blocks malicious package releases", () => {
|
||||
expect(isPackageBlockedFromPublic("malicious")).toBe(true);
|
||||
expect(
|
||||
getPackageDownloadSecurityBlock({
|
||||
vtAnalysis: { status: "malicious" },
|
||||
} as never),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 403,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Doc } from "../_generated/dataModel";
|
||||
|
||||
export type PackageScanStatus = Doc<"packages">["scanStatus"];
|
||||
|
||||
type PackageReleaseSecurityLike = Pick<
|
||||
Doc<"packageReleases">,
|
||||
"sha256hash" | "vtAnalysis" | "verification" | "staticScan"
|
||||
>;
|
||||
|
||||
export function normalizePackageScanStatus(status: string | null | undefined): PackageScanStatus {
|
||||
switch (status?.trim().toLowerCase()) {
|
||||
case "clean":
|
||||
case "suspicious":
|
||||
case "malicious":
|
||||
case "pending":
|
||||
case "not-run":
|
||||
return status.trim().toLowerCase() as PackageScanStatus;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackageReleaseScanStatus(
|
||||
release: PackageReleaseSecurityLike,
|
||||
): Exclude<PackageScanStatus, undefined> {
|
||||
const staticStatus = normalizePackageScanStatus(release.staticScan?.status);
|
||||
if (staticStatus === "malicious") return "malicious";
|
||||
|
||||
const vtStatus = normalizePackageScanStatus(release.vtAnalysis?.status);
|
||||
if (vtStatus === "malicious") return "malicious";
|
||||
|
||||
const verificationStatus = normalizePackageScanStatus(release.verification?.scanStatus);
|
||||
if (verificationStatus === "malicious") return "malicious";
|
||||
|
||||
if (vtStatus) return vtStatus;
|
||||
if (verificationStatus && verificationStatus !== "not-run") return verificationStatus;
|
||||
if (release.sha256hash) return "pending";
|
||||
|
||||
return verificationStatus ?? "not-run";
|
||||
}
|
||||
|
||||
export function isPackageBlockedFromPublic(scanStatus: PackageScanStatus) {
|
||||
return scanStatus === "malicious";
|
||||
}
|
||||
|
||||
export function getPackageDownloadSecurityBlock(release: PackageReleaseSecurityLike) {
|
||||
const scanStatus = resolvePackageReleaseScanStatus(release);
|
||||
|
||||
if (scanStatus === "malicious") {
|
||||
return {
|
||||
status: 403,
|
||||
message: "Blocked: this package release has been flagged as malicious and cannot be downloaded.",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -97,6 +97,36 @@ export async function getPublisherByHandle(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserByHandleOrPersonalPublisher(
|
||||
ctx: DbCtx,
|
||||
handle: string | undefined | null,
|
||||
) {
|
||||
const normalized = normalizePublisherHandle(handle);
|
||||
if (!normalized) return null;
|
||||
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", normalized))
|
||||
.unique();
|
||||
if (user) return user;
|
||||
|
||||
const publisher = await getPublisherByHandle(ctx, normalized);
|
||||
if (!publisher || !isPublisherActive(publisher) || publisher.kind !== "user" || !publisher.linkedUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await ctx.db.get(publisher.linkedUserId);
|
||||
}
|
||||
|
||||
export async function getActiveUserByHandleOrPersonalPublisher(
|
||||
ctx: DbCtx,
|
||||
handle: string | undefined | null,
|
||||
) {
|
||||
const user = await getUserByHandleOrPersonalPublisher(ctx, handle);
|
||||
if (!user || user.deletedAt || user.deactivatedAt) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getPersonalPublisherForUser(
|
||||
ctx: DbCtx,
|
||||
userId: Id<"users">,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
backfillPackageReleaseScansInternal,
|
||||
getPackageReleaseScanBackfillBatchInternal,
|
||||
getByName,
|
||||
list,
|
||||
publishPackage,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
listPublicPage,
|
||||
listPageForViewerInternal,
|
||||
listVersions,
|
||||
updateReleaseStaticScanInternal,
|
||||
softDeletePackageInternal,
|
||||
searchForViewerInternal,
|
||||
searchPublic,
|
||||
@@ -173,6 +176,59 @@ const publishPackageForUserInternalHandler = (
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
const getPackageReleaseScanBackfillBatchInternalHandler = (
|
||||
getPackageReleaseScanBackfillBatchInternal as unknown as WrappedHandler<
|
||||
{
|
||||
cursor?: number;
|
||||
batchSize?: number;
|
||||
prioritizeRecent?: boolean;
|
||||
},
|
||||
{
|
||||
releases: Array<{
|
||||
releaseId: string;
|
||||
packageId: string;
|
||||
needsVt: boolean;
|
||||
needsLlm: boolean;
|
||||
needsStatic: boolean;
|
||||
}>;
|
||||
nextCursor: number;
|
||||
done: boolean;
|
||||
}
|
||||
>
|
||||
)._handler;
|
||||
const backfillPackageReleaseScansInternalHandler = (
|
||||
backfillPackageReleaseScansInternal as unknown as WrappedHandler<
|
||||
{
|
||||
cursor?: number;
|
||||
batchSize?: number;
|
||||
scheduled?: number;
|
||||
},
|
||||
{ scheduled: number; nextCursor: number; done: boolean }
|
||||
>
|
||||
)._handler;
|
||||
const updateReleaseStaticScanInternalHandler = (
|
||||
updateReleaseStaticScanInternal as unknown as WrappedHandler<
|
||||
{
|
||||
releaseId: string;
|
||||
staticScan: {
|
||||
status: "clean" | "suspicious" | "malicious";
|
||||
reasonCodes: string[];
|
||||
findings: Array<{
|
||||
code: string;
|
||||
severity: string;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
evidence: string;
|
||||
}>;
|
||||
summary: string;
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
};
|
||||
},
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
const softDeletePackageInternalHandler = (
|
||||
softDeletePackageInternal as unknown as WrappedHandler<
|
||||
{ userId: string; name: string },
|
||||
@@ -258,6 +314,8 @@ function makeDigestCtx(options: {
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
}>;
|
||||
exactPackages?: Array<Record<string, unknown>>;
|
||||
exactDigests?: Array<Record<string, unknown>>;
|
||||
publisherMemberships?: Record<string, "owner" | "admin" | "publisher">;
|
||||
}) {
|
||||
const pageByTable = new Map<
|
||||
@@ -326,6 +384,55 @@ function makeDigestCtx(options: {
|
||||
ctx: {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packages") {
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
indexName: string,
|
||||
builder?: (q: {
|
||||
eq: (field: string, value: string) => unknown;
|
||||
gte: (field: string, value: string) => unknown;
|
||||
lt: (field: string, value: string) => unknown;
|
||||
}) => unknown,
|
||||
) => {
|
||||
let matchedValue = "";
|
||||
let lowerBound = "";
|
||||
let upperBound = "";
|
||||
const queryBuilder = {
|
||||
eq: (_field: string, value: string) => {
|
||||
matchedValue = value;
|
||||
return queryBuilder;
|
||||
},
|
||||
gte: (_field: string, value: string) => {
|
||||
lowerBound = value;
|
||||
return queryBuilder;
|
||||
},
|
||||
lt: (_field: string, value: string) => {
|
||||
upperBound = value;
|
||||
return queryBuilder;
|
||||
},
|
||||
};
|
||||
builder?.(queryBuilder);
|
||||
if (indexName !== "by_name" && indexName !== "by_runtime_id") {
|
||||
throw new Error(`Unexpected packages index ${indexName}`);
|
||||
}
|
||||
const matches = (options.exactPackages ?? []).filter((pkg) =>
|
||||
indexName === "by_name"
|
||||
? matchedValue
|
||||
? String(pkg.normalizedName) === matchedValue
|
||||
: String(pkg.normalizedName) >= lowerBound && String(pkg.normalizedName) < upperBound
|
||||
: matchedValue
|
||||
? String(pkg.runtimeId) === matchedValue
|
||||
: String(pkg.runtimeId) >= lowerBound && String(pkg.runtimeId) < upperBound,
|
||||
);
|
||||
return {
|
||||
unique: vi.fn().mockResolvedValue(matches[0] ?? null),
|
||||
take: vi.fn().mockResolvedValue(matches),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(
|
||||
@@ -358,7 +465,64 @@ function makeDigestCtx(options: {
|
||||
),
|
||||
};
|
||||
}
|
||||
if (table !== "packageSearchDigest" && table !== "packageCapabilitySearchDigest") {
|
||||
if (table === "packageSearchDigest") {
|
||||
tableNames.push(table);
|
||||
return {
|
||||
withIndex: (
|
||||
indexName: string,
|
||||
builder?: (q: {
|
||||
eq: (field: string, value: string | undefined) => unknown;
|
||||
gte: (field: string, value: string) => unknown;
|
||||
lt: (field: string, value: string) => unknown;
|
||||
}) => unknown,
|
||||
) => {
|
||||
if (indexName === "by_package") {
|
||||
let packageId = "";
|
||||
const queryBuilder = {
|
||||
eq: (field: string, value: string | undefined) => {
|
||||
if (field === "packageId") packageId = value ?? "";
|
||||
return queryBuilder;
|
||||
},
|
||||
gte: () => queryBuilder,
|
||||
lt: () => queryBuilder,
|
||||
};
|
||||
builder?.(queryBuilder);
|
||||
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") {
|
||||
let lowerBound = "";
|
||||
let upperBound = "";
|
||||
const queryBuilder = {
|
||||
eq: () => queryBuilder,
|
||||
gte: (_field: string, value: string) => {
|
||||
lowerBound = value;
|
||||
return queryBuilder;
|
||||
},
|
||||
lt: (_field: string, value: string) => {
|
||||
upperBound = value;
|
||||
return queryBuilder;
|
||||
},
|
||||
};
|
||||
builder?.(queryBuilder);
|
||||
const matches = (options.exactDigests ?? []).filter((digest) =>
|
||||
indexName === "by_active_normalized_name"
|
||||
? String(digest.normalizedName) >= lowerBound &&
|
||||
String(digest.normalizedName) < upperBound
|
||||
: String(digest.runtimeId) >= lowerBound &&
|
||||
String(digest.runtimeId) < upperBound,
|
||||
);
|
||||
return {
|
||||
take: vi.fn().mockResolvedValue(matches),
|
||||
};
|
||||
}
|
||||
return withIndex(table, indexName);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table !== "packageCapabilitySearchDigest") {
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}
|
||||
tableNames.push(table);
|
||||
@@ -1016,6 +1180,134 @@ describe("packages public queries", () => {
|
||||
expect(result.map((entry) => entry.package.name)).toContain("demo-plugin");
|
||||
});
|
||||
|
||||
it("includes exact package-name matches before digest scanning", async () => {
|
||||
const exactPkg = makePackageDoc({
|
||||
_id: "packages:exact",
|
||||
name: "demo-plugin",
|
||||
normalizedName: "demo-plugin",
|
||||
});
|
||||
const exactDigest = makeDigest("demo-plugin", {
|
||||
packageId: "packages:exact",
|
||||
});
|
||||
const { ctx, paginate } = makeDigestCtx({
|
||||
pages: [],
|
||||
exactPackages: [exactPkg],
|
||||
exactDigests: [exactDigest],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "demo-plugin",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["demo-plugin"]);
|
||||
expect(paginate).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.db.query).toHaveBeenCalledWith("packageSearchDigest");
|
||||
});
|
||||
|
||||
it("includes exact runtime-id matches before digest scanning", async () => {
|
||||
const exactPkg = makePackageDoc({
|
||||
_id: "packages:runtime",
|
||||
name: "runtime-demo",
|
||||
normalizedName: "runtime-demo",
|
||||
runtimeId: "demo.plugin",
|
||||
});
|
||||
const exactDigest = makeDigest("runtime-demo", {
|
||||
packageId: "packages:runtime",
|
||||
runtimeId: "demo.plugin",
|
||||
});
|
||||
const { ctx, paginate } = makeDigestCtx({
|
||||
pages: [],
|
||||
exactPackages: [exactPkg],
|
||||
exactDigests: [exactDigest],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "demo.plugin",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["runtime-demo"]);
|
||||
expect(paginate).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.db.query).toHaveBeenCalledWith("packageSearchDigest");
|
||||
});
|
||||
|
||||
it("includes prefix package-name matches before digest scanning", async () => {
|
||||
const prefixPkg = makePackageDoc({
|
||||
_id: "packages:prefix",
|
||||
name: "demo-prefix",
|
||||
normalizedName: "demo-prefix",
|
||||
});
|
||||
const prefixDigest = makeDigest("demo-prefix", {
|
||||
packageId: "packages:prefix",
|
||||
});
|
||||
const { ctx, paginate } = makeDigestCtx({
|
||||
pages: [],
|
||||
exactPackages: [prefixPkg],
|
||||
exactDigests: [prefixDigest],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "demo",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["demo-prefix"]);
|
||||
expect(paginate).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.db.query).toHaveBeenCalledWith("packageSearchDigest");
|
||||
});
|
||||
|
||||
it("keeps spaced queries on the scan path without throwing", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("demo-plugin", {
|
||||
displayName: "Demo Plugin",
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "demo plugin",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["demo-plugin"]);
|
||||
});
|
||||
|
||||
it("skips publisher membership lookups for public search rows", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("demo-plugin", {
|
||||
ownerPublisherId: "publishers:org",
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
publisherMemberships: {
|
||||
"publishers:org": "publisher",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await searchForViewerInternalHandler(ctx, {
|
||||
query: "demo",
|
||||
limit: 10,
|
||||
viewerUserId: "users:member",
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["demo-plugin"]);
|
||||
expect(ctx.db.query).not.toHaveBeenCalledWith("publisherMembers");
|
||||
});
|
||||
|
||||
it("caps public list scans below the Convex read limit budget", async () => {
|
||||
const { ctx, paginate } = makeDigestCtx({
|
||||
pages: Array.from({ length: 120 }, (_, index) => ({
|
||||
@@ -1825,9 +2117,15 @@ describe("packages public queries", () => {
|
||||
reasonCodes: expect.arrayContaining(["suspicious.dangerous_exec"]),
|
||||
}),
|
||||
);
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
30_000,
|
||||
expect.anything(),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("hides pending-scan packages from public reads", async () => {
|
||||
it("keeps pending-scan packages visible to public reads", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue(null);
|
||||
const ctx = {
|
||||
db: {
|
||||
@@ -1848,7 +2146,7 @@ describe("packages public queries", () => {
|
||||
};
|
||||
|
||||
const result = await getByNameHandler(ctx as never, { name: "demo-plugin" });
|
||||
expect(result).toBeNull();
|
||||
expect(result?.package?.name).toBe("demo-plugin");
|
||||
});
|
||||
|
||||
it("keeps pending-scan packages visible to the owner", async () => {
|
||||
@@ -1995,3 +2293,247 @@ describe("packages public queries", () => {
|
||||
).rejects.toThrow("Unauthorized");
|
||||
});
|
||||
});
|
||||
|
||||
describe("package scan backfill", () => {
|
||||
it("includes releases missing static scan in the backfill batch", async () => {
|
||||
const result = await getPackageReleaseScanBackfillBatchInternalHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "packageReleases") throw new Error(`Unexpected table ${table}`);
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([
|
||||
{
|
||||
_id: "packageReleases:missing-static",
|
||||
_creationTime: 10,
|
||||
packageId: "packages:demo",
|
||||
sha256hash: "hash",
|
||||
vtAnalysis: { status: "clean" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: undefined,
|
||||
},
|
||||
{
|
||||
_id: "packageReleases:fully-scanned",
|
||||
_creationTime: 11,
|
||||
packageId: "packages:demo",
|
||||
sha256hash: "hash",
|
||||
vtAnalysis: { status: "clean" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
},
|
||||
]),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packages:demo") return makePackageDoc();
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
{ batchSize: 10 },
|
||||
);
|
||||
|
||||
expect(result.releases).toEqual([
|
||||
{
|
||||
releaseId: "packageReleases:missing-static",
|
||||
packageId: "packages:demo",
|
||||
needsVt: false,
|
||||
needsLlm: false,
|
||||
needsStatic: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("prioritizes recent releases before draining older backlog", async () => {
|
||||
const result = await getPackageReleaseScanBackfillBatchInternalHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "packageReleases") throw new Error(`Unexpected table ${table}`);
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([
|
||||
{
|
||||
_id: "packageReleases:recent-vt",
|
||||
_creationTime: 200,
|
||||
packageId: "packages:demo",
|
||||
sha256hash: "hash",
|
||||
vtAnalysis: undefined,
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
},
|
||||
]),
|
||||
})),
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn().mockResolvedValue([
|
||||
{
|
||||
_id: "packageReleases:old-static",
|
||||
_creationTime: 10,
|
||||
packageId: "packages:demo",
|
||||
sha256hash: "hash",
|
||||
vtAnalysis: { status: "clean" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: undefined,
|
||||
},
|
||||
]),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packages:demo") return makePackageDoc();
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
{ batchSize: 2, prioritizeRecent: true },
|
||||
);
|
||||
|
||||
expect(result.releases).toEqual([
|
||||
{
|
||||
releaseId: "packageReleases:recent-vt",
|
||||
packageId: "packages:demo",
|
||||
needsVt: true,
|
||||
needsLlm: false,
|
||||
needsStatic: false,
|
||||
},
|
||||
{
|
||||
releaseId: "packageReleases:old-static",
|
||||
packageId: "packages:demo",
|
||||
needsVt: false,
|
||||
needsLlm: false,
|
||||
needsStatic: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("schedules static rescans for releases missing only static scan data", async () => {
|
||||
const originalVtApiKey = process.env.VT_API_KEY;
|
||||
process.env.VT_API_KEY = "vt-test-key";
|
||||
|
||||
try {
|
||||
const runAfter = vi.fn().mockResolvedValue(undefined);
|
||||
const result = await backfillPackageReleaseScansInternalHandler(
|
||||
{
|
||||
runQuery: vi.fn().mockResolvedValue({
|
||||
releases: [
|
||||
{
|
||||
releaseId: "packageReleases:static-only",
|
||||
needsVt: false,
|
||||
needsLlm: false,
|
||||
needsStatic: true,
|
||||
},
|
||||
],
|
||||
nextCursor: 123,
|
||||
done: true,
|
||||
}),
|
||||
scheduler: { runAfter },
|
||||
} as never,
|
||||
{ batchSize: 10 },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ scheduled: 1, nextCursor: 123, done: true });
|
||||
expect(runAfter).toHaveBeenCalledTimes(1);
|
||||
expect(runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ releaseId: "packageReleases:static-only" }),
|
||||
);
|
||||
} finally {
|
||||
if (originalVtApiKey === undefined) {
|
||||
delete process.env.VT_API_KEY;
|
||||
} else {
|
||||
process.env.VT_API_KEY = originalVtApiKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("promotes latest package scan status when a static rescan finds malware", async () => {
|
||||
const patch = vi.fn().mockResolvedValue(undefined);
|
||||
const release = {
|
||||
_id: "packageReleases:demo-1",
|
||||
packageId: "packages:demo",
|
||||
verification: {
|
||||
tier: "source-linked",
|
||||
scope: "artifact-only",
|
||||
scanStatus: "pending",
|
||||
},
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
const pkg = {
|
||||
...makePackageDoc(),
|
||||
_id: "packages:demo",
|
||||
latestReleaseId: "packageReleases:demo-1",
|
||||
verification: {
|
||||
tier: "source-linked",
|
||||
scope: "artifact-only",
|
||||
scanStatus: "pending",
|
||||
},
|
||||
latestVersionSummary: {
|
||||
version: "1.0.0",
|
||||
verification: {
|
||||
tier: "source-linked",
|
||||
scope: "artifact-only",
|
||||
scanStatus: "pending",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await updateReleaseStaticScanInternalHandler(
|
||||
{
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packageReleases:demo-1") return release;
|
||||
if (id === "packages:demo") return pkg;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
patch,
|
||||
replace: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
releaseId: "packageReleases:demo-1",
|
||||
staticScan: {
|
||||
status: "malicious",
|
||||
reasonCodes: ["malware.test"],
|
||||
findings: [],
|
||||
summary: "Malware detected",
|
||||
engineVersion: "test",
|
||||
checkedAt: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"packageReleases:demo-1",
|
||||
expect.objectContaining({
|
||||
staticScan: expect.objectContaining({ status: "malicious" }),
|
||||
verification: expect.objectContaining({ scanStatus: "malicious" }),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"packages:demo",
|
||||
expect.objectContaining({
|
||||
scanStatus: "malicious",
|
||||
verification: expect.objectContaining({ scanStatus: "malicious" }),
|
||||
latestVersionSummary: expect.objectContaining({
|
||||
verification: expect.objectContaining({ scanStatus: "malicious" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+307
-96
@@ -34,25 +34,35 @@ import {
|
||||
import { getOwnerPublisher, getPublisherMembership } from "./lib/publishers";
|
||||
import { toPublicPublisher } from "./lib/public";
|
||||
import { runStaticPublishScan } from "./lib/staticPublishScan";
|
||||
import {
|
||||
isPackageBlockedFromPublic,
|
||||
resolvePackageReleaseScanStatus,
|
||||
} from "./lib/packageSecurity";
|
||||
import { tokenize } from "./lib/searchText";
|
||||
import { hashSkillFiles } from "./lib/skills";
|
||||
|
||||
const MAX_PACKAGE_SCAN_DOCUMENTS = 30_000;
|
||||
const MAX_PUBLIC_LIST_SCAN_PAGES = 200;
|
||||
const MAX_SEARCH_PAGE_SIZE = 200;
|
||||
const MAX_SEARCH_SCAN_PAGES = 200;
|
||||
const MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES = 20;
|
||||
const INITIAL_PACKAGE_VT_SCAN_DELAY_MS = 30_000;
|
||||
const internalRefs = internal as unknown as {
|
||||
llmEval: {
|
||||
evaluatePackageReleaseWithLlm: unknown;
|
||||
};
|
||||
packages: {
|
||||
backfillPackageReleaseScansInternal: unknown;
|
||||
scanPackageReleaseStaticallyInternal: unknown;
|
||||
insertReleaseInternal: unknown;
|
||||
getByNameForViewerInternal: unknown;
|
||||
getPackageByIdInternal: unknown;
|
||||
getReleaseByIdInternal: unknown;
|
||||
getPackageReleaseScanBackfillBatchInternal: unknown;
|
||||
listVersionsForViewerInternal: unknown;
|
||||
getVersionByNameForViewerInternal: unknown;
|
||||
publishPackageForUserInternal: unknown;
|
||||
updateReleaseStaticScanInternal: unknown;
|
||||
};
|
||||
skills: {
|
||||
getSkillBySlugInternal: unknown;
|
||||
@@ -152,19 +162,6 @@ async function runAfterRef(
|
||||
return await ctx.scheduler.runAfter(delayMs, ref as never, args as never);
|
||||
}
|
||||
|
||||
function toPackageScanStatus(status: string | undefined): Doc<"packages">["scanStatus"] {
|
||||
switch (status) {
|
||||
case "clean":
|
||||
case "suspicious":
|
||||
case "malicious":
|
||||
case "pending":
|
||||
case "not-run":
|
||||
return status;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type PublicPackageDoc = {
|
||||
_id: Id<"packages">;
|
||||
name: string;
|
||||
@@ -213,8 +210,10 @@ type DashboardPackageListItem = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
function isPackageBlockedFromPublic(scanStatus: Doc<"packages">["scanStatus"]) {
|
||||
return scanStatus === "pending" || scanStatus === "malicious";
|
||||
function requiresPrivilegedPackageAccess(
|
||||
digest: Pick<PackageDigestLike, "channel" | "scanStatus">,
|
||||
) {
|
||||
return digest.channel === "private" || isPackageBlockedFromPublic(digest.scanStatus);
|
||||
}
|
||||
|
||||
async function viewerCanAccessPackageOwner(
|
||||
@@ -240,6 +239,28 @@ async function viewerCanAccessPackageOwner(
|
||||
return await membershipPromise;
|
||||
}
|
||||
|
||||
async function canViewerReadPackage(
|
||||
ctx: DbReaderCtx,
|
||||
digest: Pick<
|
||||
PackageDigestLike,
|
||||
"channel" | "scanStatus" | "ownerUserId" | "ownerPublisherId"
|
||||
>,
|
||||
viewerUserId: Id<"users"> | undefined,
|
||||
membershipCache?: Map<string, Promise<boolean>>,
|
||||
) {
|
||||
if (!requiresPrivilegedPackageAccess(digest)) return true;
|
||||
const isPrivilegedViewer = await viewerCanAccessPackageOwner(
|
||||
ctx,
|
||||
digest,
|
||||
viewerUserId,
|
||||
membershipCache,
|
||||
);
|
||||
return (
|
||||
(digest.channel !== "private" || isPrivilegedViewer) &&
|
||||
(!isPackageBlockedFromPublic(digest.scanStatus) || isPrivilegedViewer)
|
||||
);
|
||||
}
|
||||
|
||||
function toPublicPackage(
|
||||
pkg: Doc<"packages"> | null | undefined,
|
||||
latestRelease?: Pick<Doc<"packageReleases">, "version" | "softDeletedAt"> | null,
|
||||
@@ -452,6 +473,7 @@ function packageSearchScore(digest: PackageDigestLike, queryText: string) {
|
||||
const needle = queryText.toLowerCase();
|
||||
const normalized = digest.normalizedName.toLowerCase();
|
||||
const display = digest.displayName.toLowerCase();
|
||||
const runtimeId = digest.runtimeId?.toLowerCase() ?? "";
|
||||
const summary = (digest.summary ?? "").toLowerCase();
|
||||
let score = 0;
|
||||
if (normalized === needle) score += 200;
|
||||
@@ -462,6 +484,10 @@ function packageSearchScore(digest: PackageDigestLike, queryText: string) {
|
||||
else if (display.startsWith(needle)) score += 70;
|
||||
else if (display.includes(needle)) score += 40;
|
||||
|
||||
if (runtimeId === needle) score += 180;
|
||||
else if (runtimeId.startsWith(needle)) score += 90;
|
||||
else if (runtimeId.includes(needle)) score += 45;
|
||||
|
||||
if (summary.includes(needle)) score += 20;
|
||||
if ((digest.capabilityTags ?? []).some((entry) => entry.toLowerCase().includes(needle))) {
|
||||
score += 12;
|
||||
@@ -470,6 +496,55 @@ function packageSearchScore(digest: PackageDigestLike, queryText: string) {
|
||||
return score;
|
||||
}
|
||||
|
||||
function prefixUpperBound(value: string) {
|
||||
return `${value}\uffff`;
|
||||
}
|
||||
|
||||
function maybeNormalizePackageQuery(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return normalizePackageName(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectPackageSearchDigests(
|
||||
ctx: DbReaderCtx,
|
||||
queryText: string,
|
||||
): Promise<PackageDigestLike[]> {
|
||||
const normalizedQuery = maybeNormalizePackageQuery(queryText);
|
||||
const queryTokens = tokenize(queryText).filter((token) => token.length > 1);
|
||||
const runtimePrefix = queryTokens.length === 1 ? queryTokens[0] : queryText;
|
||||
const [nameDigests, runtimeDigests] = await Promise.all([
|
||||
normalizedQuery
|
||||
? ctx.db
|
||||
.query("packageSearchDigest")
|
||||
.withIndex("by_active_normalized_name", (q) =>
|
||||
q.eq("softDeletedAt", undefined)
|
||||
.gte("normalizedName", normalizedQuery)
|
||||
.lt("normalizedName", prefixUpperBound(normalizedQuery)),
|
||||
)
|
||||
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
||||
: Promise.resolve([]),
|
||||
runtimePrefix
|
||||
? ctx.db
|
||||
.query("packageSearchDigest")
|
||||
.withIndex("by_active_runtime_id", (q) =>
|
||||
q.eq("softDeletedAt", undefined)
|
||||
.gte("runtimeId", runtimePrefix)
|
||||
.lt("runtimeId", prefixUpperBound(runtimePrefix)),
|
||||
)
|
||||
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
return [...nameDigests, ...runtimeDigests].filter(
|
||||
(digest, index, all) =>
|
||||
all.findIndex((candidate) => candidate?.packageId === digest?.packageId) === index,
|
||||
) as PackageDigestLike[];
|
||||
}
|
||||
|
||||
function buildPackageDigestQuery(
|
||||
ctx: DbReaderCtx,
|
||||
args: {
|
||||
@@ -704,9 +779,7 @@ async function getReadablePackageByName(
|
||||
const normalizedName = normalizePackageName(name);
|
||||
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
|
||||
if (!pkg || pkg.softDeletedAt) return null;
|
||||
const isPrivilegedViewer = await viewerCanAccessPackageOwner(ctx, pkg, viewerUserId);
|
||||
if (pkg.channel === "private" && !isPrivilegedViewer) return null;
|
||||
if (isPackageBlockedFromPublic(pkg.scanStatus) && !isPrivilegedViewer) return null;
|
||||
if (!(await canViewerReadPackage(ctx, pkg, viewerUserId))) return null;
|
||||
return pkg;
|
||||
}
|
||||
|
||||
@@ -917,18 +990,8 @@ async function listPackagePageImpl(
|
||||
}
|
||||
const viewerUserId = args.viewerUserId;
|
||||
const membershipCache = new Map<string, Promise<boolean>>();
|
||||
const canViewPackage = async (digest: PackageDigestLike) => {
|
||||
const isPrivilegedViewer = await viewerCanAccessPackageOwner(
|
||||
ctx,
|
||||
digest,
|
||||
viewerUserId,
|
||||
membershipCache,
|
||||
);
|
||||
return (
|
||||
(digest.channel !== "private" || isPrivilegedViewer) &&
|
||||
(!isPackageBlockedFromPublic(digest.scanStatus) || isPrivilegedViewer)
|
||||
);
|
||||
};
|
||||
const canViewPackage = async (digest: PackageDigestLike) =>
|
||||
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
|
||||
const targetCount = args.paginationOpts.numItems;
|
||||
const collected: PublicPackageListItem[] = [];
|
||||
const decodedCursor = decodePublicPageCursor(args.paginationOpts.cursor);
|
||||
@@ -1066,18 +1129,8 @@ async function searchPackagesImpl(
|
||||
const targetCount = Math.max(1, Math.min(args.limit ?? 20, 100));
|
||||
const viewerUserId = args.viewerUserId;
|
||||
const membershipCache = new Map<string, Promise<boolean>>();
|
||||
const canViewPackage = async (digest: PackageDigestLike) => {
|
||||
const isPrivilegedViewer = await viewerCanAccessPackageOwner(
|
||||
ctx,
|
||||
digest,
|
||||
viewerUserId,
|
||||
membershipCache,
|
||||
);
|
||||
return (
|
||||
(digest.channel !== "private" || isPrivilegedViewer) &&
|
||||
(!isPackageBlockedFromPublic(digest.scanStatus) || isPrivilegedViewer)
|
||||
);
|
||||
};
|
||||
const canViewPackage = async (digest: PackageDigestLike) =>
|
||||
await canViewerReadPackage(ctx, digest, viewerUserId, membershipCache);
|
||||
const builder = args.capabilityTag
|
||||
? buildPackageCapabilityDigestQuery(ctx, {
|
||||
capabilityTag: args.capabilityTag,
|
||||
@@ -1094,39 +1147,60 @@ async function searchPackagesImpl(
|
||||
});
|
||||
const matches: Array<{ score: number; package: PublicPackageListItem }> = [];
|
||||
const seen = new Set<string>();
|
||||
const pageSize = Math.min(MAX_SEARCH_PAGE_SIZE, Math.max(targetCount * 5, 50));
|
||||
let cursor: string | null = null;
|
||||
let done = false;
|
||||
let loops = 0;
|
||||
let remainingScanBudget = MAX_PACKAGE_SCAN_DOCUMENTS;
|
||||
const directDigests = args.capabilityTag
|
||||
? []
|
||||
: await resolveDirectPackageSearchDigests(ctx, queryText);
|
||||
for (const digest of directDigests) {
|
||||
if (!(await canViewPackage(digest))) continue;
|
||||
if (args.channel && digest.channel !== args.channel) continue;
|
||||
if (typeof args.isOfficial === "boolean" && digest.isOfficial !== args.isOfficial) {
|
||||
continue;
|
||||
}
|
||||
if (!digestMatchesFilters(digest, args)) continue;
|
||||
const score = packageSearchScore(digest, queryText);
|
||||
if (score <= 0 || seen.has(digest.packageId)) continue;
|
||||
seen.add(digest.packageId);
|
||||
matches.push({
|
||||
score,
|
||||
package: toPublicPackageListItem(digest),
|
||||
});
|
||||
}
|
||||
|
||||
while (!done && loops < MAX_SEARCH_SCAN_PAGES && remainingScanBudget > 0) {
|
||||
loops += 1;
|
||||
const effectivePageSize = Math.min(pageSize, remainingScanBudget);
|
||||
if (effectivePageSize <= 0) break;
|
||||
remainingScanBudget -= effectivePageSize;
|
||||
const page: {
|
||||
page: PackageDigestLike[];
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
} = await builder.order("desc").paginate({ cursor, numItems: effectivePageSize });
|
||||
for (const digest of page.page) {
|
||||
if (!(await canViewPackage(digest))) continue;
|
||||
if (args.channel && digest.channel !== args.channel) continue;
|
||||
if (typeof args.isOfficial === "boolean" && digest.isOfficial !== args.isOfficial) {
|
||||
continue;
|
||||
if (matches.length < targetCount) {
|
||||
const pageSize = Math.min(MAX_SEARCH_PAGE_SIZE, Math.max(targetCount * 5, 50));
|
||||
let cursor: string | null = null;
|
||||
let done = false;
|
||||
let loops = 0;
|
||||
let remainingScanBudget = MAX_PACKAGE_SCAN_DOCUMENTS;
|
||||
|
||||
while (!done && loops < MAX_SEARCH_SCAN_PAGES && remainingScanBudget > 0) {
|
||||
loops += 1;
|
||||
const effectivePageSize = Math.min(pageSize, remainingScanBudget);
|
||||
if (effectivePageSize <= 0) break;
|
||||
remainingScanBudget -= effectivePageSize;
|
||||
const page: {
|
||||
page: PackageDigestLike[];
|
||||
isDone: boolean;
|
||||
continueCursor: string;
|
||||
} = await builder.order("desc").paginate({ cursor, numItems: effectivePageSize });
|
||||
for (const digest of page.page) {
|
||||
if (!(await canViewPackage(digest))) continue;
|
||||
if (args.channel && digest.channel !== args.channel) continue;
|
||||
if (typeof args.isOfficial === "boolean" && digest.isOfficial !== args.isOfficial) {
|
||||
continue;
|
||||
}
|
||||
if (!digestMatchesFilters(digest, args)) continue;
|
||||
const score = packageSearchScore(digest, queryText);
|
||||
if (score <= 0 || seen.has(digest.packageId)) continue;
|
||||
seen.add(digest.packageId);
|
||||
matches.push({
|
||||
score,
|
||||
package: toPublicPackageListItem(digest),
|
||||
});
|
||||
}
|
||||
if (!digestMatchesFilters(digest, args)) continue;
|
||||
const score = packageSearchScore(digest, queryText);
|
||||
if (score <= 0 || seen.has(digest.packageId)) continue;
|
||||
seen.add(digest.packageId);
|
||||
matches.push({
|
||||
score,
|
||||
package: toPublicPackageListItem(digest),
|
||||
});
|
||||
done = page.isDone;
|
||||
cursor = page.continueCursor;
|
||||
}
|
||||
done = page.isDone;
|
||||
cursor = page.continueCursor;
|
||||
}
|
||||
|
||||
return matches
|
||||
@@ -1245,18 +1319,40 @@ export const getPackageReleaseScanBackfillBatchInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.number()),
|
||||
batchSize: v.optional(v.number()),
|
||||
prioritizeRecent: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = Math.max(1, Math.min(args.batchSize ?? 50, 200));
|
||||
const cursor = args.cursor ?? 0;
|
||||
const prioritizeRecent = args.prioritizeRecent ?? true;
|
||||
|
||||
const releases = await ctx.db
|
||||
.query("packageReleases")
|
||||
.withIndex("by_creation_time", (q) => q.gt("_creationTime", cursor))
|
||||
.order("asc")
|
||||
.take(batchSize * 3);
|
||||
const [recentReleases, backlogReleases] = await Promise.all([
|
||||
prioritizeRecent
|
||||
? ctx.db.query("packageReleases").order("desc").take(batchSize * 2)
|
||||
: Promise.resolve([]),
|
||||
ctx.db
|
||||
.query("packageReleases")
|
||||
.withIndex("by_creation_time", (q) => q.gt("_creationTime", cursor))
|
||||
.order("asc")
|
||||
.take(batchSize * 3),
|
||||
]);
|
||||
|
||||
const results: Array<{ releaseId: Id<"packageReleases">; packageId: Id<"packages"> }> = [];
|
||||
const releases = [
|
||||
...recentReleases,
|
||||
...backlogReleases.filter(
|
||||
(release, index, all) =>
|
||||
recentReleases.findIndex((candidate) => candidate._id === release._id) === -1 &&
|
||||
all.findIndex((candidate) => candidate._id === release._id) === index,
|
||||
),
|
||||
];
|
||||
|
||||
const results: Array<{
|
||||
releaseId: Id<"packageReleases">;
|
||||
packageId: Id<"packages">;
|
||||
needsVt: boolean;
|
||||
needsLlm: boolean;
|
||||
needsStatic: boolean;
|
||||
}> = [];
|
||||
let nextCursor = cursor;
|
||||
|
||||
for (const release of releases) {
|
||||
@@ -1269,18 +1365,22 @@ export const getPackageReleaseScanBackfillBatchInternal = internalQuery({
|
||||
|
||||
const needsVt = !release.sha256hash || !release.vtAnalysis;
|
||||
const needsLlm = !release.llmAnalysis || release.llmAnalysis.status === "error";
|
||||
if (!needsVt && !needsLlm) continue;
|
||||
const needsStatic = !release.staticScan;
|
||||
if (!needsVt && !needsLlm && !needsStatic) continue;
|
||||
|
||||
results.push({
|
||||
releaseId: release._id,
|
||||
packageId: release.packageId,
|
||||
needsVt,
|
||||
needsLlm,
|
||||
needsStatic,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
releases: results,
|
||||
nextCursor,
|
||||
done: releases.length < batchSize * 3,
|
||||
done: backlogReleases.length < batchSize * 3,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1432,7 +1532,7 @@ async function publishPackageImpl(
|
||||
},
|
||||
);
|
||||
|
||||
await runAfterRef(ctx, 0, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
await runAfterRef(ctx, INITIAL_PACKAGE_VT_SCAN_DELAY_MS, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: publishResult.releaseId,
|
||||
});
|
||||
await runAfterRef(ctx, 0, internalRefs.llmEval.evaluatePackageReleaseWithLlm, {
|
||||
@@ -1701,10 +1801,10 @@ function isReleaseActive(release: Doc<"packageReleases"> | null | undefined) {
|
||||
async function syncLatestPackageVerification(
|
||||
ctx: MutationCtx,
|
||||
release: Doc<"packageReleases">,
|
||||
scanStatus: Doc<"packages">["scanStatus"],
|
||||
) {
|
||||
const pkg = await ctx.db.get(release.packageId);
|
||||
if (!pkg || pkg.latestReleaseId !== release._id) return;
|
||||
const scanStatus = resolvePackageReleaseScanStatus(release);
|
||||
|
||||
const nextVerification = pkg.verification
|
||||
? {
|
||||
@@ -1752,7 +1852,10 @@ export const updateReleaseScanResultsInternal = internalMutation({
|
||||
const patch: Partial<Doc<"packageReleases">> = {};
|
||||
if (args.sha256hash !== undefined) patch.sha256hash = args.sha256hash;
|
||||
if (args.vtAnalysis !== undefined) {
|
||||
const nextScanStatus = toPackageScanStatus(args.vtAnalysis.status) ?? "pending";
|
||||
const nextScanStatus = resolvePackageReleaseScanStatus({
|
||||
...activeRelease,
|
||||
vtAnalysis: args.vtAnalysis,
|
||||
});
|
||||
patch.vtAnalysis = args.vtAnalysis;
|
||||
patch.verification = activeRelease.verification
|
||||
? {
|
||||
@@ -1765,12 +1868,7 @@ export const updateReleaseScanResultsInternal = internalMutation({
|
||||
await ctx.db.patch(args.releaseId, patch);
|
||||
}
|
||||
if (args.vtAnalysis !== undefined) {
|
||||
const nextScanStatus = toPackageScanStatus(args.vtAnalysis.status) ?? "pending";
|
||||
await syncLatestPackageVerification(
|
||||
ctx,
|
||||
{ ...activeRelease, ...patch } as Doc<"packageReleases">,
|
||||
nextScanStatus,
|
||||
);
|
||||
await syncLatestPackageVerification(ctx, { ...activeRelease, ...patch } as Doc<"packageReleases">);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1806,6 +1904,103 @@ export const updateReleaseLlmAnalysisInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const updateReleaseStaticScanInternal = internalMutation({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
staticScan: v.object({
|
||||
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
|
||||
reasonCodes: v.array(v.string()),
|
||||
findings: v.array(
|
||||
v.object({
|
||||
code: v.string(),
|
||||
severity: v.union(v.literal("info"), v.literal("warn"), v.literal("critical")),
|
||||
file: v.string(),
|
||||
line: v.number(),
|
||||
message: v.string(),
|
||||
evidence: v.string(),
|
||||
}),
|
||||
),
|
||||
summary: v.string(),
|
||||
engineVersion: v.string(),
|
||||
checkedAt: v.number(),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const release = await ctx.db.get(args.releaseId);
|
||||
if (!release || release.softDeletedAt) return;
|
||||
const activeRelease = release;
|
||||
|
||||
const patch: Partial<Doc<"packageReleases">> = {
|
||||
staticScan: args.staticScan,
|
||||
};
|
||||
if (activeRelease.verification) {
|
||||
const nextScanStatus = resolvePackageReleaseScanStatus({
|
||||
...activeRelease,
|
||||
staticScan: args.staticScan,
|
||||
});
|
||||
patch.verification = activeRelease.verification
|
||||
? {
|
||||
...activeRelease.verification,
|
||||
scanStatus: nextScanStatus,
|
||||
}
|
||||
: activeRelease.verification;
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.releaseId, patch);
|
||||
|
||||
await syncLatestPackageVerification(ctx, { ...activeRelease, ...patch } as Doc<"packageReleases">);
|
||||
},
|
||||
});
|
||||
|
||||
export const scanPackageReleaseStaticallyInternal = internalAction({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const release = await runQueryRef<Doc<"packageReleases"> | null>(
|
||||
ctx,
|
||||
internalRefs.packages.getReleaseByIdInternal,
|
||||
{ releaseId: args.releaseId },
|
||||
);
|
||||
if (!release || release.softDeletedAt) {
|
||||
return { ok: true as const, skipped: "missing_release" as const };
|
||||
}
|
||||
const activeRelease = release;
|
||||
|
||||
const pkg = await runQueryRef<Doc<"packages"> | null>(
|
||||
ctx,
|
||||
internalRefs.packages.getPackageByIdInternal,
|
||||
{ packageId: activeRelease.packageId },
|
||||
);
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
||||
return { ok: true as const, skipped: "missing_package" as const };
|
||||
}
|
||||
|
||||
const staticScan = await runStaticPublishScan(ctx, {
|
||||
slug: pkg.name,
|
||||
displayName: pkg.displayName,
|
||||
summary: pkg.summary,
|
||||
metadata: {
|
||||
packageJson: activeRelease.extractedPackageJson,
|
||||
pluginManifest: activeRelease.extractedPluginManifest,
|
||||
bundleManifest: activeRelease.normalizedBundleManifest,
|
||||
source: activeRelease.source,
|
||||
},
|
||||
files: activeRelease.files,
|
||||
});
|
||||
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseStaticScanInternal, {
|
||||
releaseId: args.releaseId,
|
||||
staticScan,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
status: staticScan.status,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const backfillPackageReleaseScansInternal = internalAction({
|
||||
args: {
|
||||
cursor: v.optional(v.number()),
|
||||
@@ -1817,20 +2012,36 @@ export const backfillPackageReleaseScansInternal = internalAction({
|
||||
const batch = (await runQueryRef(ctx, internalRefs.packages.getPackageReleaseScanBackfillBatchInternal, {
|
||||
cursor: args.cursor,
|
||||
batchSize,
|
||||
prioritizeRecent: args.cursor === undefined,
|
||||
})) as {
|
||||
releases: Array<{ releaseId: Id<"packageReleases"> }>;
|
||||
releases: Array<{
|
||||
releaseId: Id<"packageReleases">;
|
||||
needsVt: boolean;
|
||||
needsLlm: boolean;
|
||||
needsStatic: boolean;
|
||||
}>;
|
||||
nextCursor: number;
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
let scheduled = args.scheduled ?? 0;
|
||||
const vtEnabled = Boolean(process.env.VT_API_KEY);
|
||||
for (const release of batch.releases) {
|
||||
await runAfterRef(ctx, 0, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: release.releaseId,
|
||||
});
|
||||
await runAfterRef(ctx, 0, internalRefs.llmEval.evaluatePackageReleaseWithLlm, {
|
||||
releaseId: release.releaseId,
|
||||
});
|
||||
if (release.needsVt && vtEnabled) {
|
||||
await runAfterRef(ctx, 0, internalRefs.vt.scanPackageReleaseWithVirusTotal, {
|
||||
releaseId: release.releaseId,
|
||||
});
|
||||
}
|
||||
if (release.needsLlm) {
|
||||
await runAfterRef(ctx, 0, internalRefs.llmEval.evaluatePackageReleaseWithLlm, {
|
||||
releaseId: release.releaseId,
|
||||
});
|
||||
}
|
||||
if (release.needsStatic) {
|
||||
await runAfterRef(ctx, 0, internalRefs.packages.scanPackageReleaseStaticallyInternal, {
|
||||
releaseId: release.releaseId,
|
||||
});
|
||||
}
|
||||
scheduled += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,189 @@ describe("publishers membership controls", () => {
|
||||
),
|
||||
).rejects.toThrow("Publisher must have at least one owner");
|
||||
});
|
||||
|
||||
it("adds a member when the requested handle resolves via a personal publisher", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
||||
const publisherMembers: Array<Record<string, unknown>> = [
|
||||
{
|
||||
_id: "publisherMembers:owner",
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:owner",
|
||||
role: "owner",
|
||||
},
|
||||
];
|
||||
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
||||
if (table === "publisherMembers") {
|
||||
const row = { _id: "publisherMembers:new", ...value };
|
||||
publisherMembers.push(row);
|
||||
return row._id;
|
||||
}
|
||||
if (table === "auditLogs") return "auditLogs:1";
|
||||
if (table === "publishers") return "publishers:jaredforreal";
|
||||
throw new Error(`unexpected insert ${table}`);
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:owner") return { _id: id };
|
||||
if (id === "users:jared") {
|
||||
return {
|
||||
_id: id,
|
||||
_creationTime: 1,
|
||||
handle: undefined,
|
||||
name: "JaredForReal",
|
||||
displayName: "Jared",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
if (id === "publishers:org") {
|
||||
return {
|
||||
_id: id,
|
||||
kind: "org",
|
||||
handle: "zai-org",
|
||||
displayName: "ZAI Org",
|
||||
};
|
||||
}
|
||||
if (id === "publishers:jaredforreal") {
|
||||
return {
|
||||
_id: id,
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
linkedUserId: "users:jared",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
if (indexName !== "by_publisher_user") {
|
||||
throw new Error(`unexpected index ${indexName}`);
|
||||
}
|
||||
let publisherId = "";
|
||||
let userId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "publisherId") publisherId = value;
|
||||
if (field === "userId") userId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () =>
|
||||
publisherMembers.find(
|
||||
(member) => member.publisherId === publisherId && member.userId === userId,
|
||||
) ?? null,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
if (indexName !== "handle") {
|
||||
throw new Error(`unexpected index ${indexName}`);
|
||||
}
|
||||
let handle = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
if (handle === "owner") return { _id: "users:owner", handle: "owner" };
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: vi.fn((indexName: string, builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown) => {
|
||||
let handle = "";
|
||||
let linkedUserId = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
if (field === "linkedUserId") linkedUserId = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
return {
|
||||
unique: vi.fn(async () => {
|
||||
if (indexName === "by_handle" && handle === "jaredforreal") {
|
||||
return {
|
||||
_id: "publishers:jaredforreal",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
linkedUserId: "users:jared",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
if (indexName === "by_linked_user" && linkedUserId === "users:jared") {
|
||||
return {
|
||||
_id: "publishers:jaredforreal",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
linkedUserId: "users:jared",
|
||||
trustedPublisher: false,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
insert,
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
normalizeId: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
addMemberHandler(
|
||||
ctx as never,
|
||||
{ publisherId: "publishers:org", userHandle: "jaredforreal", role: "admin" } as never,
|
||||
),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"publisherMembers",
|
||||
expect.objectContaining({
|
||||
publisherId: "publishers:org",
|
||||
userId: "users:jared",
|
||||
role: "admin",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publisher bootstrap", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, requireUser } from "./lib/access";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPublisherByHandle,
|
||||
getPublisherMembership,
|
||||
getPersonalPublisherForUserOrFallback,
|
||||
@@ -584,11 +585,8 @@ export const addMember = mutation({
|
||||
}
|
||||
const handle = normalizePublisherHandle(args.userHandle);
|
||||
if (!handle) throw new ConvexError("User handle is required");
|
||||
const targetUser = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", handle))
|
||||
.unique();
|
||||
if (!targetUser || targetUser.deletedAt || targetUser.deactivatedAt) {
|
||||
const targetUser = await getActiveUserByHandleOrPersonalPublisher(ctx, handle);
|
||||
if (!targetUser) {
|
||||
throw new ConvexError(`User "@${handle}" not found`);
|
||||
}
|
||||
await ensurePersonalPublisherForUser(ctx, targetUser);
|
||||
|
||||
@@ -808,6 +808,8 @@ const packageSearchDigest = defineTable({
|
||||
"executesCode",
|
||||
"updatedAt",
|
||||
])
|
||||
.index("by_active_normalized_name", ["softDeletedAt", "normalizedName", "updatedAt"])
|
||||
.index("by_active_runtime_id", ["softDeletedAt", "runtimeId", "updatedAt"])
|
||||
.index("by_active_name", ["softDeletedAt", "displayName"]);
|
||||
|
||||
const packageCapabilitySearchDigest = defineTable({
|
||||
|
||||
+236
-3
@@ -46,8 +46,8 @@ describe("search helpers", () => {
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
// With incremental hydration, empty vector results skip the hydrate call entirely.
|
||||
const runQuery = vi.fn().mockResolvedValueOnce(fallback); // lexicalFallbackSkills (only call)
|
||||
// Slug-like queries now do an indexed exact-slug lookup before lexical fallback.
|
||||
const runQuery = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(fallback);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
@@ -183,6 +183,7 @@ describe("search helpers", () => {
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
|
||||
.mockResolvedValueOnce(vectorEntries) // hydrateResults
|
||||
.mockResolvedValueOnce(fallbackEntries); // lexicalFallbackSkills
|
||||
|
||||
@@ -204,6 +205,235 @@ describe("search helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("always includes an exact slug match even when vector exact matches already fill the limit", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const vectorEntries = Array.from({ length: 10 }, (_, index) => ({
|
||||
embeddingId: `skillEmbeddings:${index}`,
|
||||
skill: makePublicSkill({
|
||||
id: `skills:${index}`,
|
||||
slug: `downloader-${index}`,
|
||||
displayName: `Downloader ${index}`,
|
||||
downloads: 100 - index,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
}));
|
||||
|
||||
const exactSlugEntry = {
|
||||
skill: makePublicSkill({
|
||||
id: "skills:exact",
|
||||
slug: "skill-downloader",
|
||||
displayName: "Skill Downloader",
|
||||
downloads: 1,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "yyang100",
|
||||
owner: null,
|
||||
};
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(exactSlugEntry)
|
||||
.mockResolvedValueOnce(vectorEntries);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue(
|
||||
vectorEntries.map((entry, index) => ({ _id: entry.embeddingId, _score: 0.9 - index * 0.01 })),
|
||||
),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "skill-downloader", limit: 10 },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(10);
|
||||
expect(result[0].skill.slug).toBe("skill-downloader");
|
||||
expect(runQuery).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("omits exact slug injection when nonSuspiciousOnly excludes it", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const vectorEntries = [
|
||||
{
|
||||
embeddingId: "skillEmbeddings:1",
|
||||
skill: makePublicSkill({
|
||||
id: "skills:1",
|
||||
slug: "downloader-1",
|
||||
displayName: "Downloader 1",
|
||||
downloads: 50,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(vectorEntries)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([{ _id: "skillEmbeddings:1", _score: 0.9 }]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "skill-downloader", limit: 10, nonSuspiciousOnly: true },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].skill.slug).toBe("downloader-1");
|
||||
});
|
||||
|
||||
it("omits exact slug injection when highlightedOnly excludes it", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const exactSlugEntry = {
|
||||
skill: makePublicSkill({
|
||||
id: "skills:exact",
|
||||
slug: "skill-downloader",
|
||||
displayName: "Skill Downloader",
|
||||
downloads: 1,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "yyang100",
|
||||
owner: null,
|
||||
};
|
||||
|
||||
const vectorEntries = [
|
||||
{
|
||||
embeddingId: "skillEmbeddings:1",
|
||||
skill: {
|
||||
...makePublicSkill({
|
||||
id: "skills:1",
|
||||
slug: "downloader-1",
|
||||
displayName: "Downloader 1",
|
||||
downloads: 50,
|
||||
}),
|
||||
badges: { highlighted: { byUserId: "users:mod", at: 1 } },
|
||||
},
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(exactSlugEntry)
|
||||
.mockResolvedValueOnce(vectorEntries)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([{ _id: "skillEmbeddings:1", _score: 0.9 }]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "skill-downloader", limit: 10, highlightedOnly: true },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].skill.slug).toBe("downloader-1");
|
||||
});
|
||||
|
||||
it("deduplicates exact slug injection against vector exact matches", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const sharedSkill = makePublicSkill({
|
||||
id: "skills:exact",
|
||||
slug: "skill-downloader",
|
||||
displayName: "Skill Downloader",
|
||||
downloads: 100,
|
||||
});
|
||||
const exactSlugEntry = {
|
||||
skill: sharedSkill,
|
||||
version: null,
|
||||
ownerHandle: "yyang100",
|
||||
owner: null,
|
||||
};
|
||||
const vectorEntries = [
|
||||
{
|
||||
embeddingId: "skillEmbeddings:exact",
|
||||
skill: sharedSkill,
|
||||
version: null,
|
||||
ownerHandle: "yyang100",
|
||||
owner: null,
|
||||
},
|
||||
{
|
||||
embeddingId: "skillEmbeddings:other",
|
||||
skill: makePublicSkill({
|
||||
id: "skills:other",
|
||||
slug: "downloader-2",
|
||||
displayName: "Downloader 2",
|
||||
downloads: 50,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(exactSlugEntry)
|
||||
.mockResolvedValueOnce(vectorEntries)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([
|
||||
{ _id: "skillEmbeddings:exact", _score: 0.95 },
|
||||
{ _id: "skillEmbeddings:other", _score: 0.8 },
|
||||
]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "skill-downloader", limit: 10 },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.filter((entry) => entry.skill._id === "skills:exact")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips duplicate slug lookup inside lexical fallback when search action already did it", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
|
||||
const fallbackEntries = [
|
||||
{
|
||||
skill: makePublicSkill({
|
||||
id: "skills:orf",
|
||||
slug: "orf",
|
||||
displayName: "ORF",
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "steipete",
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockImplementationOnce(async (_ref: unknown, args: { skipExactSlugLookup?: boolean }) => {
|
||||
expect(args.skipExactSlugLookup).toBe(true);
|
||||
return fallbackEntries;
|
||||
});
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "orf", limit: 10 },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].skill.slug).toBe("orf");
|
||||
});
|
||||
|
||||
it("filters suspicious vector results in hydrateResults when requested", async () => {
|
||||
const result = await hydrateResultsHandler(
|
||||
{
|
||||
@@ -525,7 +755,10 @@ describe("search helpers", () => {
|
||||
|
||||
const hydrateCalls: string[][] = [];
|
||||
const runQuery = vi.fn(
|
||||
async (_ref: unknown, args: { embeddingIds?: string[]; query?: string }) => {
|
||||
async (_ref: unknown, args: { embeddingIds?: string[]; query?: string; slug?: string }) => {
|
||||
if (args.slug) {
|
||||
return null; // getExactSkillSlugMatch
|
||||
}
|
||||
if (args.embeddingIds) {
|
||||
hydrateCalls.push(args.embeddingIds);
|
||||
return args.embeddingIds.map((embeddingId: string) => ({
|
||||
|
||||
+51
-4
@@ -116,6 +116,10 @@ function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearch
|
||||
return out;
|
||||
}
|
||||
|
||||
function isSlugLikeQuery(query: string) {
|
||||
return /^[a-z0-9][a-z0-9-]*$/.test(query.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export const searchSkills: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
query: v.string(),
|
||||
@@ -128,6 +132,17 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
if (!query) 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 exactSlugMatch =
|
||||
rawExactSlugMatch && (!args.highlightedOnly || isSkillHighlighted(rawExactSlugMatch.skill))
|
||||
? rawExactSlugMatch
|
||||
: null;
|
||||
let vector: number[];
|
||||
try {
|
||||
vector = await generateEmbedding(query);
|
||||
@@ -192,8 +207,12 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
candidateLimit = nextLimit;
|
||||
}
|
||||
|
||||
const primaryMatches = exactSlugMatch
|
||||
? mergeUniqueBySkillId([exactSlugMatch], exactMatches)
|
||||
: exactMatches;
|
||||
|
||||
const fallbackMatches =
|
||||
exactMatches.length >= limit
|
||||
primaryMatches.length >= limit
|
||||
? []
|
||||
: ((await ctx.runQuery(internal.search.lexicalFallbackSkills, {
|
||||
query,
|
||||
@@ -201,9 +220,9 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
limit: Math.min(Math.max(limit * 4, 200), FALLBACK_SCAN_LIMIT),
|
||||
highlightedOnly: args.highlightedOnly,
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
||||
skipExactSlugLookup: true,
|
||||
})) as SkillSearchEntry[]);
|
||||
|
||||
const mergedMatches = mergeUniqueBySkillId(exactMatches, fallbackMatches);
|
||||
const mergedMatches = mergeUniqueBySkillId(primaryMatches, fallbackMatches);
|
||||
|
||||
return mergedMatches
|
||||
.map((entry) => {
|
||||
@@ -225,6 +244,33 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
},
|
||||
});
|
||||
|
||||
export const getExactSkillSlugMatch = internalQuery({
|
||||
args: {
|
||||
slug: v.string(),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry | null> => {
|
||||
const skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
||||
.unique();
|
||||
if (!skill || skill.softDeletedAt) return null;
|
||||
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null;
|
||||
|
||||
const getOwnerInfo = makeOwnerInfoGetter(ctx);
|
||||
const resolved = await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
if (!publicSkill || !resolved.owner) return null;
|
||||
|
||||
return {
|
||||
skill: publicSkill,
|
||||
version: null,
|
||||
ownerHandle: resolved.ownerHandle,
|
||||
owner: resolved.owner,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const hydrateResults = internalQuery({
|
||||
args: {
|
||||
embeddingIds: v.array(v.id("skillEmbeddings")),
|
||||
@@ -285,6 +331,7 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
limit: v.optional(v.number()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
skipExactSlugLookup: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
||||
const limit = Math.min(Math.max(args.limit ?? 200, 10), FALLBACK_SCAN_LIMIT);
|
||||
@@ -298,7 +345,7 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
|
||||
// Exact slug match via the skills table (only one row, cheap).
|
||||
const slugQuery = args.query.trim().toLowerCase();
|
||||
if (/^[a-z0-9][a-z0-9-]*$/.test(slugQuery)) {
|
||||
if (!args.skipExactSlugLookup && /^[a-z0-9][a-z0-9-]*$/.test(slugQuery)) {
|
||||
const exactSlugSkill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slugQuery))
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("skillTransfers", () => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
first: async () => ({ _id: "users:2", handle: "alice", displayName: "Alice" }),
|
||||
unique: async () => ({ _id: "users:2", handle: "alice", displayName: "Alice" }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -102,6 +102,242 @@ describe("skillTransfers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("requestTransferInternal resolves recipient via personal publisher handle", async () => {
|
||||
const insert = vi.fn(async (table: string) => {
|
||||
if (table === "skillOwnershipTransfers") return "skillOwnershipTransfers:new";
|
||||
return "auditLogs:1";
|
||||
});
|
||||
|
||||
const result = (await requestTransferInternalHandler(
|
||||
{
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:1") return { _id: "users:1", handle: "owner" };
|
||||
if (id === "users:2") {
|
||||
return {
|
||||
_id: "users:2",
|
||||
handle: undefined,
|
||||
name: "Alice",
|
||||
displayName: "Alice",
|
||||
};
|
||||
}
|
||||
if (id === "skills:1") {
|
||||
return {
|
||||
_id: "skills:1",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
ownerUserId: "users:1",
|
||||
};
|
||||
}
|
||||
if (id === "publishers:alice") {
|
||||
return {
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
displayName: "Alice",
|
||||
linkedUserId: "users:2",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
unique: async () => ({
|
||||
_id: "publishers:alice",
|
||||
kind: "user",
|
||||
handle: "alice",
|
||||
displayName: "Alice",
|
||||
linkedUserId: "users:2",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "skillOwnershipTransfers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
collect: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch: vi.fn(async () => {}),
|
||||
insert,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
actorUserId: "users:1",
|
||||
skillId: "skills:1",
|
||||
toUserHandle: "@alice",
|
||||
} as never,
|
||||
)) as { ok: boolean; transferId: string };
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
transferId: "skillOwnershipTransfers:new",
|
||||
toUserHandle: "alice",
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"skillOwnershipTransfers",
|
||||
expect.objectContaining({
|
||||
toUserId: "users:2",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("acceptTransferInternal updates skill and alias ownership to the recipient publisher", async () => {
|
||||
const patch = vi.fn(async () => {});
|
||||
const insert = vi.fn(async () => "auditLogs:1");
|
||||
const newPublisher = {
|
||||
_id: "publishers:alice",
|
||||
handle: "alice",
|
||||
displayName: "Alice",
|
||||
linkedUserId: "users:2",
|
||||
trustedPublisher: false,
|
||||
};
|
||||
const existingMember = {
|
||||
_id: "publisherMembers:1",
|
||||
publisherId: "publishers:alice",
|
||||
userId: "users:2",
|
||||
role: "owner",
|
||||
};
|
||||
const aliases = [
|
||||
{
|
||||
_id: "skillSlugAliases:1",
|
||||
slug: "demo-old",
|
||||
skillId: "skills:1",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:owner",
|
||||
},
|
||||
{
|
||||
_id: "skillSlugAliases:2",
|
||||
slug: "demo-legacy",
|
||||
skillId: "skills:1",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:owner",
|
||||
},
|
||||
];
|
||||
|
||||
const result = (await acceptTransferInternalHandler(
|
||||
{
|
||||
db: {
|
||||
normalizeId: vi.fn(),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:2") {
|
||||
return {
|
||||
_id: "users:2",
|
||||
handle: "alice",
|
||||
personalPublisherId: "publishers:alice",
|
||||
trustedPublisher: false,
|
||||
};
|
||||
}
|
||||
if (id === "skillOwnershipTransfers:1") {
|
||||
return {
|
||||
_id: "skillOwnershipTransfers:1",
|
||||
skillId: "skills:1",
|
||||
fromUserId: "users:1",
|
||||
toUserId: "users:2",
|
||||
status: "pending",
|
||||
requestedAt: Date.now() - 1_000,
|
||||
expiresAt: Date.now() + 10_000,
|
||||
};
|
||||
}
|
||||
if (id === "skills:1") {
|
||||
return {
|
||||
_id: "skills:1",
|
||||
slug: "demo",
|
||||
ownerUserId: "users:1",
|
||||
ownerPublisherId: "publishers:owner",
|
||||
};
|
||||
}
|
||||
if (id === "publishers:alice") {
|
||||
return newPublisher;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "skillSlugAliases") {
|
||||
return {
|
||||
withIndex: (indexName: string) => {
|
||||
expect(indexName).toBe("by_skill");
|
||||
return {
|
||||
collect: async () => aliases,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (indexName: string) => {
|
||||
expect(indexName).toBe("by_handle");
|
||||
return {
|
||||
unique: async () => newPublisher,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: (indexName: string) => {
|
||||
expect(indexName).toBe("by_publisher_user");
|
||||
return {
|
||||
unique: async () => existingMember,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
patch,
|
||||
insert,
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
actorUserId: "users:2",
|
||||
transferId: "skillOwnershipTransfers:1",
|
||||
} as never,
|
||||
)) as { ok: boolean; skillSlug: string };
|
||||
|
||||
expect(result).toEqual({ ok: true, skillSlug: "demo" });
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skills:1",
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:2",
|
||||
ownerPublisherId: "publishers:alice",
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillSlugAliases:1",
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:2",
|
||||
ownerPublisherId: "publishers:alice",
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillSlugAliases:2",
|
||||
expect.objectContaining({
|
||||
ownerUserId: "users:2",
|
||||
ownerPublisherId: "publishers:alice",
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"skillOwnershipTransfers:1",
|
||||
expect.objectContaining({ status: "accepted" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("acceptTransferInternal cancels stale transfer when ownership changed", async () => {
|
||||
const patch = vi.fn(async () => {});
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { v } from "convex/values";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { internalMutation, internalQuery } from "./functions";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
const TRANSFER_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type TransferDoc = Doc<"skillOwnershipTransfers">;
|
||||
@@ -111,11 +115,8 @@ export const requestTransferInternal = internalMutation({
|
||||
const toHandle = normalizeHandle(args.toUserHandle);
|
||||
if (!toHandle) throw new Error("toUserHandle required");
|
||||
|
||||
const toUser = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", toHandle))
|
||||
.first();
|
||||
if (!toUser || toUser.deletedAt || toUser.deactivatedAt) throw new Error("User not found");
|
||||
const toUser = await getActiveUserByHandleOrPersonalPublisher(ctx, toHandle);
|
||||
if (!toUser) throw new Error("User not found");
|
||||
if (toUser._id === args.actorUserId) throw new Error("Cannot transfer to yourself");
|
||||
|
||||
const activePending = await getActivePendingTransferForSkill(ctx, args.skillId, now);
|
||||
@@ -157,7 +158,7 @@ export const acceptTransferInternal = internalMutation({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const now = Date.now();
|
||||
await requireActiveUserById(ctx, args.actorUserId);
|
||||
const newOwner = await requireActiveUserById(ctx, args.actorUserId);
|
||||
|
||||
const transfer = await validatePendingTransferForActor(ctx, {
|
||||
transferId: args.transferId,
|
||||
@@ -173,10 +174,27 @@ export const acceptTransferInternal = internalMutation({
|
||||
throw new Error("Transfer is no longer valid");
|
||||
}
|
||||
|
||||
const newPublisher = await ensurePersonalPublisherForUser(ctx, newOwner);
|
||||
if (!newPublisher) throw new Error("Failed to resolve publisher for new owner");
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
ownerUserId: args.actorUserId,
|
||||
ownerPublisherId: newPublisher._id,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const aliases = await ctx.db
|
||||
.query("skillSlugAliases")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", skill._id))
|
||||
.collect();
|
||||
for (const alias of aliases) {
|
||||
await ctx.db.patch(alias._id, {
|
||||
ownerUserId: args.actorUserId,
|
||||
ownerPublisherId: newPublisher._id,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.patch(transfer._id, { status: "accepted", respondedAt: now });
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
|
||||
@@ -37,6 +37,7 @@ function makeCtx() {
|
||||
slug: "padel",
|
||||
displayName: "Padel",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:local",
|
||||
latestVersionId: "skillVersions:1",
|
||||
manualOverride: {
|
||||
verdict: "clean",
|
||||
@@ -103,6 +104,15 @@ function makeCtx() {
|
||||
switch (id) {
|
||||
case "skillVersions:1":
|
||||
return latestVersion;
|
||||
case "publishers:local":
|
||||
return {
|
||||
_id: "publishers:local",
|
||||
_creationTime: 1,
|
||||
kind: "user",
|
||||
handle: "local-publisher",
|
||||
displayName: "Local Dev",
|
||||
linkedUserId: "users:owner",
|
||||
};
|
||||
case "users:owner":
|
||||
return {
|
||||
_id: "users:owner",
|
||||
@@ -150,7 +160,7 @@ describe("getBySlugForStaff audit logs", () => {
|
||||
vi.mocked(requireUser).mockReset();
|
||||
});
|
||||
|
||||
it("returns reviewer info and recent audit logs with actor handles", async () => {
|
||||
it("returns publisher-backed owner info plus recent audit logs with actor handles", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
@@ -162,6 +172,7 @@ describe("getBySlugForStaff audit logs", () => {
|
||||
slug: "padel",
|
||||
auditLogLimit: 5,
|
||||
})) as {
|
||||
owner: { handle?: string | null } | null;
|
||||
overrideReviewer: { handle?: string | null } | null;
|
||||
auditLogs: Array<{
|
||||
actor: { handle?: string | null } | null;
|
||||
@@ -171,6 +182,7 @@ describe("getBySlugForStaff audit logs", () => {
|
||||
|
||||
expect(getSkillBadgeMap).toHaveBeenCalled();
|
||||
expect(auditTake).toHaveBeenCalledWith(5);
|
||||
expect(result.owner?.handle).toBe("local-publisher");
|
||||
expect(result.overrideReviewer?.handle).toBe("moddy");
|
||||
expect(result.auditLogs).toHaveLength(2);
|
||||
expect(result.auditLogs[0]?.action).toBe("skill.manual_override.set");
|
||||
|
||||
+21
-7
@@ -1632,7 +1632,11 @@ export const getBySlugForStaff = query({
|
||||
if (!skill) return null;
|
||||
|
||||
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null;
|
||||
const owner = toPublicUser(await ctx.db.get(skill.ownerUserId));
|
||||
const ownerPublisher = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
});
|
||||
const owner = toPublicPublisher(ownerPublisher);
|
||||
const badges = await getSkillBadgeMap(ctx, skill._id);
|
||||
const rawAuditLogs = await ctx.db
|
||||
.query("auditLogs")
|
||||
@@ -1659,10 +1663,20 @@ export const getBySlugForStaff = query({
|
||||
}));
|
||||
|
||||
const forkOfSkill = skill.forkOf?.skillId ? await ctx.db.get(skill.forkOf.skillId) : null;
|
||||
const forkOfOwner = forkOfSkill ? await ctx.db.get(forkOfSkill.ownerUserId) : null;
|
||||
const forkOfOwner = forkOfSkill
|
||||
? await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: forkOfSkill.ownerPublisherId,
|
||||
ownerUserId: forkOfSkill.ownerUserId,
|
||||
})
|
||||
: null;
|
||||
|
||||
const canonicalSkill = skill.canonicalSkillId ? await ctx.db.get(skill.canonicalSkillId) : null;
|
||||
const canonicalOwner = canonicalSkill ? await ctx.db.get(canonicalSkill.ownerUserId) : null;
|
||||
const canonicalOwner = canonicalSkill
|
||||
? await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: canonicalSkill.ownerPublisherId,
|
||||
ownerUserId: canonicalSkill.ownerUserId,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
requestedSlug: resolved.requestedSlug,
|
||||
@@ -1681,8 +1695,8 @@ export const getBySlugForStaff = query({
|
||||
displayName: forkOfSkill.displayName,
|
||||
},
|
||||
owner: {
|
||||
handle: forkOfOwner?.handle ?? forkOfOwner?.name ?? null,
|
||||
userId: forkOfOwner?._id ?? null,
|
||||
handle: forkOfOwner?.handle ?? null,
|
||||
userId: forkOfOwner?.linkedUserId ?? null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
@@ -1693,8 +1707,8 @@ export const getBySlugForStaff = query({
|
||||
displayName: canonicalSkill.displayName,
|
||||
},
|
||||
owner: {
|
||||
handle: canonicalOwner?.handle ?? canonicalOwner?.name ?? null,
|
||||
userId: canonicalOwner?._id ?? null,
|
||||
handle: canonicalOwner?.handle ?? null,
|
||||
userId: canonicalOwner?.linkedUserId ?? null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -18,6 +18,7 @@ const { getAuthUserId } = await import("@convex-dev/auth/server");
|
||||
const { insertStatEvent } = await import("./skillStatEvents");
|
||||
const {
|
||||
ensureHandler,
|
||||
getByHandle,
|
||||
list,
|
||||
searchInternal,
|
||||
banUserInternal,
|
||||
@@ -32,6 +33,9 @@ type WrappedHandler<TArgs, TResult> = {
|
||||
};
|
||||
|
||||
const meHandler = (me as unknown as WrappedHandler<Record<string, never>, unknown>)._handler;
|
||||
const getByHandleHandler = (
|
||||
getByHandle as unknown as WrappedHandler<{ handle: string }, unknown>
|
||||
)._handler;
|
||||
|
||||
function makeCtx() {
|
||||
const patch = vi.fn();
|
||||
@@ -500,6 +504,168 @@ describe("me", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.getByHandle", () => {
|
||||
it("normalizes the incoming handle before querying", async () => {
|
||||
const unique = vi.fn(async () => ({
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "jaredforreal",
|
||||
name: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
image: undefined,
|
||||
bio: undefined,
|
||||
}));
|
||||
|
||||
const result = await getByHandleHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== "users") throw new Error(`Unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (
|
||||
name: string,
|
||||
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
||||
) => {
|
||||
if (name !== "handle") throw new Error(`Unexpected index ${name}`);
|
||||
let handle = "";
|
||||
const q = {
|
||||
eq: (field: string, value: string) => {
|
||||
if (field === "handle") handle = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
builder?.(q);
|
||||
expect(handle).toBe("jaredforreal");
|
||||
return { unique };
|
||||
},
|
||||
};
|
||||
}),
|
||||
get: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
{ handle: " @JaredForReal " },
|
||||
);
|
||||
|
||||
expect(unique).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({
|
||||
_id: "users:owner",
|
||||
handle: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the linked user for a personal publisher handle", async () => {
|
||||
const userUnique = vi.fn(async () => null);
|
||||
const publisherUnique = vi.fn(async () => ({
|
||||
_id: "publishers:jaredforreal",
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
linkedUserId: "users:owner",
|
||||
displayName: "Jared",
|
||||
}));
|
||||
const get = vi.fn(async (id: string) =>
|
||||
id === "users:owner"
|
||||
? {
|
||||
_id: "users:owner",
|
||||
_creationTime: 1,
|
||||
handle: "jared",
|
||||
name: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
image: undefined,
|
||||
bio: "Profile",
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const result = await getByHandleHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
||||
return { unique: userUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_handle") throw new Error(`Unexpected publishers index ${name}`);
|
||||
return { unique: publisherUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
get,
|
||||
},
|
||||
} as never,
|
||||
{ handle: "jaredforreal" },
|
||||
);
|
||||
|
||||
expect(userUnique).toHaveBeenCalledOnce();
|
||||
expect(publisherUnique).toHaveBeenCalledOnce();
|
||||
expect(get).toHaveBeenCalledWith("users:owner");
|
||||
expect(result).toMatchObject({
|
||||
_id: "users:owner",
|
||||
handle: "jared",
|
||||
name: "jaredforreal",
|
||||
displayName: "Jared",
|
||||
bio: "Profile",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resolve a deleted personal publisher handle", async () => {
|
||||
const userUnique = vi.fn(async () => null);
|
||||
const publisherUnique = vi.fn(async () => ({
|
||||
_id: "publishers:jaredforreal",
|
||||
kind: "user",
|
||||
handle: "jaredforreal",
|
||||
linkedUserId: "users:owner",
|
||||
deletedAt: 1_700_000_000_000,
|
||||
displayName: "Jared",
|
||||
}));
|
||||
const get = vi.fn(async () => {
|
||||
throw new Error("linked user should not be loaded for inactive publishers");
|
||||
});
|
||||
|
||||
const result = await getByHandleHandler(
|
||||
{
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "users") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
||||
return { unique: userUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: (name: string) => {
|
||||
if (name !== "by_handle") throw new Error(`Unexpected publishers index ${name}`);
|
||||
return { unique: publisherUnique };
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
get,
|
||||
},
|
||||
} as never,
|
||||
{ handle: "jaredforreal" },
|
||||
);
|
||||
|
||||
expect(userUnique).toHaveBeenCalledOnce();
|
||||
expect(publisherUnique).toHaveBeenCalledOnce();
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("users.syncGitHubProfileInternal", () => {
|
||||
it("keeps a derived handle unchanged when the new login is reserved", async () => {
|
||||
const { ctx, get, patch, query } = makeCtx();
|
||||
|
||||
+8
-12
@@ -6,7 +6,12 @@ import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery, mutation, query } from "./functions";
|
||||
import { assertAdmin, assertModerator, requireUser } from "./lib/access";
|
||||
import { syncGitHubProfile } from "./lib/githubAccount";
|
||||
import { ensurePersonalPublisherForUser, getPublisherByHandle } from "./lib/publishers";
|
||||
import {
|
||||
ensurePersonalPublisherForUser,
|
||||
getActiveUserByHandleOrPersonalPublisher,
|
||||
getPublisherByHandle,
|
||||
getUserByHandleOrPersonalPublisher,
|
||||
} from "./lib/publishers";
|
||||
import { toPublicUser } from "./lib/public";
|
||||
import {
|
||||
getLatestActiveReservedHandle,
|
||||
@@ -36,12 +41,7 @@ export const getByIdInternal = internalQuery({
|
||||
export const getByHandleInternal = internalQuery({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const normalizedHandle = normalizeReservedHandle(args.handle);
|
||||
if (!normalizedHandle) return null;
|
||||
return await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", normalizedHandle))
|
||||
.unique();
|
||||
return await getUserByHandleOrPersonalPublisher(ctx, args.handle);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -396,11 +396,7 @@ function clampInt(value: number, min: number, max: number) {
|
||||
export const getByHandle = query({
|
||||
args: { handle: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.withIndex("handle", (q) => q.eq("handle", args.handle))
|
||||
.unique();
|
||||
return toPublicUser(user);
|
||||
return toPublicUser(await getActiveUserByHandleOrPersonalPublisher(ctx, args.handle));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+512
-2
@@ -1,5 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test } from "./vt";
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { __test, pollPackageReleaseScanResults, scanPackageReleaseWithVirusTotal } from "./vt";
|
||||
|
||||
type WrappedHandler<TArgs, TResult> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const scanPackageReleaseWithVirusTotalHandler = (
|
||||
scanPackageReleaseWithVirusTotal as unknown as WrappedHandler<
|
||||
{ releaseId: string; attempt?: number },
|
||||
void
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const pollPackageReleaseScanResultsHandler = (
|
||||
pollPackageReleaseScanResults as unknown as WrappedHandler<
|
||||
{ releaseId: string; attempt?: number },
|
||||
void
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const originalVtApiKey = process.env.VT_API_KEY;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalVtApiKey === undefined) {
|
||||
delete process.env.VT_API_KEY;
|
||||
} else {
|
||||
process.env.VT_API_KEY = originalVtApiKey;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("vt activation fallback", () => {
|
||||
it("activates only VT-pending hidden skills", () => {
|
||||
@@ -100,3 +132,481 @@ describe("vt AV engine fallback verdicts", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("package VT retries", () => {
|
||||
it("retries package scan when release files are not readable yet", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
|
||||
await scanPackageReleaseWithVirusTotalHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
}),
|
||||
runMutation: vi.fn(async () => null),
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => null),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 2 },
|
||||
);
|
||||
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
});
|
||||
|
||||
it("retries package upload when VT upload fails", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("", { status: 404 }))
|
||||
.mockResolvedValueOnce(new Response("rate limited", { status: 429 }));
|
||||
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",
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
sha256hash: expect.any(String),
|
||||
}),
|
||||
);
|
||||
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 () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 1,
|
||||
harmless: 10,
|
||||
undetected: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
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: "clean" },
|
||||
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).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({ status: "suspicious", source: "engines" }),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("promotes official source-linked packages with undetected-only VT stats 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
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).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({
|
||||
status: "clean",
|
||||
source: "engines-undetected-fallback",
|
||||
verdict: "undetected-only-fallback",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("promotes community source-linked packages with undetected-only VT stats 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
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: "clean" },
|
||||
files: [{ path: "package.json", storageId: "storage:pkg" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
isOfficial: false,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
storage: {
|
||||
get: vi.fn(async () => new Blob(['{"name":"demo-plugin"}'], { type: "application/json" })),
|
||||
},
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo" },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({
|
||||
status: "clean",
|
||||
source: "engines-undetected-fallback",
|
||||
verdict: "undetected-only-fallback",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries package poll when VT lookup throws", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network error")));
|
||||
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await pollPackageReleaseScanResultsHandler(
|
||||
{
|
||||
runQuery: vi.fn().mockResolvedValue({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
sha256hash: "abc123",
|
||||
}),
|
||||
runMutation: vi.fn(async () => null),
|
||||
scheduler,
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
|
||||
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 () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await pollPackageReleaseScanResultsHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
sha256hash: "abc123",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "suspicious" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
family: "code-plugin",
|
||||
isOfficial: true,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
} as never,
|
||||
{ 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();
|
||||
});
|
||||
|
||||
it("applies the same undetected-only fallback during community package polling", async () => {
|
||||
process.env.VT_API_KEY = "test-key";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
attributes: {
|
||||
last_analysis_stats: {
|
||||
malicious: 0,
|
||||
suspicious: 0,
|
||||
harmless: 0,
|
||||
undetected: 66,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await pollPackageReleaseScanResultsHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
sha256hash: "abc123",
|
||||
verification: { tier: "source-linked" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
family: "code-plugin",
|
||||
isOfficial: false,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
releaseId: "packageReleases:demo",
|
||||
vtAnalysis: expect.objectContaining({
|
||||
status: "clean",
|
||||
source: "engines-undetected-fallback",
|
||||
verdict: "undetected-only-fallback",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(scheduler.runAfter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not promote undetected-only community packages without trusted verification", 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 () => ({}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runMutation = vi.fn(async () => null);
|
||||
const scheduler = { runAfter: vi.fn(async () => null) };
|
||||
await pollPackageReleaseScanResultsHandler(
|
||||
{
|
||||
runQuery: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo",
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
sha256hash: "abc123",
|
||||
verification: { tier: "artifact-only" },
|
||||
llmAnalysis: { status: "clean" },
|
||||
staticScan: { status: "clean" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packages:demo",
|
||||
family: "code-plugin",
|
||||
isOfficial: false,
|
||||
}),
|
||||
runMutation,
|
||||
scheduler,
|
||||
} as never,
|
||||
{ releaseId: "packageReleases:demo", attempt: 3 },
|
||||
);
|
||||
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(scheduler.runAfter).toHaveBeenCalledWith(
|
||||
5 * 60 * 1000,
|
||||
expect.anything(),
|
||||
{ releaseId: "packageReleases:demo", attempt: 4 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+112
-40
@@ -12,6 +12,7 @@ const internalRefs = internal as unknown as {
|
||||
updateReleaseScanResultsInternal: unknown;
|
||||
};
|
||||
vt: {
|
||||
scanPackageReleaseWithVirusTotal: unknown;
|
||||
pollPackageReleaseScanResults: unknown;
|
||||
};
|
||||
};
|
||||
@@ -159,6 +160,70 @@ type VTFileResponse = {
|
||||
};
|
||||
|
||||
type VTAnalysisStats = NonNullable<VTFileResponse["data"]["attributes"]["last_analysis_stats"]>;
|
||||
type PackageReleaseScanDoc = Pick<
|
||||
Doc<"packageReleases">,
|
||||
"verification" | "llmAnalysis" | "staticScan"
|
||||
>;
|
||||
type PackageScanDoc = Pick<Doc<"packages">, "family" | "isOfficial">;
|
||||
|
||||
function buildPackageUndetectedFallbackAnalysis(
|
||||
release: PackageReleaseScanDoc,
|
||||
pkg: PackageScanDoc,
|
||||
stats?: VTAnalysisStats,
|
||||
) {
|
||||
if (!stats) return null;
|
||||
if (pkg.family === "skill") return null;
|
||||
|
||||
const tier = release.verification?.tier;
|
||||
if (tier !== "source-linked" && tier !== "provenance-verified" && tier !== "rebuild-verified") {
|
||||
return null;
|
||||
}
|
||||
if (release.llmAnalysis?.status !== "clean") return null;
|
||||
if (!release.staticScan || release.staticScan.status === "malicious") return null;
|
||||
if (stats.malicious !== 0 || stats.suspicious !== 0) return null;
|
||||
if ((stats.harmless ?? 0) <= 0 && (stats.undetected ?? 0) <= 0) return null;
|
||||
|
||||
return {
|
||||
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.",
|
||||
source: "engines-undetected-fallback",
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPackageScanAnalysisFromVtResult(
|
||||
release: PackageReleaseScanDoc,
|
||||
pkg: PackageScanDoc,
|
||||
vtResult: VTFileResponse,
|
||||
) {
|
||||
const aiResult = vtResult.data.attributes.crowdsourced_ai_results?.find(
|
||||
(r) => r.category === "code_insight",
|
||||
);
|
||||
if (aiResult) {
|
||||
const verdict = normalizeVerdict(aiResult.verdict);
|
||||
return {
|
||||
status: verdictToStatus(verdict),
|
||||
verdict: aiResult.verdict,
|
||||
analysis: aiResult.analysis,
|
||||
source: aiResult.source,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const stats = vtResult.data.attributes.last_analysis_stats;
|
||||
const status = statusFromAvStats(stats);
|
||||
if (status) {
|
||||
return {
|
||||
status,
|
||||
source: "engines",
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return buildPackageUndetectedFallbackAnalysis(release, pkg, stats);
|
||||
}
|
||||
|
||||
type ScanQueueHealth = {
|
||||
queueSize: number;
|
||||
@@ -526,6 +591,7 @@ const PACKAGE_SCAN_MAX_ATTEMPTS = 10;
|
||||
export const scanPackageReleaseWithVirusTotal = internalAction({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
attempt: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const apiKey = process.env.VT_API_KEY;
|
||||
@@ -550,17 +616,30 @@ export const scanPackageReleaseWithVirusTotal = internalAction({
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = args.attempt ?? 1;
|
||||
const entries: Array<{ path: string; bytes: Uint8Array }> = [];
|
||||
let missingFiles = 0;
|
||||
for (const file of release.files) {
|
||||
const content = await ctx.storage.get(file.storageId);
|
||||
if (!content) continue;
|
||||
if (!content) {
|
||||
missingFiles += 1;
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
path: file.path,
|
||||
bytes: new Uint8Array(await content.arrayBuffer()),
|
||||
});
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
console.warn(`[vt:package] No files found for release ${args.releaseId}, skipping scan`);
|
||||
if (entries.length === 0 || missingFiles > 0) {
|
||||
console.warn(
|
||||
`[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,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -577,21 +656,14 @@ export const scanPackageReleaseWithVirusTotal = internalAction({
|
||||
|
||||
try {
|
||||
const existingFile = await checkExistingFile(apiKey, sha256hash);
|
||||
const aiResult = existingFile?.data.attributes.crowdsourced_ai_results?.find(
|
||||
(r) => r.category === "code_insight",
|
||||
);
|
||||
const vtAnalysis = existingFile
|
||||
? buildPackageScanAnalysisFromVtResult(release, pkg, existingFile)
|
||||
: null;
|
||||
|
||||
if (aiResult) {
|
||||
const verdict = normalizeVerdict(aiResult.verdict);
|
||||
if (vtAnalysis) {
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
|
||||
releaseId: args.releaseId,
|
||||
vtAnalysis: {
|
||||
status: verdictToStatus(verdict),
|
||||
verdict: aiResult.verdict,
|
||||
analysis: aiResult.analysis,
|
||||
source: aiResult.source,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
vtAnalysis,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -613,6 +685,12 @@ export const scanPackageReleaseWithVirusTotal = internalAction({
|
||||
if (!response.ok) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -626,6 +704,12 @@ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -643,6 +727,10 @@ export const pollPackageReleaseScanResults = internalAction({
|
||||
releaseId: args.releaseId,
|
||||
})) as Doc<"packageReleases"> | null;
|
||||
if (!release || release.softDeletedAt || !release.sha256hash) return;
|
||||
const pkg = (await runQueryRef(ctx, internalRefs.packages.getPackageByIdInternal, {
|
||||
packageId: release.packageId,
|
||||
})) as Doc<"packages"> | null;
|
||||
if (!pkg || pkg.softDeletedAt) return;
|
||||
|
||||
const attempt = args.attempt ?? 1;
|
||||
try {
|
||||
@@ -657,33 +745,11 @@ export const pollPackageReleaseScanResults = internalAction({
|
||||
return;
|
||||
}
|
||||
|
||||
const aiResult = vtResult.data.attributes.crowdsourced_ai_results?.find(
|
||||
(r) => r.category === "code_insight",
|
||||
);
|
||||
if (aiResult) {
|
||||
const verdict = normalizeVerdict(aiResult.verdict);
|
||||
const vtAnalysis = buildPackageScanAnalysisFromVtResult(release, pkg, vtResult);
|
||||
if (vtAnalysis) {
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
|
||||
releaseId: args.releaseId,
|
||||
vtAnalysis: {
|
||||
status: verdictToStatus(verdict),
|
||||
verdict: aiResult.verdict,
|
||||
analysis: aiResult.analysis,
|
||||
source: aiResult.source,
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const status = statusFromAvStats(vtResult.data.attributes.last_analysis_stats);
|
||||
if (status) {
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseScanResultsInternal, {
|
||||
releaseId: args.releaseId,
|
||||
vtAnalysis: {
|
||||
status,
|
||||
source: "engines",
|
||||
checkedAt: Date.now(),
|
||||
},
|
||||
vtAnalysis,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -697,6 +763,12 @@ export const pollPackageReleaseScanResults = internalAction({
|
||||
}
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
summary: "Marketplace policy: what ClawHub will not allow."
|
||||
read_when:
|
||||
- Reviewing uploads for abuse or policy violations
|
||||
- Writing moderation docs or reviewer runbooks
|
||||
- Deciding whether a skill should be hidden or a user banned
|
||||
---
|
||||
|
||||
# Acceptable Usage
|
||||
|
||||
This page describes the kinds of skills and content ClawHub is not okay with.
|
||||
|
||||
These rules are intentionally practical. We care most about end-to-end abuse workflows, not just isolated keywords. If a skill is built to evade defenses, abuse platforms, scam people, invade privacy, or enable non-consensual behavior, it does not belong on ClawHub.
|
||||
|
||||
## Not okay
|
||||
|
||||
- Security-bypass or unauthorized-access workflows.
|
||||
- Examples: auth bypass, account takeover, CAPTCHA bypass, Cloudflare or anti-bot evasion, rate-limit bypass, stealth scraping designed to defeat protections, live call or agent takeover, reusable session theft, auto-approving pairing flows for unapproved users.
|
||||
|
||||
- Platform abuse and ban evasion.
|
||||
- Examples: stealth accounts after bans, account warming/farming, fake engagement, karma or follower cultivation, multi-account automation, mass posting, spam bots, marketplace or social automation built to avoid detection.
|
||||
|
||||
- Fraud, scams, and deceptive financial workflows.
|
||||
- Examples: fake certificates, fake invoices, deceptive payment flows, scam outreach, fake social proof, tools that enable spending or charging without clear human approval and transparent controls, or synthetic-identity workflows built to create accounts for fraud.
|
||||
|
||||
- Privacy-invasive scraping, enrichment, or surveillance.
|
||||
- Examples: scraping contact details at scale for spam, doxxing, stalking, lead extraction paired with unsolicited outreach, covert monitoring, face search or biometric matching used without clear consent, or buying, publishing, downloading, or operationalizing leaked data or breach dumps.
|
||||
|
||||
- Non-consensual impersonation or deceptive identity manipulation.
|
||||
- Examples: face swap, digital twins, fake personas, cloned influencers, or other identity-manipulation tooling used to impersonate or mislead.
|
||||
|
||||
- Explicit sexual content and safety-disabled adult generation.
|
||||
- Examples: NSFW image/video/content generation, adult-content wrappers around third-party APIs, or skills whose primary purpose is explicit sexual content.
|
||||
|
||||
- Hidden, unsafe, or misleading execution requirements.
|
||||
- Examples: obfuscated install commands, `curl | sh`, undeclared secret requirements, undeclared private-key use, remote `npx @latest` execution without clear reviewability, misleading metadata that hides what the skill really needs to run.
|
||||
|
||||
## Recent patterns we are explicitly not okay with
|
||||
|
||||
- “Create stealth seller accounts after marketplace bans.”
|
||||
- “Modify Telegram pairing so unapproved users automatically receive pairing codes.”
|
||||
- “Cultivate Reddit/Twitter accounts with undetectable automation.”
|
||||
- “Generate professional certificates or invoices for arbitrary use.”
|
||||
- “Generate NSFW content with safety checks disabled.”
|
||||
- “Scrape leads, enrich contacts, and launch cold outreach at scale.”
|
||||
- “Buy, publish, or download leaked data or breach dumps.”
|
||||
- “Bulk-create email or social accounts with synthetic identities or CAPTCHA solving.”
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
- Context matters. The same topic can be legitimate in a narrow defensive or consent-based setting and unacceptable when packaged as an abuse workflow.
|
||||
- We should bias toward action when a skill is clearly optimized for evasion, deception, or non-consensual use.
|
||||
- Repeated uploads in these categories are grounds for hiding content and banning the account.
|
||||
|
||||
## Enforcement
|
||||
|
||||
- We may hide, remove, or hard-delete violating skills.
|
||||
- We may revoke tokens, soft-delete associated content, and ban repeat or severe offenders.
|
||||
- We do not guarantee warning-first enforcement for obvious abuse.
|
||||
+2
-2
@@ -24,8 +24,8 @@ Auth-aware enforcement:
|
||||
- Authenticated requests (valid Bearer token): per user bucket.
|
||||
- Missing/invalid token falls back to IP enforcement.
|
||||
|
||||
- Read: 120/min per IP, 600/min per key
|
||||
- Write: 30/min per IP, 120/min per key
|
||||
- Read: 180/min per IP, 900/min per key
|
||||
- Write: 45/min per IP, 180/min per key
|
||||
|
||||
Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `Retry-After` (on 429).
|
||||
|
||||
|
||||
+55
-3
@@ -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,62 @@ 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.
|
||||
- `--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`](/Users/tengjizhang/.codex/worktrees/7d03/clawhub/.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/')
|
||||
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.
|
||||
- The workflow uploads the JSON result as an artifact and exposes it as workflow outputs.
|
||||
|
||||
### `sync`
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
+15
-12
@@ -21,9 +21,9 @@ Enforcement model:
|
||||
- Authenticated requests (valid Bearer token): enforced per user bucket.
|
||||
- If token is missing/invalid, behavior falls back to IP enforcement.
|
||||
|
||||
- Read: 120/min per IP, 600/min per key
|
||||
- Write: 30/min per IP, 120/min per key
|
||||
- Download: 20/min per IP, 120/min per key (`/api/v1/download`)
|
||||
- Read: 180/min per IP, 900/min per key
|
||||
- Write: 45/min per IP, 180/min per key
|
||||
- Download: 30/min per IP, 180/min per key (`/api/v1/download`)
|
||||
|
||||
Headers:
|
||||
|
||||
@@ -280,8 +280,8 @@ Notes:
|
||||
- Skill entries stay backed by the skill registry and can still be published only through `POST /api/v1/skills`.
|
||||
- `POST /api/v1/packages` is still only for code-plugin and bundle-plugin releases.
|
||||
- Anonymous callers only see public package channels.
|
||||
- Authenticated callers can see their own private packages in list/search results.
|
||||
- `channel=private` only returns packages owned by the authenticated caller.
|
||||
- Authenticated callers can see private packages for publishers they belong to in list/search results.
|
||||
- `channel=private` only returns packages the authenticated caller can read.
|
||||
|
||||
### `GET /api/v1/packages/search`
|
||||
|
||||
@@ -300,8 +300,8 @@ Query params:
|
||||
Notes:
|
||||
|
||||
- Anonymous callers only see public package channels.
|
||||
- Authenticated callers can search their own private packages.
|
||||
- `channel=private` only returns packages owned by the authenticated caller.
|
||||
- Authenticated callers can search private packages for publishers they belong to.
|
||||
- `channel=private` only returns packages the authenticated caller can read.
|
||||
|
||||
### `GET /api/v1/packages/{name}`
|
||||
|
||||
@@ -310,7 +310,7 @@ Returns package detail metadata.
|
||||
Notes:
|
||||
|
||||
- Skills can also resolve through this route in the unified catalog.
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
- Private packages return `404` unless the caller can read the owning publisher.
|
||||
|
||||
### `GET /api/v1/packages/{name}/versions`
|
||||
|
||||
@@ -323,15 +323,16 @@ Query params:
|
||||
|
||||
Notes:
|
||||
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
- Private packages return `404` unless the caller can read the owning publisher.
|
||||
|
||||
### `GET /api/v1/packages/{name}/versions/{version}`
|
||||
|
||||
Returns one package version, including file metadata, compatibility, capabilities, and verification.
|
||||
Returns one package version, including file metadata, compatibility, capabilities, verification, and scan data.
|
||||
|
||||
Notes:
|
||||
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
- `version.sha256hash`, `version.vtAnalysis`, `version.llmAnalysis`, and `version.staticScan` are included when scan data exists.
|
||||
- Private packages return `404` unless the caller can read the owning publisher.
|
||||
|
||||
### `GET /api/v1/packages/{name}/file`
|
||||
|
||||
@@ -349,7 +350,8 @@ Notes:
|
||||
- Uses the read rate bucket, not the download bucket.
|
||||
- Binary files return `415`.
|
||||
- File size limit: 200KB.
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
- Pending VirusTotal scans do not block reads; malicious releases may still be withheld elsewhere.
|
||||
- Private packages return `404` unless the caller can read the owning publisher.
|
||||
|
||||
### `GET /api/v1/packages/{name}/download`
|
||||
|
||||
@@ -366,6 +368,7 @@ Notes:
|
||||
- Skills redirect to `GET /api/v1/download`.
|
||||
- Plugin/package archives are zip files with a `package/` root so they install directly in OpenClaw without repacking.
|
||||
- Registry-only metadata is not injected into the downloaded archive.
|
||||
- Pending VirusTotal scans do not block downloads; malicious releases return `403`.
|
||||
- Private packages return `404` unless the caller is the owner.
|
||||
|
||||
### `GET /api/v1/resolve`
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+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 \
|
||||
|
||||
@@ -8,6 +8,8 @@ read_when:
|
||||
|
||||
# Security + Moderation
|
||||
|
||||
See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace policy on prohibited skill categories.
|
||||
|
||||
## Roles + permissions
|
||||
|
||||
- user: upload skills/souls (subject to GitHub age gate), report skills/comments.
|
||||
@@ -42,6 +44,11 @@ read_when:
|
||||
## Skill moderation pipeline
|
||||
|
||||
- New skill publishes now persist a deterministic static scan result on the version.
|
||||
- Package/plugin scan backfills now also recompute deterministic static scan results for older releases,
|
||||
so legacy plugin versions can surface OpenClaw scan findings without republishing.
|
||||
- Source-linked packages can fall back to a clean package verdict when VirusTotal only returns
|
||||
undetected engine results, provided the LLM scan is clean and static scan is non-malicious. This
|
||||
avoids indefinite pending scans when VT Code Insight never materializes.
|
||||
- Skill moderation state stores a structured snapshot:
|
||||
- `moderationVerdict`: `clean | suspicious | malicious`
|
||||
- `moderationReasonCodes[]`: canonical machine-readable reasons
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+110
-8
@@ -47,6 +47,34 @@ function getSite() {
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -268,7 +296,81 @@ describe("clawhub e2e", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes, deletes, and undeletes a skill (logged-in)", async () => {
|
||||
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;
|
||||
@@ -284,7 +386,7 @@ describe("clawhub e2e", () => {
|
||||
|
||||
try {
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), `# ${slug}\n\nHello.\n`, "utf8");
|
||||
await writeFile(join(skillDir, "SKILL.md"), buildE2ESkillMarkdown(slug), "utf8");
|
||||
|
||||
const publish1 = spawnSync(
|
||||
"bun",
|
||||
@@ -506,22 +608,22 @@ describe("clawhub e2e", () => {
|
||||
}, 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 registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWDHUB_E2E_TOKEN or run: bun clawdhub auth login");
|
||||
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(), "clawdhub-e2e-delete-"));
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-delete-"));
|
||||
const nonExistentSlug = `non-existent-skill-${Date.now()}`;
|
||||
|
||||
try {
|
||||
const del = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawdhub",
|
||||
"clawhub",
|
||||
"delete",
|
||||
nonExistentSlug,
|
||||
"--yes",
|
||||
@@ -534,7 +636,7 @@ describe("clawhub e2e", () => {
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path, CLAWDHUB_DISABLE_TELEMETRY: "1" },
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
|
||||
|
||||
const navLabels = ["Skills", "Upload", "Import", "Search"];
|
||||
const navLabels = ["Skills", "Plugins", "Search"];
|
||||
|
||||
test("skills loads without error", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
@@ -31,18 +31,9 @@ test("header menu routes render", async ({ page }) => {
|
||||
await expect(page.locator("h1", { hasText: "Skills" })).toBeVisible();
|
||||
}
|
||||
|
||||
if (label === "Upload") {
|
||||
await expect(page).toHaveURL(/\/upload/);
|
||||
const heading = page.locator("h1.section-title", { hasText: /^Publish a /i });
|
||||
const signInCard = page.locator("text=Sign in to upload");
|
||||
await expect(heading.or(signInCard)).toBeVisible();
|
||||
}
|
||||
|
||||
if (label === "Import") {
|
||||
await expect(page).toHaveURL(/\/import/);
|
||||
const heading = page.getByRole("heading", { name: "Import from GitHub" });
|
||||
const signInCard = page.locator("text=Sign in to import and publish skills.");
|
||||
await expect(heading.or(signInCard)).toBeVisible();
|
||||
if (label === "Plugins") {
|
||||
await expect(page).toHaveURL(/\/plugins(\?|$)/);
|
||||
await expect(page.locator("h1", { hasText: "Plugins" })).toBeVisible();
|
||||
}
|
||||
|
||||
if (label === "Search") {
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("prod http smoke", () => {
|
||||
|
||||
expect(html).toContain("<title>ClawHub");
|
||||
expect(html).toContain('href="/skills"');
|
||||
expect(html).toContain('href="/upload"');
|
||||
expect(html).toContain('href="/publish-skill"');
|
||||
expect(html).not.toContain("Something went wrong!");
|
||||
});
|
||||
|
||||
|
||||
@@ -36,13 +36,38 @@ 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`](/Users/tengjizhang/.codex/worktrees/7d03/clawhub/.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.
|
||||
|
||||
## Development
|
||||
|
||||
The supported verification flow for this package is package-local:
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/clawdhub test
|
||||
bun run --cwd packages/clawdhub verify:build
|
||||
bun run --cwd packages/clawdhub test:artifact
|
||||
bun run --cwd packages/clawdhub 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
|
||||
|
||||
@@ -15,9 +15,14 @@
|
||||
],
|
||||
"type": "module",
|
||||
"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:src": "vitest run -c vitest.config.ts",
|
||||
"verify:build": "tsc -p tsconfig.json --noEmit",
|
||||
"test:artifact": "bun run build && vitest run -c vitest.artifact.config.ts",
|
||||
"verify": "bun run test:src && bun run verify:build && bun run test:artifact"
|
||||
},
|
||||
"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);
|
||||
@@ -276,7 +276,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,6 +330,21 @@ program
|
||||
});
|
||||
|
||||
const skill = program.command("skill").description("Manage published skills");
|
||||
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");
|
||||
@@ -372,8 +387,8 @@ 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")
|
||||
@@ -387,9 +402,11 @@ packageCmd
|
||||
.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("--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);
|
||||
});
|
||||
|
||||
skill
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,62 +1,42 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
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),
|
||||
}));
|
||||
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");
|
||||
|
||||
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);
|
||||
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 () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true });
|
||||
await cmdDeleteSkill(makeOpts(), "demo", { yes: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
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(),
|
||||
@@ -64,9 +44,9 @@ describe("delete/undelete", () => {
|
||||
});
|
||||
|
||||
it("calls undelete endpoint with --yes", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true });
|
||||
await cmdUndeleteSkill(makeOpts(), "demo", { yes: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
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(),
|
||||
@@ -74,15 +54,15 @@ describe("delete/undelete", () => {
|
||||
});
|
||||
|
||||
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(
|
||||
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(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/undelete" }),
|
||||
expect.anything(),
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
try {
|
||||
await expect(resolveSourceInput(input, { workdir })).resolves.toEqual(expected);
|
||||
} finally {
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { unzipSync } from "fflate";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
const GITHUB_API = "https://api.github.com";
|
||||
const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]);
|
||||
const ZIP_USER_AGENT = "clawhub/package-publish";
|
||||
|
||||
export type ResolvedPublishSource =
|
||||
| {
|
||||
kind: "local";
|
||||
path: string;
|
||||
}
|
||||
| {
|
||||
kind: "github";
|
||||
owner: string;
|
||||
repo: string;
|
||||
ref?: string;
|
||||
path: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type LocalGitInfo = {
|
||||
root: string;
|
||||
path: string;
|
||||
repo?: string;
|
||||
commit?: string;
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
export type FetchedGitHubSource = {
|
||||
dir: string;
|
||||
source: {
|
||||
kind: "github";
|
||||
url: string;
|
||||
repo: string;
|
||||
ref: string;
|
||||
commit: string;
|
||||
path: string;
|
||||
importedAt: number;
|
||||
};
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function resolveSourceInput(
|
||||
input: string,
|
||||
options: { workdir: string },
|
||||
): Promise<ResolvedPublishSource> {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) throw new Error("Path required");
|
||||
|
||||
if (trimmed.startsWith("https://")) {
|
||||
return parseGitHubUrl(trimmed);
|
||||
}
|
||||
|
||||
const shorthand = parseGitHubShorthand(trimmed);
|
||||
if (shorthand) {
|
||||
const localPath = resolveLocalPath(options.workdir, trimmed);
|
||||
const localStat = await stat(localPath).catch(() => null);
|
||||
if (localStat?.isDirectory()) {
|
||||
return { kind: "local", path: localPath };
|
||||
}
|
||||
return shorthand;
|
||||
}
|
||||
|
||||
return { kind: "local", path: resolveLocalPath(options.workdir, trimmed) };
|
||||
}
|
||||
|
||||
export async function fetchGitHubSource(source: Extract<ResolvedPublishSource, { kind: "github" }>) {
|
||||
const token = process.env.GITHUB_TOKEN?.trim() || undefined;
|
||||
const repo = `${source.owner}/${source.repo}`;
|
||||
const repoUrl = `https://github.com/${repo}`;
|
||||
const resolvedRef = source.ref?.trim() || (await resolveDefaultBranch(source.owner, source.repo, token));
|
||||
const commit = await resolveCommitSha(source.owner, source.repo, resolvedRef, token);
|
||||
const archiveBytes = await downloadGitHubZip(source.owner, source.repo, commit, token);
|
||||
const entries = stripSingleTopLevelFolder(unzipSync(archiveBytes));
|
||||
const publishPath = normalizeRepoSubpath(source.path);
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "clawhub-github-publish-"));
|
||||
|
||||
try {
|
||||
const subdirEntries = filterEntriesForSubpath(entries, publishPath);
|
||||
if (Object.keys(subdirEntries).length === 0) {
|
||||
throw new Error(`GitHub path "${publishPath}" does not contain any files`);
|
||||
}
|
||||
await writeEntries(tempDir, subdirEntries);
|
||||
} catch (error) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
dir: tempDir,
|
||||
source: {
|
||||
kind: "github" as const,
|
||||
url: repoUrl,
|
||||
repo,
|
||||
ref: resolvedRef,
|
||||
commit,
|
||||
path: publishPath,
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
cleanup: async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
},
|
||||
} satisfies FetchedGitHubSource;
|
||||
}
|
||||
|
||||
export function resolveLocalGitInfo(folder: string): LocalGitInfo | null {
|
||||
const root = runGit(folder, ["rev-parse", "--show-toplevel"]);
|
||||
if (!root) return null;
|
||||
|
||||
const prefix = runGit(folder, ["rev-parse", "--show-prefix"]);
|
||||
const commit = runGit(folder, ["rev-parse", "HEAD"]) || undefined;
|
||||
const ref =
|
||||
runGit(folder, ["describe", "--tags", "--exact-match"]) ||
|
||||
runGit(folder, ["branch", "--show-current"]) ||
|
||||
commit;
|
||||
const repo = normalizeGitHubRepo(runGit(folder, ["remote", "get-url", "origin"]) || "");
|
||||
|
||||
return {
|
||||
root: root,
|
||||
path: normalizePath(prefix || "") || ".",
|
||||
repo: repo || undefined,
|
||||
commit,
|
||||
ref: ref || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeGitHubRepo(value: string) {
|
||||
const trimmed = value
|
||||
.trim()
|
||||
.replace(/^git\+/, "")
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/^git@github\.com:/i, "https://github.com/");
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const shorthand = trimmed.match(/^([a-z0-9_.-]+)\/([a-z0-9_.-]+)$/i);
|
||||
if (shorthand) return `${shorthand[1]}/${shorthand[2]}`;
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (!GITHUB_HOSTS.has(url.hostname)) return undefined;
|
||||
const segments = decodePathSegments(url.pathname);
|
||||
const owner = segments[0] ?? "";
|
||||
const repo = (segments[1] ?? "").replace(/\.git$/i, "");
|
||||
if (!owner || !repo) return undefined;
|
||||
return `${owner}/${repo}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseGitHubShorthand(input: string): Extract<ResolvedPublishSource, { kind: "github" }> | null {
|
||||
const atIndex = input.lastIndexOf("@");
|
||||
const rawRepo = atIndex > 0 ? input.slice(0, atIndex) : input;
|
||||
const rawRef = atIndex > 0 ? input.slice(atIndex + 1).trim() : "";
|
||||
if (
|
||||
!rawRepo ||
|
||||
rawRepo.startsWith(".") ||
|
||||
rawRepo.startsWith("~") ||
|
||||
rawRepo.startsWith("/") ||
|
||||
rawRepo.includes("\\")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const match = rawRepo.match(/^([a-z0-9_.-]+)\/([a-z0-9_.-]+)$/i);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
kind: "github",
|
||||
owner: match[1],
|
||||
repo: match[2],
|
||||
...(rawRef ? { ref: rawRef } : {}),
|
||||
path: ".",
|
||||
url: `https://github.com/${match[1]}/${match[2]}`,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGitHubUrl(input: string): Extract<ResolvedPublishSource, { kind: "github" }> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input);
|
||||
} catch {
|
||||
throw new Error("Invalid GitHub URL");
|
||||
}
|
||||
if (url.protocol !== "https:") throw new Error("Only https:// GitHub URLs are supported");
|
||||
if (!GITHUB_HOSTS.has(url.hostname)) throw new Error("Only github.com URLs are supported");
|
||||
|
||||
const segments = decodePathSegments(url.pathname);
|
||||
const owner = segments[0] ?? "";
|
||||
const repo = (segments[1] ?? "").replace(/\.git$/i, "");
|
||||
if (!owner || !repo) throw new Error("GitHub URL must be /<owner>/<repo>");
|
||||
|
||||
const kind = segments[2] ?? "";
|
||||
if (!kind || (kind !== "tree" && kind !== "blob")) {
|
||||
return {
|
||||
kind: "github",
|
||||
owner,
|
||||
repo,
|
||||
path: ".",
|
||||
url: `https://github.com/${owner}/${repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
const ref = segments[3] ?? "";
|
||||
if (!ref) throw new Error("Missing ref in GitHub URL");
|
||||
const rest = segments.slice(4).join("/");
|
||||
const normalizedPath = normalizeRepoSubpath(rest || ".");
|
||||
if (kind === "blob") {
|
||||
if (!rest) throw new Error("Missing path in GitHub URL");
|
||||
const parent = normalizeRepoSubpath(rest.split("/").slice(0, -1).join("/") || ".");
|
||||
return {
|
||||
kind: "github",
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
path: parent,
|
||||
url: `https://github.com/${owner}/${repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "github",
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
path: normalizedPath,
|
||||
url: `https://github.com/${owner}/${repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRepoSubpath(value: string) {
|
||||
const normalized = normalizePath(value.trim());
|
||||
if (!normalized || normalized === ".") return ".";
|
||||
const segments = normalized.split("/");
|
||||
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
||||
throw new Error("Invalid GitHub path");
|
||||
}
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function resolveLocalPath(workdir: string, input: string) {
|
||||
if (input === "~") return homedir();
|
||||
if (input.startsWith("~/")) return resolve(homedir(), input.slice(2));
|
||||
return resolve(workdir, input);
|
||||
}
|
||||
|
||||
function normalizePath(pathValue: string) {
|
||||
return pathValue
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.join("/")
|
||||
.replace(/^\.\/+/, "");
|
||||
}
|
||||
|
||||
function decodePathSegments(pathname: string) {
|
||||
return pathname
|
||||
.split("/")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
.map((segment) => {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
throw new Error("Invalid GitHub URL");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveDefaultBranch(owner: string, repo: string, token?: string) {
|
||||
const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`, {
|
||||
headers: buildGitHubHeaders(token),
|
||||
});
|
||||
if (!response.ok) throw new Error(`GitHub repo not found: ${owner}/${repo}`);
|
||||
const parsed = (await response.json()) as { default_branch?: unknown };
|
||||
const defaultBranch =
|
||||
typeof parsed.default_branch === "string" ? parsed.default_branch.trim() : "";
|
||||
if (!defaultBranch) throw new Error("GitHub repo default branch missing");
|
||||
return defaultBranch;
|
||||
}
|
||||
|
||||
async function resolveCommitSha(owner: string, repo: string, ref: string, token?: string) {
|
||||
const response = await fetch(
|
||||
`${GITHUB_API}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
|
||||
{
|
||||
headers: buildGitHubHeaders(token),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`GitHub ref not found: ${owner}/${repo}@${ref}`);
|
||||
const parsed = (await response.json()) as { sha?: unknown };
|
||||
const sha = typeof parsed.sha === "string" ? parsed.sha.trim().toLowerCase() : "";
|
||||
if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error("GitHub commit sha missing");
|
||||
return sha;
|
||||
}
|
||||
|
||||
async function downloadGitHubZip(owner: string, repo: string, ref: string, token?: string) {
|
||||
const response = await fetch(
|
||||
`${GITHUB_API}/repos/${owner}/${repo}/zipball/${encodeURIComponent(ref)}`,
|
||||
{
|
||||
headers: buildGitHubHeaders(token),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`GitHub archive download failed: ${owner}/${repo}@${ref}`);
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
function buildGitHubHeaders(token?: string) {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": ZIP_USER_AGENT,
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function stripSingleTopLevelFolder(entries: Record<string, Uint8Array>) {
|
||||
const paths = Object.keys(entries);
|
||||
if (paths.length === 0) return {};
|
||||
const firstRoot = paths[0]?.split("/")[0] ?? "";
|
||||
if (!firstRoot) return entries;
|
||||
const prefix = `${firstRoot}/`;
|
||||
if (!paths.every((path) => path.startsWith(prefix))) return entries;
|
||||
|
||||
const stripped: Record<string, Uint8Array> = {};
|
||||
for (const [path, bytes] of Object.entries(entries)) {
|
||||
const next = path.slice(prefix.length);
|
||||
if (!next) continue;
|
||||
stripped[next] = bytes;
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
function filterEntriesForSubpath(entries: Record<string, Uint8Array>, subpath: string) {
|
||||
if (subpath === ".") return entries;
|
||||
const prefix = `${subpath}/`;
|
||||
const filtered: Record<string, Uint8Array> = {};
|
||||
for (const [path, bytes] of Object.entries(entries)) {
|
||||
if (!path.startsWith(prefix)) continue;
|
||||
const relPath = path.slice(prefix.length);
|
||||
if (!relPath) continue;
|
||||
filtered[relPath] = bytes;
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async function writeEntries(root: string, entries: Record<string, Uint8Array>) {
|
||||
for (const [path, bytes] of Object.entries(entries)) {
|
||||
if (!path || path.endsWith("/")) continue;
|
||||
const absPath = join(root, ...path.split("/"));
|
||||
await mkdir(dirname(absPath), { recursive: true });
|
||||
await writeFile(absPath, Buffer.from(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
function runGit(cwd: string, args: string[]) {
|
||||
const result = spawnSync("git", ["-C", cwd, ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
if (result.status !== 0) return null;
|
||||
const value = result.stdout.trim();
|
||||
return value || null;
|
||||
}
|
||||
@@ -1,63 +1,29 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
import { ApiRoutes } from "../../schema/index.js";
|
||||
import type { GlobalOpts } from "../types";
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
const mockApiRequest = 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),
|
||||
fetchText: (...args: unknown[]) => mockFetchText(...args),
|
||||
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
|
||||
}));
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => "https://clawhub.ai");
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}));
|
||||
|
||||
const mockGetOptionalAuthToken = vi.fn(async () => undefined as string | undefined);
|
||||
vi.mock("../authToken.js", () => ({
|
||||
getOptionalAuthToken: () => mockGetOptionalAuthToken(),
|
||||
}));
|
||||
|
||||
const mockSpinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
start: vi.fn(),
|
||||
succeed: vi.fn(),
|
||||
isSpinning: false,
|
||||
text: "",
|
||||
};
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
}));
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdInspect } = await import("./inspect");
|
||||
|
||||
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const mockWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLog.mockClear();
|
||||
@@ -66,7 +32,7 @@ afterEach(() => {
|
||||
|
||||
describe("cmdInspect", () => {
|
||||
it("fetches latest version files when --files is set", async () => {
|
||||
mockApiRequest
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
skill: {
|
||||
slug: "demo",
|
||||
@@ -85,10 +51,10 @@ describe("cmdInspect", () => {
|
||||
version: { version: "1.2.3", createdAt: 3, changelog: "init", files: [] },
|
||||
});
|
||||
|
||||
await cmdInspect(makeOpts(), "demo", { files: true });
|
||||
await cmdInspect(makeGlobalOpts(), "demo", { files: true });
|
||||
|
||||
const firstArgs = mockApiRequest.mock.calls[0]?.[1];
|
||||
const secondArgs = mockApiRequest.mock.calls[1]?.[1];
|
||||
const firstArgs = httpMocks.apiRequest.mock.calls[0]?.[1];
|
||||
const secondArgs = httpMocks.apiRequest.mock.calls[1]?.[1];
|
||||
expect(firstArgs?.path).toBe(`${ApiRoutes.skills}/${encodeURIComponent("demo")}`);
|
||||
expect(secondArgs?.path).toBe(
|
||||
`${ApiRoutes.skills}/${encodeURIComponent("demo")}/versions/${encodeURIComponent("1.2.3")}`,
|
||||
@@ -96,7 +62,7 @@ describe("cmdInspect", () => {
|
||||
});
|
||||
|
||||
it("uses tag param when fetching a file", async () => {
|
||||
mockApiRequest
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
skill: {
|
||||
slug: "demo",
|
||||
@@ -114,11 +80,11 @@ describe("cmdInspect", () => {
|
||||
skill: { slug: "demo", displayName: "Demo" },
|
||||
version: { version: "2.0.0", createdAt: 3, changelog: "init", files: [] },
|
||||
});
|
||||
mockFetchText.mockResolvedValue("content");
|
||||
httpMocks.fetchText.mockResolvedValue("content");
|
||||
|
||||
await cmdInspect(makeOpts(), "demo", { file: "SKILL.md", tag: "latest" });
|
||||
await cmdInspect(makeGlobalOpts(), "demo", { file: "SKILL.md", tag: "latest" });
|
||||
|
||||
const fetchArgs = mockFetchText.mock.calls[0]?.[1];
|
||||
const fetchArgs = httpMocks.fetchText.mock.calls[0]?.[1];
|
||||
const url = new URL(String(fetchArgs?.url));
|
||||
expect(url.pathname).toBe("/api/v1/skills/demo/file");
|
||||
expect(url.searchParams.get("path")).toBe("SKILL.md");
|
||||
@@ -127,7 +93,7 @@ describe("cmdInspect", () => {
|
||||
});
|
||||
|
||||
it("prints security summary when version security metadata exists", async () => {
|
||||
mockApiRequest
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
skill: {
|
||||
slug: "demo",
|
||||
@@ -157,7 +123,7 @@ describe("cmdInspect", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await cmdInspect(makeOpts(), "demo", { version: "2.0.0" });
|
||||
await cmdInspect(makeGlobalOpts(), "demo", { version: "2.0.0" });
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("License: MIT-0"));
|
||||
expect(mockLog).toHaveBeenCalledWith("Security: SUSPICIOUS");
|
||||
@@ -168,7 +134,7 @@ describe("cmdInspect", () => {
|
||||
|
||||
it("rejects when both version and tag are provided", async () => {
|
||||
await expect(
|
||||
cmdInspect(makeOpts(), "demo", { version: "1.0.0", tag: "latest" }),
|
||||
cmdInspect(makeGlobalOpts(), "demo", { version: "1.0.0", tag: "latest" }),
|
||||
).rejects.toThrow("Use either --version or --tag");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,63 +1,39 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: vi.fn(async () => "https://clawhub.ai"),
|
||||
}));
|
||||
|
||||
const mockApiRequest = 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: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
mockApiRequest(registry, args, schema),
|
||||
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
|
||||
}));
|
||||
|
||||
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),
|
||||
}));
|
||||
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 { cmdBanUser, cmdSetRole } = await import("./moderation");
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("cmdBanUser", () => {
|
||||
it("requires --yes when input is disabled", async () => {
|
||||
await expect(cmdBanUser(makeOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdBanUser(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
});
|
||||
|
||||
it("posts handle payload", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 1 });
|
||||
await cmdBanUser(makeOpts(), "hightower6eu", { yes: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 1 });
|
||||
await cmdBanUser(makeGlobalOpts(), "hightower6eu", { yes: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -69,14 +45,14 @@ describe("cmdBanUser", () => {
|
||||
});
|
||||
|
||||
it("includes reason when provided", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 });
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 });
|
||||
await cmdBanUser(
|
||||
makeOpts(),
|
||||
makeGlobalOpts(),
|
||||
"hightower6eu",
|
||||
{ yes: true, reason: "malware distribution" },
|
||||
false,
|
||||
);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -88,9 +64,9 @@ describe("cmdBanUser", () => {
|
||||
});
|
||||
|
||||
it("posts user id payload when --id is set", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 });
|
||||
await cmdBanUser(makeOpts(), "user_123", { yes: true, id: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 });
|
||||
await cmdBanUser(makeGlobalOpts(), "user_123", { yes: true, id: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -102,7 +78,7 @@ describe("cmdBanUser", () => {
|
||||
});
|
||||
|
||||
it("resolves user via fuzzy search", async () => {
|
||||
mockApiRequest
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
@@ -116,8 +92,8 @@ describe("cmdBanUser", () => {
|
||||
total: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 });
|
||||
await cmdBanUser(makeOpts(), "moonshine-100rze", { yes: true, fuzzy: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenNthCalledWith(
|
||||
await cmdBanUser(makeGlobalOpts(), "moonshine-100rze", { yes: true, fuzzy: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
@@ -126,7 +102,7 @@ describe("cmdBanUser", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockApiRequest).toHaveBeenNthCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
@@ -139,7 +115,7 @@ describe("cmdBanUser", () => {
|
||||
});
|
||||
|
||||
it("fails fuzzy search with multiple matches when not interactive", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
userId: "users_1",
|
||||
@@ -159,26 +135,28 @@ describe("cmdBanUser", () => {
|
||||
total: 2,
|
||||
});
|
||||
await expect(
|
||||
cmdBanUser(makeOpts(), "moonshine", { yes: true, fuzzy: true }, false),
|
||||
cmdBanUser(makeGlobalOpts(), "moonshine", { yes: true, fuzzy: true }, false),
|
||||
).rejects.toThrow(/multiple users matched/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdSetRole", () => {
|
||||
it("requires --yes when input is disabled", async () => {
|
||||
await expect(cmdSetRole(makeOpts(), "demo", "moderator", {}, false)).rejects.toThrow(/--yes/i);
|
||||
});
|
||||
|
||||
it("rejects invalid roles", async () => {
|
||||
await expect(cmdSetRole(makeOpts(), "demo", "owner", { yes: true }, false)).rejects.toThrow(
|
||||
/role/i,
|
||||
await expect(cmdSetRole(makeGlobalOpts(), "demo", "moderator", {}, false)).rejects.toThrow(
|
||||
/--yes/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid roles", async () => {
|
||||
await expect(
|
||||
cmdSetRole(makeGlobalOpts(), "demo", "owner", { yes: true }, false),
|
||||
).rejects.toThrow(/role/i);
|
||||
});
|
||||
|
||||
it("posts handle payload", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, role: "moderator" });
|
||||
await cmdSetRole(makeOpts(), "hightower6eu", "moderator", { yes: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true, role: "moderator" });
|
||||
await cmdSetRole(makeGlobalOpts(), "hightower6eu", "moderator", { yes: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -190,9 +168,9 @@ describe("cmdSetRole", () => {
|
||||
});
|
||||
|
||||
it("posts user id payload when --id is set", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, role: "admin" });
|
||||
await cmdSetRole(makeOpts(), "user_123", "admin", { yes: true, id: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true, role: "admin" });
|
||||
await cmdSetRole(makeGlobalOpts(), "user_123", "admin", { yes: true, id: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
|
||||
@@ -1,65 +1,47 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
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),
|
||||
}));
|
||||
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 { 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,
|
||||
);
|
||||
await expect(
|
||||
cmdRenameSkill(makeGlobalOpts(), "demo", "demo-new", {}, false),
|
||||
).rejects.toThrow(/--yes/i);
|
||||
});
|
||||
|
||||
it("rename calls rename endpoint", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "demo-new",
|
||||
previousSlug: "demo",
|
||||
});
|
||||
|
||||
await cmdRenameSkill(makeOpts(), "Demo", "Demo-New", { yes: true }, false);
|
||||
await cmdRenameSkill(makeGlobalOpts(), "Demo", "Demo-New", { yes: true }, false);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -67,20 +49,20 @@ describe("ownership commands", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
const requestArgs = mockApiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
expect(requestArgs.body).toContain('"newSlug":"demo-new"');
|
||||
});
|
||||
|
||||
it("merge calls merge endpoint", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
sourceSlug: "demo-old",
|
||||
targetSlug: "demo",
|
||||
});
|
||||
|
||||
await cmdMergeSkill(makeOpts(), "Demo-Old", "Demo", { yes: true }, false);
|
||||
await cmdMergeSkill(makeGlobalOpts(), "Demo-Old", "Demo", { yes: true }, false);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -88,7 +70,7 @@ describe("ownership commands", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
const requestArgs = mockApiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
expect(requestArgs.body).toContain('"targetSlug":"demo"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,80 +3,100 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
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 authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
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)),
|
||||
}));
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
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",
|
||||
};
|
||||
function makeOpts(workdir = "/work") {
|
||||
return makeGlobalOpts(workdir);
|
||||
}
|
||||
|
||||
async function makeTmpWorkdir() {
|
||||
return await mkdtemp(join(tmpdir(), "clawhub-package-"));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function getPublishForm() {
|
||||
const publishCall = httpMocks.apiRequestForm.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 form = (publishCall[1] as { form?: FormData }).form;
|
||||
if (!(form instanceof FormData)) throw new Error("Missing publish form");
|
||||
return form;
|
||||
}
|
||||
|
||||
function getPublishPayload() {
|
||||
const form = getPublishForm();
|
||||
const payloadEntry = form.get("payload");
|
||||
if (typeof payloadEntry !== "string") throw new Error("Missing publish payload");
|
||||
return JSON.parse(payloadEntry) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getUploadedFileNames() {
|
||||
const form = getPublishForm();
|
||||
return (form.getAll("files") as Array<Blob & { name?: string }>)
|
||||
.map((file) => String(file.name ?? ""))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function makeCodePluginPackageJson(overrides: Record<string, unknown>) {
|
||||
return JSON.stringify({
|
||||
openclaw: {
|
||||
extensions: ["./dist/index.js"],
|
||||
compat: {
|
||||
pluginApi: ">=2026.3.24-beta.2",
|
||||
},
|
||||
build: {
|
||||
openclawVersion: "2026.3.24-beta.2",
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLog.mockClear();
|
||||
mockWrite.mockClear();
|
||||
uiMocks.spinner.text = "";
|
||||
});
|
||||
|
||||
describe("package commands", () => {
|
||||
it("searches package catalog via /api/v1/packages/search", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
results: [
|
||||
{
|
||||
score: 10,
|
||||
@@ -98,7 +118,7 @@ describe("package commands", () => {
|
||||
executesCode: true,
|
||||
});
|
||||
|
||||
const request = mockApiRequest.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
const request = httpMocks.apiRequest.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");
|
||||
@@ -107,14 +127,14 @@ describe("package commands", () => {
|
||||
});
|
||||
|
||||
it("supports skill family package browse requests", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
await cmdExplorePackages(makeOpts(), "", { family: "skill", limit: 7 });
|
||||
|
||||
const request = mockApiRequest.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
const request = httpMocks.apiRequest.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");
|
||||
@@ -122,7 +142,7 @@ describe("package commands", () => {
|
||||
});
|
||||
|
||||
it("uses tag param when fetching a package file", async () => {
|
||||
mockApiRequest
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
package: {
|
||||
name: "demo",
|
||||
@@ -154,11 +174,11 @@ describe("package commands", () => {
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
mockFetchText.mockResolvedValue("content");
|
||||
httpMocks.fetchText.mockResolvedValue("content");
|
||||
|
||||
await cmdInspectPackage(makeOpts(), "demo", { file: "README.md", tag: "latest" });
|
||||
|
||||
const fetchArgs = mockFetchText.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
const fetchArgs = httpMocks.fetchText.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");
|
||||
@@ -166,14 +186,15 @@ describe("package commands", () => {
|
||||
expect(url.searchParams.get("version")).toBeNull();
|
||||
});
|
||||
|
||||
it("publishes a code plugin package with source metadata", async () => {
|
||||
it("publishes a code plugin package with an exact explicit payload", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(123_456_789);
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
JSON.stringify({
|
||||
makeCodePluginPackageJson({
|
||||
name: "@scope/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
@@ -184,7 +205,7 @@ describe("package commands", () => {
|
||||
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({
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "pkg_1",
|
||||
releaseId: "rel_1",
|
||||
@@ -197,31 +218,91 @@ describe("package commands", () => {
|
||||
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";
|
||||
expect(getPublishPayload()).toEqual({
|
||||
name: "@scope/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
ownerHandle: "openclaw",
|
||||
family: "code-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "",
|
||||
tags: ["latest"],
|
||||
source: {
|
||||
kind: "github",
|
||||
url: "https://github.com/openclaw/demo-plugin",
|
||||
repo: "openclaw/demo-plugin",
|
||||
ref: "refs/tags/v1.0.0",
|
||||
commit: "abc123",
|
||||
path: ".",
|
||||
importedAt: 123_456_789,
|
||||
},
|
||||
});
|
||||
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([
|
||||
expect(getUploadedFileNames()).toEqual([
|
||||
".gitignore",
|
||||
"dist/index.js",
|
||||
"openclaw.plugin.json",
|
||||
"package.json",
|
||||
]);
|
||||
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
|
||||
"OK. Published @scope/demo-plugin@1.0.0 (rel_1)",
|
||||
);
|
||||
expect(uiMocks.spinner.fail).not.toHaveBeenCalled();
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
expect(mockWrite).not.toHaveBeenCalled();
|
||||
dateSpy.mockRestore();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes a bundle plugin package with manifest-driven family detection", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "demo-bundle");
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "demo-bundle",
|
||||
displayName: "Demo Bundle",
|
||||
version: "0.4.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(folder, "openclaw.bundle.json"),
|
||||
JSON.stringify({ id: "demo.bundle", hostTargets: ["desktop", "mobile"] }),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "dist", "plugin.wasm"), "binary", "utf8");
|
||||
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "pkg_bundle",
|
||||
releaseId: "rel_bundle",
|
||||
});
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-bundle", {
|
||||
bundleFormat: "openclaw-bundle",
|
||||
hostTargets: "desktop,mobile",
|
||||
});
|
||||
|
||||
expect(getPublishPayload()).toEqual({
|
||||
name: "demo-bundle",
|
||||
displayName: "Demo Bundle",
|
||||
family: "bundle-plugin",
|
||||
version: "0.4.0",
|
||||
changelog: "",
|
||||
tags: ["latest"],
|
||||
bundle: {
|
||||
format: "openclaw-bundle",
|
||||
hostTargets: ["desktop", "mobile"],
|
||||
},
|
||||
});
|
||||
expect(getUploadedFileNames()).toEqual([
|
||||
"dist/plugin.wasm",
|
||||
"openclaw.bundle.json",
|
||||
"package.json",
|
||||
]);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -234,7 +315,7 @@ describe("package commands", () => {
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
JSON.stringify({ name: "demo-plugin", version: "1.0.0" }),
|
||||
makeCodePluginPackageJson({ name: "demo-plugin", version: "1.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "demo.plugin" }), "utf8");
|
||||
@@ -242,6 +323,364 @@ describe("package commands", () => {
|
||||
await expect(cmdPublishPackage(makeOpts(workdir), "demo-plugin", {})).rejects.toThrow(
|
||||
"--source-repo and --source-commit required for code plugins",
|
||||
);
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects code-plugin publish when openclaw.plugin.json is missing", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({ name: "demo-plugin", displayName: "Demo", version: "1.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(
|
||||
cmdPublishPackage(makeOpts(workdir), "demo-plugin", {
|
||||
family: "code-plugin",
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
sourceCommit: "abc123",
|
||||
}),
|
||||
).rejects.toThrow("openclaw.plugin.json required");
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects code-plugin publish when required OpenClaw compatibility metadata is missing", 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",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(folder, "openclaw.plugin.json"),
|
||||
JSON.stringify({ id: "demo.plugin", configSchema: { type: "object" } }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(
|
||||
cmdPublishPackage(makeOpts(workdir), "demo-plugin", {
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
sourceCommit: "abc123",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"openclaw.compat.pluginApi is required for external code plugins published to ClawHub.",
|
||||
);
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects bundle-plugin publish when host targets cannot be resolved", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "demo-bundle");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
JSON.stringify({ name: "demo-bundle", displayName: "Demo Bundle", version: "0.1.0" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(
|
||||
cmdPublishPackage(makeOpts(workdir), "demo-bundle", { family: "bundle-plugin" }),
|
||||
).rejects.toThrow("Bundle plugins need openclaw.bundle.json or --host-targets");
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("respects package ignore rules and built-in ignored directories", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "ignored-plugin");
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await mkdir(join(folder, "node_modules", "pkg"), { recursive: true });
|
||||
await mkdir(join(folder, ".git"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "ignored-plugin",
|
||||
displayName: "Ignored Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "ignored.plugin" }), "utf8");
|
||||
await writeFile(join(folder, ".clawhubignore"), "ignored.txt\n", "utf8");
|
||||
await writeFile(join(folder, "dist", "index.js"), "export {};\n", "utf8");
|
||||
await writeFile(join(folder, "ignored.txt"), "ignore me\n", "utf8");
|
||||
await writeFile(join(folder, "node_modules", "pkg", "index.js"), "module.exports = {};\n", "utf8");
|
||||
await writeFile(join(folder, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
|
||||
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "pkg_ignored",
|
||||
releaseId: "rel_ignored",
|
||||
});
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "ignored-plugin", {
|
||||
sourceRepo: "openclaw/ignored-plugin",
|
||||
sourceCommit: "abc123",
|
||||
});
|
||||
|
||||
expect(getUploadedFileNames()).toEqual([
|
||||
".clawhubignore",
|
||||
"dist/index.js",
|
||||
"openclaw.plugin.json",
|
||||
"package.json",
|
||||
]);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reports publish failures through the spinner without writing to stdout", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "broken-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "broken-plugin",
|
||||
displayName: "Broken Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "broken.plugin" }), "utf8");
|
||||
|
||||
httpMocks.apiRequestForm.mockRejectedValueOnce(new Error("Registry rejected upload"));
|
||||
|
||||
await expect(
|
||||
cmdPublishPackage(makeOpts(workdir), "broken-plugin", {
|
||||
sourceRepo: "openclaw/broken-plugin",
|
||||
sourceCommit: "deadbeef",
|
||||
}),
|
||||
).rejects.toThrow("Registry rejected upload");
|
||||
|
||||
expect(uiMocks.spinner.fail).toHaveBeenCalledWith("Registry rejected upload");
|
||||
expect(uiMocks.spinner.succeed).not.toHaveBeenCalled();
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
expect(mockWrite).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-detects local git source metadata and matches the explicit payload", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(987_654_321);
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "@scope/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"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");
|
||||
|
||||
runGit(folder, ["init", "-b", "main"]);
|
||||
runGit(folder, ["remote", "add", "origin", "git@github.com:openclaw/demo-plugin.git"]);
|
||||
runGit(folder, ["add", "."]);
|
||||
runGit(folder, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]);
|
||||
const commit = runGit(folder, ["rev-parse", "HEAD"]);
|
||||
runGit(folder, ["-c", "tag.gpgSign=false", "tag", "v1.0.0"]);
|
||||
|
||||
httpMocks.apiRequestForm.mockResolvedValue({
|
||||
ok: true,
|
||||
packageId: "pkg_1",
|
||||
releaseId: "rel_1",
|
||||
});
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-plugin", {
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
sourceCommit: commit,
|
||||
sourceRef: "v1.0.0",
|
||||
});
|
||||
const explicitPayload = getPublishPayload();
|
||||
const explicitFiles = getUploadedFileNames();
|
||||
|
||||
httpMocks.apiRequestForm.mockClear();
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-plugin", {});
|
||||
const inferredPayload = getPublishPayload();
|
||||
const inferredFiles = getUploadedFileNames();
|
||||
|
||||
expect(inferredPayload).toEqual(explicitPayload);
|
||||
expect(inferredFiles).toEqual(explicitFiles);
|
||||
dateSpy.mockRestore();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("lets explicit source flags override inferred git metadata", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(222_222_222);
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "demo.plugin" }), "utf8");
|
||||
|
||||
runGit(folder, ["init", "-b", "main"]);
|
||||
runGit(folder, ["remote", "add", "origin", "git@github.com:openclaw/demo-plugin.git"]);
|
||||
runGit(folder, ["add", "."]);
|
||||
runGit(folder, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]);
|
||||
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "pkg_1",
|
||||
releaseId: "rel_1",
|
||||
});
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-plugin", {
|
||||
sourceRepo: "openclaw/override-plugin",
|
||||
sourceCommit: "feedface",
|
||||
sourceRef: "refs/heads/release",
|
||||
sourcePath: "custom/path",
|
||||
});
|
||||
|
||||
expect(getPublishPayload()).toEqual({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "",
|
||||
tags: ["latest"],
|
||||
source: {
|
||||
kind: "github",
|
||||
url: "https://github.com/openclaw/override-plugin",
|
||||
repo: "openclaw/override-plugin",
|
||||
ref: "refs/heads/release",
|
||||
commit: "feedface",
|
||||
path: "custom/path",
|
||||
importedAt: 222_222_222,
|
||||
},
|
||||
});
|
||||
dateSpy.mockRestore();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("supports dry-run without auth or publish and prints a summary", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(444_444_444);
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"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");
|
||||
|
||||
runGit(folder, ["init", "-b", "main"]);
|
||||
runGit(folder, ["remote", "add", "origin", "git@github.com:openclaw/demo-plugin.git"]);
|
||||
runGit(folder, ["add", "."]);
|
||||
runGit(folder, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]);
|
||||
const commit = runGit(folder, ["rev-parse", "HEAD"]);
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-plugin", { dryRun: true });
|
||||
|
||||
expect(authTokenMocks.requireAuthToken).not.toHaveBeenCalled();
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
expect(mockLog.mock.calls.map((call) => call[0])).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Dry run - nothing will be published.",
|
||||
expect.stringMatching(/Source:\s+github:openclaw\/demo-plugin@main/),
|
||||
expect.stringMatching(/Name:\s+demo-plugin/),
|
||||
expect.stringMatching(new RegExp(`Commit:\\s+${commit}`)),
|
||||
"Files:",
|
||||
]),
|
||||
);
|
||||
expect(mockWrite).not.toHaveBeenCalled();
|
||||
dateSpy.mockRestore();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("supports dry-run json output without auth or publish", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "demo.plugin" }), "utf8");
|
||||
|
||||
runGit(folder, ["init", "-b", "main"]);
|
||||
runGit(folder, ["remote", "add", "origin", "git@github.com:openclaw/demo-plugin.git"]);
|
||||
runGit(folder, ["add", "."]);
|
||||
runGit(folder, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]);
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-plugin", { dryRun: true, json: true });
|
||||
|
||||
expect(authTokenMocks.requireAuthToken).not.toHaveBeenCalled();
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
expect(mockWrite).toHaveBeenCalledTimes(1);
|
||||
const output = String(mockWrite.mock.calls[0]?.[0] ?? "").trim();
|
||||
expect(JSON.parse(output)).toEqual({
|
||||
source: "github:openclaw/demo-plugin@main",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.0.0",
|
||||
commit: expect.any(String),
|
||||
files: 2,
|
||||
totalBytes: expect.any(Number),
|
||||
});
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import ignore from "ignore";
|
||||
import mime from "mime";
|
||||
import semver from "semver";
|
||||
import { apiRequest, apiRequestForm, fetchText, registryUrl } from "../../http.js";
|
||||
import {
|
||||
fetchGitHubSource,
|
||||
normalizeGitHubRepo,
|
||||
resolveLocalGitInfo,
|
||||
resolveSourceInput,
|
||||
} from "./github.js";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1PackageListResponseSchema,
|
||||
@@ -12,10 +18,12 @@ import {
|
||||
ApiV1PackageSearchResponseSchema,
|
||||
ApiV1PackageVersionListResponseSchema,
|
||||
ApiV1PackageVersionResponseSchema,
|
||||
normalizeOpenClawExternalPluginCompatibility,
|
||||
type PackageCapabilitySummary,
|
||||
type PackageCompatibility,
|
||||
type PackageFamily,
|
||||
type PackageVerificationSummary,
|
||||
validateOpenClawExternalCodePluginPackageJson,
|
||||
} from "../../schema/index.js";
|
||||
import { getOptionalAuthToken, requireAuthToken } from "../authToken.js";
|
||||
import { getRegistry } from "../registry.js";
|
||||
@@ -60,6 +68,8 @@ type PackagePublishOptions = {
|
||||
sourceCommit?: string;
|
||||
sourceRef?: string;
|
||||
sourcePath?: string;
|
||||
dryRun?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type PackageFile = {
|
||||
@@ -68,6 +78,50 @@ type PackageFile = {
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
type InferredPublishSource = {
|
||||
repo?: string;
|
||||
commit?: string;
|
||||
ref?: string;
|
||||
path?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type PackagePublishSource = ReturnType<typeof buildSource>;
|
||||
|
||||
type PackagePublishPayload = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
ownerHandle?: string;
|
||||
family: "code-plugin" | "bundle-plugin";
|
||||
version: string;
|
||||
changelog: string;
|
||||
tags: string[];
|
||||
source?: NonNullable<PackagePublishSource>;
|
||||
bundle?: {
|
||||
format?: string;
|
||||
hostTargets: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type PackagePublishPlan = {
|
||||
folder: string;
|
||||
cleanup?: () => Promise<void>;
|
||||
filesOnDisk: PackageFile[];
|
||||
payload: PackagePublishPayload;
|
||||
compatibility?: PackageCompatibility;
|
||||
sourceLabel: string;
|
||||
output: {
|
||||
source: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: "code-plugin" | "bundle-plugin";
|
||||
version: string;
|
||||
commit?: string;
|
||||
files: number;
|
||||
totalBytes: number;
|
||||
};
|
||||
};
|
||||
|
||||
type PrintableFile = {
|
||||
path: string;
|
||||
size: number | null;
|
||||
@@ -280,100 +334,77 @@ export async function cmdInspectPackage(
|
||||
|
||||
export async function cmdPublishPackage(
|
||||
opts: GlobalOpts,
|
||||
folderArg: string,
|
||||
sourceArg: string,
|
||||
options: PackagePublishOptions = {},
|
||||
) {
|
||||
const folder = folderArg ? resolve(opts.workdir, folderArg) : null;
|
||||
if (!folder) fail("Path required");
|
||||
const folderStat = await stat(folder).catch(() => null);
|
||||
if (!folderStat || !folderStat.isDirectory()) fail("Path must be a folder");
|
||||
if (!sourceArg?.trim()) fail("Path required");
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const filesOnDisk = await listPackageFiles(folder);
|
||||
if (filesOnDisk.length === 0) fail("No files found");
|
||||
|
||||
const fileSet = new Set(filesOnDisk.map((file) => file.relPath.toLowerCase()));
|
||||
const packageJson = await readJsonFile(join(folder, "package.json"));
|
||||
const family = detectPackageFamily(fileSet, options.family);
|
||||
const name =
|
||||
options.name?.trim() ||
|
||||
packageJsonString(packageJson, "name") ||
|
||||
basename(folder).trim().toLowerCase();
|
||||
const displayName =
|
||||
options.displayName?.trim() ||
|
||||
packageJsonString(packageJson, "displayName") ||
|
||||
titleCase(basename(folder));
|
||||
const ownerHandle = options.owner?.trim().replace(/^@+/, "");
|
||||
const version = options.version?.trim() || packageJsonString(packageJson, "version");
|
||||
const changelog = options.changelog ?? "";
|
||||
const tags = parseTags(options.tags ?? "latest");
|
||||
const source = buildSource(options);
|
||||
|
||||
if (!name) fail("--name required");
|
||||
if (!displayName) fail("--display-name required");
|
||||
if (!version) fail("--version required");
|
||||
if (family === "code-plugin" && !semver.valid(version)) {
|
||||
fail("--version must be valid semver for code plugins");
|
||||
}
|
||||
if (family === "code-plugin") {
|
||||
if (!fileSet.has("package.json")) fail("package.json required");
|
||||
if (!fileSet.has("openclaw.plugin.json")) fail("openclaw.plugin.json required");
|
||||
if (!source) fail("--source-repo and --source-commit required for code plugins");
|
||||
}
|
||||
if (family === "bundle-plugin") {
|
||||
const hostTargets = parseCsv(options.hostTargets);
|
||||
if (!fileSet.has("openclaw.bundle.json") && hostTargets.length === 0) {
|
||||
fail("Bundle plugins need openclaw.bundle.json or --host-targets");
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = createSpinner(`Preparing ${name}@${version}`);
|
||||
let plan: PackagePublishPlan | undefined;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
"payload",
|
||||
JSON.stringify({
|
||||
name,
|
||||
displayName,
|
||||
...(ownerHandle ? { ownerHandle } : {}),
|
||||
family,
|
||||
version,
|
||||
changelog,
|
||||
tags,
|
||||
...(source ? { source } : {}),
|
||||
...(family === "bundle-plugin"
|
||||
? {
|
||||
bundle: {
|
||||
format: options.bundleFormat?.trim() || undefined,
|
||||
hostTargets: parseCsv(options.hostTargets),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
plan = await preparePackagePublishPlan(opts, sourceArg, options);
|
||||
|
||||
let index = 0;
|
||||
for (const file of filesOnDisk) {
|
||||
index += 1;
|
||||
spinner.text = `Uploading ${file.relPath} (${index}/${filesOnDisk.length})`;
|
||||
const blob = new Blob([Buffer.from(file.bytes)], {
|
||||
type: file.contentType ?? "application/octet-stream",
|
||||
});
|
||||
form.append("files", blob, file.relPath);
|
||||
if (options.dryRun) {
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(plan.output, null, 2)}\n`);
|
||||
} else {
|
||||
printPackageDryRun({
|
||||
source: plan.sourceLabel,
|
||||
family: plan.payload.family,
|
||||
name: plan.payload.name,
|
||||
displayName: plan.payload.displayName,
|
||||
version: plan.payload.version,
|
||||
commit: plan.payload.source?.commit,
|
||||
compatibility: plan.compatibility,
|
||||
tags: plan.payload.tags,
|
||||
files: plan.filesOnDisk,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
spinner.text = `Publishing ${name}@${version}`;
|
||||
const result = await apiRequestForm(
|
||||
registry,
|
||||
{ method: "POST", path: ApiRoutes.packages, token, form },
|
||||
ApiV1PackagePublishResponseSchema,
|
||||
);
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = options.json
|
||||
? null
|
||||
: createSpinner(`Preparing ${plan.payload.name}@${plan.payload.version}`);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("payload", JSON.stringify(plan.payload));
|
||||
|
||||
spinner.succeed(`OK. Published ${name}@${version} (${result.releaseId})`);
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error));
|
||||
throw error;
|
||||
let index = 0;
|
||||
for (const file of plan.filesOnDisk) {
|
||||
index += 1;
|
||||
if (spinner) {
|
||||
spinner.text = `Uploading ${file.relPath} (${index}/${plan.filesOnDisk.length})`;
|
||||
}
|
||||
const blob = new Blob([Buffer.from(file.bytes)], {
|
||||
type: file.contentType ?? "application/octet-stream",
|
||||
});
|
||||
form.append("files", blob, file.relPath);
|
||||
}
|
||||
|
||||
if (spinner) spinner.text = `Publishing ${plan.payload.name}@${plan.payload.version}`;
|
||||
const result = await apiRequestForm(
|
||||
registry,
|
||||
{ method: "POST", path: ApiRoutes.packages, token, form },
|
||||
ApiV1PackagePublishResponseSchema,
|
||||
);
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ...plan.output, releaseId: result.releaseId }, null, 2)}\n`,
|
||||
);
|
||||
} else {
|
||||
spinner?.succeed(
|
||||
`OK. Published ${plan.payload.name}@${plan.payload.version} (${result.releaseId})`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await plan?.cleanup?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,7 +507,12 @@ function printVersionSummary(version: NonNullable<PackageVersionResponse["versio
|
||||
|
||||
function printCompatibility(compatibility: PackageCompatibility | null | undefined) {
|
||||
if (!compatibility) return;
|
||||
const entries = [
|
||||
const entries = formatCompatibilityEntries(compatibility);
|
||||
if (entries.length > 0) console.log(`Compatibility: ${entries.join(", ")}`);
|
||||
}
|
||||
|
||||
function formatCompatibilityEntries(compatibility: PackageCompatibility) {
|
||||
return [
|
||||
compatibility.pluginApiRange ? `pluginApi=${compatibility.pluginApiRange}` : null,
|
||||
compatibility.builtWithOpenClawVersion
|
||||
? `builtWith=${compatibility.builtWithOpenClawVersion}`
|
||||
@@ -484,7 +520,6 @@ function printCompatibility(compatibility: PackageCompatibility | null | undefin
|
||||
compatibility.pluginSdkVersion ? `sdk=${compatibility.pluginSdkVersion}` : null,
|
||||
compatibility.minGatewayVersion ? `minGateway=${compatibility.minGatewayVersion}` : null,
|
||||
].filter(Boolean);
|
||||
if (entries.length > 0) console.log(`Compatibility: ${entries.join(", ")}`);
|
||||
}
|
||||
|
||||
function printCapabilities(capabilities: PackageCapabilitySummary | null | undefined) {
|
||||
@@ -619,20 +654,158 @@ function parseCsv(value: string | undefined) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildSource(options: PackagePublishOptions) {
|
||||
const rawRepo = options.sourceRepo?.trim();
|
||||
const rawCommit = options.sourceCommit?.trim();
|
||||
const rawRef = options.sourceRef?.trim();
|
||||
const rawPath = options.sourcePath?.trim();
|
||||
async function preparePackagePublishPlan(
|
||||
opts: GlobalOpts,
|
||||
sourceArg: string,
|
||||
options: PackagePublishOptions,
|
||||
): Promise<PackagePublishPlan> {
|
||||
const resolvedSource = await resolveSourceInput(sourceArg, { workdir: opts.workdir });
|
||||
let folder = resolvedSource.kind === "local" ? resolvedSource.path : "";
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
let inferredSource: InferredPublishSource | undefined;
|
||||
|
||||
if (resolvedSource.kind === "github") {
|
||||
const fetchSpinner = options.json
|
||||
? null
|
||||
: createSpinner(`Fetching ${resolvedSource.owner}/${resolvedSource.repo}`);
|
||||
try {
|
||||
const fetched = await fetchGitHubSource(resolvedSource);
|
||||
folder = fetched.dir;
|
||||
cleanup = fetched.cleanup;
|
||||
inferredSource = fetched.source;
|
||||
fetchSpinner?.stop();
|
||||
} catch (error) {
|
||||
fetchSpinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const folderStat = await stat(folder).catch(() => null);
|
||||
if (!folderStat || !folderStat.isDirectory()) fail("Path must be a folder");
|
||||
|
||||
const localGitInfo = resolveLocalGitInfo(folder);
|
||||
if (localGitInfo) {
|
||||
inferredSource = {
|
||||
repo: localGitInfo.repo,
|
||||
commit: localGitInfo.commit,
|
||||
ref: localGitInfo.ref,
|
||||
path: localGitInfo.path,
|
||||
...(localGitInfo.repo ? { url: `https://github.com/${localGitInfo.repo}` } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const filesOnDisk = await listPackageFiles(folder);
|
||||
if (filesOnDisk.length === 0) fail("No files found");
|
||||
|
||||
const fileSet = new Set(filesOnDisk.map((file) => file.relPath.toLowerCase()));
|
||||
const packageJson = await readJsonFile(join(folder, "package.json"));
|
||||
const pluginManifest = await readJsonFile(join(folder, "openclaw.plugin.json"));
|
||||
const bundleManifest = await readJsonFile(join(folder, "openclaw.bundle.json"));
|
||||
const family = detectPackageFamily(fileSet, options.family);
|
||||
const name =
|
||||
options.name?.trim() ||
|
||||
packageJsonString(packageJson, "name") ||
|
||||
packageJsonString(pluginManifest, "id") ||
|
||||
packageJsonString(bundleManifest, "id") ||
|
||||
basename(folder).trim().toLowerCase();
|
||||
const displayName =
|
||||
options.displayName?.trim() ||
|
||||
packageJsonString(packageJson, "displayName") ||
|
||||
packageJsonString(pluginManifest, "name") ||
|
||||
packageJsonString(bundleManifest, "name") ||
|
||||
titleCase(basename(folder));
|
||||
const ownerHandle = options.owner?.trim().replace(/^@+/, "");
|
||||
const version = options.version?.trim() || packageJsonString(packageJson, "version");
|
||||
const changelog = options.changelog ?? "";
|
||||
const tags = parseTags(options.tags ?? "latest");
|
||||
const source = buildSource(options, inferredSource);
|
||||
|
||||
if (!name) fail("--name required");
|
||||
if (!displayName) fail("--display-name required");
|
||||
if (!version) fail("--version required");
|
||||
if (family === "code-plugin" && !semver.valid(version)) {
|
||||
fail("--version must be valid semver for code plugins");
|
||||
}
|
||||
if (family === "code-plugin") {
|
||||
if (!fileSet.has("package.json")) fail("package.json required");
|
||||
if (!fileSet.has("openclaw.plugin.json")) fail("openclaw.plugin.json required");
|
||||
if (!source) fail("--source-repo and --source-commit required for code plugins");
|
||||
const validation = validateOpenClawExternalCodePluginPackageJson(packageJson);
|
||||
if (validation.issues.length > 0) {
|
||||
fail(validation.issues.map((issue) => issue.message).join(" "));
|
||||
}
|
||||
}
|
||||
if (family === "bundle-plugin") {
|
||||
const hostTargets = parseCsv(options.hostTargets);
|
||||
if (!fileSet.has("openclaw.bundle.json") && hostTargets.length === 0) {
|
||||
fail("Bundle plugins need openclaw.bundle.json or --host-targets");
|
||||
}
|
||||
}
|
||||
|
||||
const payload: PackagePublishPayload = {
|
||||
name,
|
||||
displayName,
|
||||
...(ownerHandle ? { ownerHandle } : {}),
|
||||
family,
|
||||
version,
|
||||
changelog,
|
||||
tags,
|
||||
...(source ? { source } : {}),
|
||||
...(family === "bundle-plugin"
|
||||
? {
|
||||
bundle: {
|
||||
format: options.bundleFormat?.trim() || undefined,
|
||||
hostTargets: parseCsv(options.hostTargets),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const sourceLabel = describePublishSource(resolvedSource, source, folder);
|
||||
|
||||
return {
|
||||
folder,
|
||||
cleanup,
|
||||
filesOnDisk,
|
||||
payload,
|
||||
compatibility:
|
||||
family === "code-plugin"
|
||||
? normalizeOpenClawExternalPluginCompatibility(packageJson)
|
||||
: undefined,
|
||||
sourceLabel,
|
||||
output: {
|
||||
source: sourceLabel,
|
||||
name,
|
||||
displayName,
|
||||
family,
|
||||
version,
|
||||
...(source?.commit ? { commit: source.commit } : {}),
|
||||
files: filesOnDisk.length,
|
||||
totalBytes: filesOnDisk.reduce((sum, file) => sum + file.bytes.byteLength, 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildSource(
|
||||
options: PackagePublishOptions,
|
||||
inferred?: InferredPublishSource,
|
||||
) {
|
||||
const rawRepo = options.sourceRepo?.trim() || inferred?.repo?.trim();
|
||||
const rawCommit = options.sourceCommit?.trim() || inferred?.commit?.trim();
|
||||
const rawRef = options.sourceRef?.trim() || inferred?.ref?.trim();
|
||||
const rawPath = options.sourcePath?.trim() || inferred?.path?.trim();
|
||||
if (!rawRepo && !rawCommit && !rawRef && !rawPath) return undefined;
|
||||
if (!rawRepo || !rawCommit) fail("--source-repo and --source-commit must be set together");
|
||||
const repo = rawRepo
|
||||
.replace(/^https?:\/\/github\.com\//, "")
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/^\/+|\/+$/g, "");
|
||||
const repo = normalizeGitHubRepo(rawRepo);
|
||||
if (!repo) fail("--source-repo must be a GitHub repo or URL");
|
||||
const explicitRepo = options.sourceRepo?.trim();
|
||||
const url = explicitRepo
|
||||
? explicitRepo.startsWith("http")
|
||||
? explicitRepo
|
||||
: `https://github.com/${repo}`
|
||||
: inferred?.url || `https://github.com/${repo}`;
|
||||
return {
|
||||
kind: "github" as const,
|
||||
url: rawRepo.startsWith("http") ? rawRepo : `https://github.com/${repo}`,
|
||||
url,
|
||||
repo,
|
||||
ref: rawRef || rawCommit,
|
||||
commit: rawCommit,
|
||||
@@ -641,6 +814,64 @@ function buildSource(options: PackagePublishOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
function describePublishSource(
|
||||
sourceInput: Awaited<ReturnType<typeof resolveSourceInput>>,
|
||||
source: ReturnType<typeof buildSource>,
|
||||
folder: string,
|
||||
) {
|
||||
if (source) {
|
||||
return `github:${source.repo}@${source.ref}${source.path !== "." ? `:${source.path}` : ""}`;
|
||||
}
|
||||
if (sourceInput.kind === "github") {
|
||||
const repo = `${sourceInput.owner}/${sourceInput.repo}`;
|
||||
return `github:${repo}@${sourceInput.ref ?? "HEAD"}${
|
||||
sourceInput.path !== "." ? `:${sourceInput.path}` : ""
|
||||
}`;
|
||||
}
|
||||
return `local:${folder}`;
|
||||
}
|
||||
|
||||
function printPackageDryRun(params: {
|
||||
source: string;
|
||||
family: PackageFamily;
|
||||
name: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
commit?: string;
|
||||
compatibility?: PackageCompatibility;
|
||||
tags: string[];
|
||||
files: PackageFile[];
|
||||
}) {
|
||||
console.log("Dry run - nothing will be published.");
|
||||
console.log("");
|
||||
console.log(`Source: ${params.source}`);
|
||||
console.log(`Family: ${params.family}`);
|
||||
console.log(`Name: ${params.name}`);
|
||||
console.log(`Display: ${params.displayName}`);
|
||||
console.log(`Version: ${params.version}`);
|
||||
if (params.commit) console.log(`Commit: ${params.commit}`);
|
||||
if (params.compatibility) {
|
||||
console.log(`Compat: ${formatCompatibilityEntries(params.compatibility).join(", ")}`);
|
||||
}
|
||||
console.log(
|
||||
`Files: ${params.files.length} files (${formatByteCount(
|
||||
params.files.reduce((sum, file) => sum + file.bytes.byteLength, 0),
|
||||
)})`,
|
||||
);
|
||||
console.log(`Tags: ${params.tags.join(", ")}`);
|
||||
console.log("");
|
||||
console.log("Files:");
|
||||
for (const file of params.files) {
|
||||
console.log(` ${file.relPath.padEnd(28)} ${formatByteCount(file.bytes.byteLength)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatByteCount(value: number) {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
async function listPackageFiles(root: string) {
|
||||
const files: PackageFile[] = [];
|
||||
const absRoot = resolve(root);
|
||||
|
||||
@@ -4,32 +4,23 @@ 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";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
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 mockApiRequestForm = vi.fn();
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequestForm: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
mockApiRequestForm(registry, args, schema),
|
||||
}));
|
||||
|
||||
const mockFail = vi.fn((message: string) => {
|
||||
throw new Error(message);
|
||||
});
|
||||
const mockSpinner = { text: "", succeed: vi.fn(), fail: vi.fn() };
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => mockFail(message),
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
}));
|
||||
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 { cmdPublish } = await import("./publish");
|
||||
|
||||
@@ -38,18 +29,12 @@ async function makeTmpWorkdir() {
|
||||
return root;
|
||||
}
|
||||
|
||||
function makeOpts(workdir: string): GlobalOpts {
|
||||
return {
|
||||
workdir,
|
||||
dir: join(workdir, "skills"),
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
function makeOpts(workdir: string) {
|
||||
return makeGlobalOpts(workdir);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -64,7 +49,7 @@ describe("cmdPublish", () => {
|
||||
await writeFile(join(folder, "SKILL.md"), skillContent, "utf8");
|
||||
await writeFile(join(folder, "notes.md"), notesContent, "utf8");
|
||||
|
||||
mockApiRequestForm.mockResolvedValueOnce({
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
skillId: "skill_1",
|
||||
versionId: "ver_1",
|
||||
@@ -78,7 +63,7 @@ describe("cmdPublish", () => {
|
||||
tags: "latest",
|
||||
});
|
||||
|
||||
const publishCall = mockApiRequestForm.mock.calls.find((call) => {
|
||||
const publishCall = httpMocks.apiRequestForm.mock.calls.find((call) => {
|
||||
const req = call[1] as { path?: string } | undefined;
|
||||
return req?.path === "/api/v1/skills";
|
||||
});
|
||||
@@ -107,7 +92,7 @@ describe("cmdPublish", () => {
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(join(folder, "SKILL.md"), "# Skill\n", "utf8");
|
||||
|
||||
mockApiRequestForm.mockResolvedValueOnce({
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
skillId: "skill_1",
|
||||
versionId: "ver_2",
|
||||
@@ -119,7 +104,7 @@ describe("cmdPublish", () => {
|
||||
tags: "latest",
|
||||
});
|
||||
|
||||
expect(mockApiRequestForm).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequestForm).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ path: "/api/v1/skills", method: "POST" }),
|
||||
expect.anything(),
|
||||
@@ -128,4 +113,31 @@ describe("cmdPublish", () => {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects plugin folders with guidance to use "clawhub package publish"', 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", openclaw: { extensions: ["./index.ts"] } }),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), '{"id":"demo-plugin"}', "utf8");
|
||||
|
||||
await expect(
|
||||
cmdPublish(makeOpts(workdir), "demo-plugin", {
|
||||
slug: "demo-plugin",
|
||||
name: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
tags: "latest",
|
||||
}),
|
||||
).rejects.toThrow('This looks like a plugin. Use "clawhub package publish <source>" instead.');
|
||||
expect(authTokenMocks.requireAuthToken).not.toHaveBeenCalled();
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import semver from "semver";
|
||||
import { apiRequestForm } from "../../http.js";
|
||||
import { ApiRoutes, ApiV1PublishResponseSchema } from "../../schema/index.js";
|
||||
@@ -26,6 +26,9 @@ export async function cmdPublish(
|
||||
if (!folder) fail("Path required");
|
||||
const folderStat = await stat(folder).catch(() => null);
|
||||
if (!folderStat || !folderStat.isDirectory()) fail("Path must be a folder");
|
||||
if (await looksLikePluginFolder(folder)) {
|
||||
fail('This looks like a plugin. Use "clawhub package publish <source>" instead.');
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
@@ -96,6 +99,27 @@ export async function cmdPublish(
|
||||
}
|
||||
}
|
||||
|
||||
async function looksLikePluginFolder(folder: string) {
|
||||
const checks = [
|
||||
join(folder, "openclaw.plugin.json"),
|
||||
join(folder, "openclaw.bundle.json"),
|
||||
join(folder, "package.json"),
|
||||
];
|
||||
const stats = await Promise.all(checks.map((candidate) => stat(candidate).catch(() => null)));
|
||||
if (stats[0]?.isFile() || stats[1]?.isFile()) {
|
||||
return true;
|
||||
}
|
||||
if (!stats[2]?.isFile()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(checks[2], "utf8")) as { openclaw?: unknown };
|
||||
return Boolean(raw && typeof raw === "object" && raw.openclaw && typeof raw.openclaw === "object");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseForkOf(value: string) {
|
||||
const trimmed = value.trim();
|
||||
const [slugRaw, versionRaw] = trimmed.split("@");
|
||||
|
||||
@@ -1,67 +1,69 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as fsPromises from "node:fs/promises";
|
||||
import * as skillStore from "../../skills.js";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
import { ApiRoutes } from "../../schema/index.js";
|
||||
import type { GlobalOpts } from "../types";
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
const mockDownloadZip = 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);
|
||||
const fsMocks = vi.hoisted(() => ({
|
||||
mkdir: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
return {
|
||||
...actual,
|
||||
mkdir: fsMocks.mkdir,
|
||||
rm: fsMocks.rm,
|
||||
stat: fsMocks.stat,
|
||||
};
|
||||
});
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
|
||||
downloadZip: (...args: unknown[]) => mockDownloadZip(...args),
|
||||
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
|
||||
}));
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => "https://clawhub.ai");
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}));
|
||||
const mocked = <T,>(value: T) => value as T & Record<string, unknown>;
|
||||
Object.assign(vi as object, { mocked });
|
||||
|
||||
const mockGetOptionalAuthToken = vi.fn(async () => undefined as string | undefined);
|
||||
vi.mock("../authToken.js", () => ({
|
||||
getOptionalAuthToken: () => mockGetOptionalAuthToken(),
|
||||
}));
|
||||
|
||||
const mockSpinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
start: vi.fn(),
|
||||
succeed: vi.fn(),
|
||||
isSpinning: false,
|
||||
text: "",
|
||||
};
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
const mockApiRequest = httpMocks.apiRequest;
|
||||
const mockDownloadZip = httpMocks.downloadZip;
|
||||
const mockGetOptionalAuthToken = authTokenMocks.getOptionalAuthToken;
|
||||
const mockSpinner = uiMocks.spinner;
|
||||
const mockIsInteractive = vi.fn(() => false);
|
||||
const mockPromptConfirm = vi.fn(async () => false);
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
fail: (message: string) => uiMocks.fail(message),
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: mockIsInteractive,
|
||||
promptConfirm: mockPromptConfirm,
|
||||
}));
|
||||
|
||||
vi.mock("../../skills.js", () => ({
|
||||
extractZipToDir: vi.fn(),
|
||||
hashSkillFiles: vi.fn(),
|
||||
listTextFiles: vi.fn(),
|
||||
readLockfile: vi.fn(),
|
||||
readSkillOrigin: vi.fn(),
|
||||
writeLockfile: vi.fn(),
|
||||
writeSkillOrigin: vi.fn(),
|
||||
}));
|
||||
const extractZipToDirMock = vi.spyOn(skillStore, "extractZipToDir");
|
||||
const hashSkillFilesMock = vi.spyOn(skillStore, "hashSkillFiles");
|
||||
const listTextFilesMock = vi.spyOn(skillStore, "listTextFiles");
|
||||
const readLockfileMock = vi.spyOn(skillStore, "readLockfile");
|
||||
const readSkillOriginMock = vi.spyOn(skillStore, "readSkillOrigin");
|
||||
const writeLockfileMock = vi.spyOn(skillStore, "writeLockfile");
|
||||
const writeSkillOriginMock = vi.spyOn(skillStore, "writeSkillOrigin");
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
mkdir: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
const mkdirMock = fsMocks.mkdir;
|
||||
const rmMock = fsMocks.rm;
|
||||
const statMock = fsMocks.stat;
|
||||
const commandSkillsModuleSpecifier = "./skills.js?command-skills-test" as string;
|
||||
|
||||
const {
|
||||
clampLimit,
|
||||
@@ -71,7 +73,7 @@ const {
|
||||
cmdUninstall,
|
||||
cmdUpdate,
|
||||
formatExploreLine,
|
||||
} = await import("./skills");
|
||||
} = (await import(commandSkillsModuleSpecifier)) as typeof import("./skills");
|
||||
const {
|
||||
extractZipToDir,
|
||||
hashSkillFiles,
|
||||
@@ -80,25 +82,42 @@ const {
|
||||
readSkillOrigin,
|
||||
writeLockfile,
|
||||
writeSkillOrigin,
|
||||
} = await import("../../skills.js");
|
||||
const { rm, stat } = await import("node:fs/promises");
|
||||
} = skillStore;
|
||||
const { rm, stat } = fsPromises;
|
||||
|
||||
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",
|
||||
};
|
||||
function makeOpts() {
|
||||
return makeGlobalOpts();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirMock.mockResolvedValue(undefined);
|
||||
rmMock.mockResolvedValue(undefined);
|
||||
statMock.mockRejectedValue(new Error("missing"));
|
||||
extractZipToDirMock.mockResolvedValue(undefined);
|
||||
hashSkillFilesMock.mockReturnValue({ fingerprint: "hash", files: [] });
|
||||
listTextFilesMock.mockResolvedValue([]);
|
||||
readLockfileMock.mockResolvedValue({ version: 1, skills: {} });
|
||||
readSkillOriginMock.mockResolvedValue(null);
|
||||
writeLockfileMock.mockResolvedValue(undefined);
|
||||
writeSkillOriginMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
extractZipToDirMock.mockRestore();
|
||||
hashSkillFilesMock.mockRestore();
|
||||
listTextFilesMock.mockRestore();
|
||||
readLockfileMock.mockRestore();
|
||||
readSkillOriginMock.mockRestore();
|
||||
writeLockfileMock.mockRestore();
|
||||
writeSkillOriginMock.mockRestore();
|
||||
});
|
||||
|
||||
describe("explore helpers", () => {
|
||||
it("clamps explore limits and handles non-finite values", () => {
|
||||
expect(clampLimit(-5)).toBe(1);
|
||||
@@ -512,10 +531,10 @@ describe("cmdUninstall", () => {
|
||||
|
||||
await cmdUninstall(makeOpts(), "demo", { yes: true }, false);
|
||||
|
||||
const rmMock = vi.mocked(rm);
|
||||
const writeLockfileMock = vi.mocked(writeLockfile);
|
||||
expect(rmMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
writeLockfileMock.mock.invocationCallOrder[0],
|
||||
const rmCallMock = vi.mocked(rm);
|
||||
const writeLockfileCallMock = vi.mocked(writeLockfile);
|
||||
expect(rmCallMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
writeLockfileCallMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
const mockIntro = vi.fn();
|
||||
const mockOutro = vi.fn();
|
||||
const mockLog = vi.fn();
|
||||
const mockMultiselect = vi.fn(async (_args?: unknown) => [] as string[]);
|
||||
let interactive = false;
|
||||
const mocked = <T,>(value: T) => value as T & { mockImplementation: (...args: unknown[]) => unknown };
|
||||
|
||||
const defaultFindSkillFolders = async (root: string) => {
|
||||
if (!root.endsWith("/scan")) return [];
|
||||
@@ -26,30 +33,25 @@ vi.mock("@clack/prompts", () => ({
|
||||
isCancel: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => "https://clawhub.ai");
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}));
|
||||
|
||||
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);
|
||||
});
|
||||
const mockSpinner = { succeed: vi.fn(), fail: vi.fn(), stop: vi.fn() };
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
httpMocks.downloadZip.mockImplementation(
|
||||
async (_registry?: unknown, _args?: unknown) => new Uint8Array([1, 2, 3]),
|
||||
);
|
||||
const mockApiRequest = httpMocks.apiRequest;
|
||||
const mockFail = uiMocks.fail;
|
||||
const mockSpinner = uiMocks.spinner;
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => mockFail(message),
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => interactive,
|
||||
promptConfirm: uiMocks.promptConfirm,
|
||||
}));
|
||||
|
||||
vi.mock("../scanSkills.js", () => ({
|
||||
@@ -68,37 +70,40 @@ vi.mock("../clawdbotConfig.js", () => ({
|
||||
resolveClawdbotSkillRoots: () => mockResolveClawdbotSkillRoots(),
|
||||
}));
|
||||
|
||||
vi.mock("../../skills.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../skills.js")>("../../skills.js");
|
||||
return {
|
||||
...actual,
|
||||
listTextFiles: vi.fn(async (folder: string) => [
|
||||
{ relPath: "SKILL.md", bytes: new TextEncoder().encode(folder) },
|
||||
]),
|
||||
};
|
||||
});
|
||||
const mockListTextFiles = vi.fn(async (folder: string) => [
|
||||
{ relPath: "SKILL.md", bytes: new TextEncoder().encode(folder) },
|
||||
]);
|
||||
const mockHashSkillFiles = vi.fn((files: Array<{ relPath: string; bytes: Uint8Array }>) => ({
|
||||
fingerprint: files.map((file) => `${file.relPath}:${Buffer.from(file.bytes).toString("hex")}`).join("|"),
|
||||
files: [],
|
||||
}));
|
||||
const mockHashSkillZip = vi.fn((_zip?: Uint8Array) => ({
|
||||
fingerprint: "remote-fingerprint",
|
||||
files: [],
|
||||
}));
|
||||
const mockReadSkillOrigin = vi.fn(async (_folder?: string) => null);
|
||||
vi.mock("../../skills.js", () => ({
|
||||
listTextFiles: (folder: string) => mockListTextFiles(folder),
|
||||
hashSkillFiles: (files: Array<{ relPath: string; bytes: Uint8Array }>) => mockHashSkillFiles(files),
|
||||
hashSkillZip: (zip: Uint8Array) => mockHashSkillZip(zip),
|
||||
readSkillOrigin: (folder: string) => mockReadSkillOrigin(folder),
|
||||
}));
|
||||
|
||||
const mockCmdPublish = vi.fn();
|
||||
vi.mock("./publish.js", () => ({
|
||||
cmdPublish: (...args: unknown[]) => mockCmdPublish(...args),
|
||||
cmdPublish: (opts: unknown, folder: unknown, options?: unknown) => mockCmdPublish(opts, folder, options),
|
||||
}));
|
||||
|
||||
const { cmdSync } = await import("./sync");
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
function makeOpts() {
|
||||
return makeGlobalOpts();
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { findSkillFolders } = await import("../scanSkills.js");
|
||||
vi.mocked(findSkillFolders).mockImplementation(defaultFindSkillFolders);
|
||||
mocked(findSkillFolders).mockImplementation(defaultFindSkillFolders);
|
||||
});
|
||||
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => {
|
||||
@@ -205,7 +210,7 @@ describe("cmdSync", () => {
|
||||
it("dedupes duplicate slugs before publishing", async () => {
|
||||
interactive = false;
|
||||
const { findSkillFolders } = await import("../scanSkills.js");
|
||||
vi.mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
if (!root.endsWith("/scan")) return [];
|
||||
return [
|
||||
{ folder: "/scan/dup-skill", slug: "dup-skill", displayName: "Dup Skill" },
|
||||
@@ -237,7 +242,7 @@ describe("cmdSync", () => {
|
||||
labels: { "/auto": "Agent: Work" },
|
||||
});
|
||||
const { findSkillFolders } = await import("../scanSkills.js");
|
||||
vi.mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
if (root === "/auto") {
|
||||
return [{ folder: "/auto/alpha", slug: "alpha", displayName: "Alpha" }];
|
||||
}
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
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(), stop: 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),
|
||||
}));
|
||||
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 {
|
||||
cmdTransferAccept,
|
||||
@@ -35,16 +27,6 @@ const {
|
||||
cmdTransferRequest,
|
||||
} = await import("./transfer");
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -53,13 +35,15 @@ afterEach(() => {
|
||||
|
||||
describe("transfer commands", () => {
|
||||
it("request requires --yes when input is disabled", async () => {
|
||||
await expect(cmdTransferRequest(makeOpts(), "demo", "@alice", {}, false)).rejects.toThrow(
|
||||
await expect(
|
||||
cmdTransferRequest(makeGlobalOpts(), "demo", "@alice", {}, false),
|
||||
).rejects.toThrow(
|
||||
/--yes/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("request calls transfer endpoint", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
transferId: "skillOwnershipTransfers:1",
|
||||
toUserHandle: "alice",
|
||||
@@ -67,14 +51,14 @@ describe("transfer commands", () => {
|
||||
});
|
||||
|
||||
await cmdTransferRequest(
|
||||
makeOpts(),
|
||||
makeGlobalOpts(),
|
||||
"Demo",
|
||||
"@Alice",
|
||||
{ yes: true, message: "Please take over" },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -82,16 +66,16 @@ describe("transfer commands", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
const requestArgs = mockApiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
expect(requestArgs.body).toContain('"toUserHandle":"alice"');
|
||||
});
|
||||
|
||||
it("list calls incoming transfers endpoint", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
transfers: [],
|
||||
});
|
||||
await cmdTransferList(makeOpts(), {});
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
await cmdTransferList(makeGlobalOpts(), {});
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
@@ -103,11 +87,11 @@ describe("transfer commands", () => {
|
||||
});
|
||||
|
||||
it("list supports outgoing endpoint", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
transfers: [],
|
||||
});
|
||||
await cmdTransferList(makeOpts(), { outgoing: true });
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
await cmdTransferList(makeGlobalOpts(), { outgoing: true });
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
@@ -119,26 +103,26 @@ describe("transfer commands", () => {
|
||||
});
|
||||
|
||||
it("accept/reject/cancel call action endpoints", async () => {
|
||||
mockApiRequest.mockResolvedValue({
|
||||
httpMocks.apiRequest.mockResolvedValue({
|
||||
ok: true,
|
||||
skillSlug: "demo",
|
||||
});
|
||||
|
||||
await cmdTransferAccept(makeOpts(), "demo", { yes: true }, false);
|
||||
await cmdTransferReject(makeOpts(), "demo", { yes: true }, false);
|
||||
await cmdTransferCancel(makeOpts(), "demo", { yes: true }, false);
|
||||
await cmdTransferAccept(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
await cmdTransferReject(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
await cmdTransferCancel(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/transfer/accept" }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/transfer/reject" }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/transfer/cancel" }),
|
||||
expect.anything(),
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEnvStubRegistry } from "../test/runtimeStubs.js";
|
||||
|
||||
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 fsMocks = vi.hoisted(() => ({
|
||||
chmod: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const { writeGlobalConfig } = await import("./config");
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
return {
|
||||
...actual,
|
||||
chmod: fsMocks.chmod,
|
||||
mkdir: fsMocks.mkdir,
|
||||
readFile: fsMocks.readFile,
|
||||
writeFile: fsMocks.writeFile,
|
||||
};
|
||||
});
|
||||
|
||||
const configModuleSpecifier = "./config.js?config-test" as string;
|
||||
|
||||
const { writeGlobalConfig } = (await import(configModuleSpecifier)) as typeof import("./config");
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
const testConfigPath = "/tmp/clawhub-config-test/config.json";
|
||||
const envStubs = createEnvStubRegistry();
|
||||
|
||||
function makeErr(code: string): NodeJS.ErrnoException {
|
||||
const error = new Error(code) as NodeJS.ErrnoException;
|
||||
@@ -26,29 +36,33 @@ function makeErr(code: string): NodeJS.ErrnoException {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CLAWHUB_CONFIG_PATH", testConfigPath);
|
||||
envStubs.stub("CLAWHUB_CONFIG_PATH", testConfigPath);
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
chmodMock.mockResolvedValue(undefined);
|
||||
mkdirMock.mockResolvedValue(undefined);
|
||||
readFileMock.mockResolvedValue("");
|
||||
writeFileMock.mockResolvedValue(undefined);
|
||||
fsMocks.chmod.mockResolvedValue(undefined);
|
||||
fsMocks.mkdir.mockResolvedValue(undefined);
|
||||
fsMocks.readFile.mockResolvedValue("");
|
||||
fsMocks.writeFile.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
||||
vi.unstubAllEnvs();
|
||||
envStubs.restoreAll();
|
||||
vi.clearAllMocks();
|
||||
fsMocks.chmod.mockReset();
|
||||
fsMocks.mkdir.mockReset();
|
||||
fsMocks.readFile.mockReset();
|
||||
fsMocks.writeFile.mockReset();
|
||||
});
|
||||
|
||||
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", {
|
||||
expect(fsMocks.mkdir).toHaveBeenCalledWith("/tmp/clawhub-config-test", {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
expect(writeFileMock).toHaveBeenCalledWith(
|
||||
expect(fsMocks.writeFile).toHaveBeenCalledWith(
|
||||
testConfigPath,
|
||||
expect.stringContaining('"token": "clh_test"'),
|
||||
{
|
||||
@@ -56,17 +70,17 @@ describe("writeGlobalConfig", () => {
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
expect(chmodMock).toHaveBeenCalledWith(testConfigPath, 0o600);
|
||||
expect(fsMocks.chmod).toHaveBeenCalledWith(testConfigPath, 0o600);
|
||||
});
|
||||
|
||||
it("ignores non-fatal chmod errors", async () => {
|
||||
chmodMock.mockRejectedValueOnce(makeErr("ENOTSUP"));
|
||||
fsMocks.chmod.mockRejectedValueOnce(makeErr("ENOTSUP"));
|
||||
|
||||
await expect(writeGlobalConfig({ registry: "https://example.com" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rethrows unexpected chmod errors", async () => {
|
||||
chmodMock.mockRejectedValueOnce(new Error("boom"));
|
||||
fsMocks.chmod.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
await expect(writeGlobalConfig({ registry: "https://example.com" })).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createGlobalStubRegistry } from "../test/runtimeStubs.js";
|
||||
import { discoverRegistryFromSite } from "./discovery";
|
||||
|
||||
const globalStubs = createGlobalStubRegistry();
|
||||
|
||||
describe("discovery", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
globalStubs.restoreAll();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns null on non-ok response", async () => {
|
||||
vi.stubGlobal(
|
||||
globalStubs.stub(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("nope", { status: 404 })) as unknown as typeof fetch,
|
||||
);
|
||||
@@ -17,7 +22,7 @@ describe("discovery", () => {
|
||||
});
|
||||
|
||||
it("parses registry config", async () => {
|
||||
vi.stubGlobal(
|
||||
globalStubs.stub(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
@@ -35,7 +40,7 @@ describe("discovery", () => {
|
||||
});
|
||||
|
||||
it("parses apiBase config", async () => {
|
||||
vi.stubGlobal(
|
||||
globalStubs.stub(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
@@ -60,7 +65,7 @@ describe("discovery", () => {
|
||||
});
|
||||
|
||||
it("returns null when apiBase is empty", async () => {
|
||||
vi.stubGlobal(
|
||||
globalStubs.stub(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
|
||||
@@ -1,283 +1,162 @@
|
||||
/* @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,
|
||||
});
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createHttpClient } from "./http.js";
|
||||
|
||||
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>),
|
||||
};
|
||||
});
|
||||
type SpawnResult = {
|
||||
status: number | null;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
function createBunClient(options?: {
|
||||
spawnImpl?: (...args: unknown[]) => SpawnResult;
|
||||
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>,
|
||||
const spawnImpl = vi.fn(options?.spawnImpl ?? (() => ({ status: 0, stdout: "", stderr: "" })));
|
||||
const mkdirImpl = vi.fn(async () => undefined);
|
||||
const mkdtempImpl = vi.fn(async () => options?.mkdtempValue ?? "/tmp/clawhub-test");
|
||||
const rmImpl = vi.fn(async () => undefined);
|
||||
const writeFileImpl = vi.fn(async () => undefined);
|
||||
const readFileImpl = vi.fn(
|
||||
async () => (options?.readFileValue ?? Buffer.from([1, 2, 3])) as Buffer<ArrayBuffer>,
|
||||
);
|
||||
const setTimeoutImpl = vi.fn((callback: () => void, _ms?: number) => {
|
||||
callback();
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutImpl = vi.fn();
|
||||
|
||||
return {
|
||||
http,
|
||||
spawnSync: bunRuntimeMocks.spawnSync,
|
||||
mkdir: bunRuntimeMocks.mkdir,
|
||||
mkdtemp: bunRuntimeMocks.mkdtemp,
|
||||
rm: bunRuntimeMocks.rm,
|
||||
writeFile: bunRuntimeMocks.writeFile,
|
||||
readFile: bunRuntimeMocks.readFile,
|
||||
client: createHttpClient({
|
||||
runtime: "bun",
|
||||
configureDispatcher: false,
|
||||
spawnSyncImpl: spawnImpl as unknown as typeof import("node:child_process").spawnSync,
|
||||
mkdirImpl: mkdirImpl as unknown as typeof import("node:fs/promises").mkdir,
|
||||
mkdtempImpl: mkdtempImpl as unknown as typeof import("node:fs/promises").mkdtemp,
|
||||
rmImpl: rmImpl as unknown as typeof import("node:fs/promises").rm,
|
||||
writeFileImpl: writeFileImpl as unknown as typeof import("node:fs/promises").writeFile,
|
||||
readFileImpl: readFileImpl as unknown as typeof import("node:fs/promises").readFile,
|
||||
setTimeoutImpl: setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl,
|
||||
tmpdirPath: "/tmp",
|
||||
random: () => 0,
|
||||
}),
|
||||
spawnImpl,
|
||||
mkdirImpl,
|
||||
mkdtempImpl,
|
||||
rmImpl,
|
||||
writeFileImpl,
|
||||
readFileImpl,
|
||||
setTimeoutImpl,
|
||||
clearTimeoutImpl,
|
||||
};
|
||||
}
|
||||
|
||||
describe("http bun runtime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(process.versions, "bun", {
|
||||
value: "1.2.3",
|
||||
configurable: true,
|
||||
describe("bun http client", () => {
|
||||
it("uses curl for apiRequest GET and POST", async () => {
|
||||
const { client, spawnImpl } = createBunClient({
|
||||
spawnImpl: () => ({ status: 0, stdout: '{"ok":true}\n200', stderr: "" }),
|
||||
});
|
||||
});
|
||||
|
||||
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", {
|
||||
const getResult = await client.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", {
|
||||
await client.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}');
|
||||
expect(getResult).toEqual({ ok: true });
|
||||
const [, getArgs] = spawnImpl.mock.calls[0] as [string, string[]];
|
||||
expect(getArgs).toContain("GET");
|
||||
expect(getArgs).toContain("https://registry.example/v1/ping");
|
||||
expect(getArgs).toContain("Authorization: Bearer clh_token");
|
||||
|
||||
const [, postArgs] = spawnImpl.mock.calls[1] as [string, string[]];
|
||||
expect(postArgs).toContain("Content-Type: application/json");
|
||||
expect(postArgs).toContain("--data-binary");
|
||||
expect(postArgs).toContain('{"a":1}');
|
||||
});
|
||||
|
||||
it("retries bun apiRequest on 429 errors", async () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: "rate limited\n429",
|
||||
stderr: "",
|
||||
it("retries 429 responses and keeps 404 non-retryable", async () => {
|
||||
const rateLimited = createBunClient({
|
||||
spawnImpl: () => ({ status: 0, stdout: "rate limited\n429", stderr: "" }),
|
||||
});
|
||||
const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync });
|
||||
|
||||
await expect(
|
||||
httpClient.apiRequest("https://registry.example", {
|
||||
rateLimited.client.apiRequest("https://registry.example", {
|
||||
method: "GET",
|
||||
path: "/v1/ping",
|
||||
}),
|
||||
).rejects.toThrow("rate limited");
|
||||
expect(rateLimited.spawnImpl).toHaveBeenCalledTimes(3);
|
||||
|
||||
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 missing = createBunClient({
|
||||
spawnImpl: () => ({ 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(/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", {
|
||||
missing.client.apiRequest("https://registry.example", {
|
||||
method: "GET",
|
||||
path: "/v1/ping",
|
||||
}),
|
||||
).rejects.toThrow("missing");
|
||||
|
||||
expect(spawnSync).toHaveBeenCalledTimes(1);
|
||||
expect(missing.spawnImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("supports fetchText bun path and propagates status fallback", async () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
it("includes curl rate-limit metadata in 429 errors", async () => {
|
||||
const { client, spawnImpl } = createBunClient({
|
||||
spawnImpl: () => ({
|
||||
status: 0,
|
||||
stdout: "hello world\n200",
|
||||
stdout: "rate limited\n__CLAWHUB_CURL_META__\n429\n20\n0\n1771404540\n20\n0\n34\n34\n",
|
||||
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");
|
||||
client.apiRequest("https://registry.example", {
|
||||
method: "GET",
|
||||
path: "/v1/ping",
|
||||
}),
|
||||
).rejects.toThrow(/retry in 34s.*remaining: 0\/20.*reset in 34s/i);
|
||||
expect(spawnImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
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,
|
||||
it("supports fetchText and downloadZip via curl", async () => {
|
||||
const { client, spawnImpl, readFileImpl, rmImpl } = createBunClient({
|
||||
spawnImpl: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 0, stdout: "hello world\n200", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "200", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "404", stderr: "" }),
|
||||
mkdtempValue: "/tmp/clawhub-download-abc",
|
||||
readFileValue: Buffer.from("not found"),
|
||||
});
|
||||
|
||||
const bytes = await httpClient.downloadZip("https://registry.example", {
|
||||
slug: "demo",
|
||||
token: "t",
|
||||
});
|
||||
await expect(client.fetchText("https://registry.example", { path: "/v1/readme" })).resolves.toBe(
|
||||
"hello world",
|
||||
);
|
||||
const bytes = await client.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" }),
|
||||
client.downloadZip("https://registry.example", { slug: "demo", token: "t" }),
|
||||
).rejects.toThrow("not found");
|
||||
|
||||
expect(readFile).toHaveBeenCalled();
|
||||
expect(rm).toHaveBeenCalledWith("/tmp/clawhub-download-abc", {
|
||||
expect(readFileImpl).toHaveBeenCalled();
|
||||
expect(rmImpl).toHaveBeenCalledWith("/tmp/clawhub-download-abc", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
expect(spawnImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
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,
|
||||
it("posts multipart form data via curl and cleans up temp files", async () => {
|
||||
const { client, spawnImpl, mkdirImpl, writeFileImpl, rmImpl } = createBunClient({
|
||||
spawnImpl: () => ({ status: 0, stdout: '{"ok":true}\n200', stderr: "" }),
|
||||
mkdtempValue: "/tmp/clawhub-upload-abc",
|
||||
});
|
||||
|
||||
@@ -285,19 +164,24 @@ describe("http bun runtime", () => {
|
||||
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", {
|
||||
const result = await client.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(mkdirImpl).toHaveBeenCalledWith("/tmp/clawhub-upload-abc/dist", { recursive: true });
|
||||
expect(writeFileImpl).toHaveBeenCalled();
|
||||
expect(rmImpl).toHaveBeenCalledWith("/tmp/clawhub-upload-abc", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
const [, args] = spawnImpl.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);
|
||||
expect(args.some((arg) => arg.includes("file=@/tmp/clawhub-upload-abc/dist/demo.txt"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+145
-220
@@ -1,31 +1,39 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
apiRequest,
|
||||
apiRequestForm,
|
||||
downloadZip,
|
||||
fetchText,
|
||||
registryUrl,
|
||||
shouldUseProxyFromEnv,
|
||||
} from "./http";
|
||||
import { createHttpClient, detectHttpRuntime, registryUrl, shouldUseProxyFromEnv } from "./http.js";
|
||||
import { ApiV1WhoamiResponseSchema } from "./schema/index.js";
|
||||
|
||||
function mockImmediateTimeouts() {
|
||||
const setTimeoutMock = vi.fn((callback: () => void, _ms?: number) => {
|
||||
function createNodeClient(options?: {
|
||||
fetchImpl?: typeof fetch;
|
||||
setTimeoutImpl?: typeof setTimeout;
|
||||
clearTimeoutImpl?: typeof clearTimeout;
|
||||
now?: () => number;
|
||||
}) {
|
||||
return createHttpClient({
|
||||
runtime: "node",
|
||||
configureDispatcher: false,
|
||||
fetchImpl: options?.fetchImpl,
|
||||
setTimeoutImpl: options?.setTimeoutImpl,
|
||||
clearTimeoutImpl: options?.clearTimeoutImpl,
|
||||
now: options?.now,
|
||||
random: () => 0,
|
||||
});
|
||||
}
|
||||
|
||||
function createImmediateTimeouts() {
|
||||
const setTimeoutImpl = 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 };
|
||||
const clearTimeoutImpl = vi.fn();
|
||||
return { setTimeoutImpl, clearTimeoutImpl };
|
||||
}
|
||||
|
||||
function createAbortingFetchMock() {
|
||||
return vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const signal = init?.signal;
|
||||
if (!signal || !(signal instanceof AbortSignal)) {
|
||||
if (!(signal instanceof AbortSignal)) {
|
||||
throw new Error("Missing abort signal");
|
||||
}
|
||||
if (signal.aborted) {
|
||||
@@ -43,6 +51,13 @@ function createAbortingFetchMock() {
|
||||
});
|
||||
}
|
||||
|
||||
describe("detectHttpRuntime", () => {
|
||||
it("detects bun and node runtimes explicitly", () => {
|
||||
expect(detectHttpRuntime({ bun: "1.2.3" } as unknown as NodeJS.ProcessVersions)).toBe("bun");
|
||||
expect(detectHttpRuntime({ node: "22.0.0" } as unknown as NodeJS.ProcessVersions)).toBe("node");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUseProxyFromEnv", () => {
|
||||
it("detects standard proxy variables", () => {
|
||||
expect(
|
||||
@@ -73,94 +88,60 @@ describe("shouldUseProxyFromEnv", () => {
|
||||
});
|
||||
|
||||
describe("registryUrl", () => {
|
||||
it("works with a plain-origin registry (no base path)", () => {
|
||||
it("preserves registry base paths and normalizes slashes", () => {
|
||||
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",
|
||||
expect(registryUrl("/api/v1/skills", "http://localhost:8081/custom/path").toString()).toBe(
|
||||
"http://localhost:8081/custom/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",
|
||||
expect(registryUrl("api/v1/skills", "http://localhost:8081/custom/path/").toString()).toBe(
|
||||
"http://localhost:8081/custom/path/api/v1/skills",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiRequest", () => {
|
||||
describe("node http client", () => {
|
||||
it("adds bearer token and parses json", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ user: { handle: null } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const result = await apiRequest(
|
||||
const client = createNodeClient({ fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
const result = await client.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];
|
||||
const [, init] = fetchImpl.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({
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await apiRequest("https://example.com", {
|
||||
const client = createNodeClient({ fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
await client.apiRequest("https://example.com", {
|
||||
method: "POST",
|
||||
path: "/x",
|
||||
body: { a: 1 },
|
||||
});
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
|
||||
const [url, init] = fetchImpl.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({
|
||||
it("includes rate-limit guidance from response headers on 429", async () => {
|
||||
const { setTimeoutImpl, clearTimeoutImpl } = createImmediateTimeouts();
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: new Headers({
|
||||
@@ -171,19 +152,22 @@ describe("apiRequest", () => {
|
||||
}),
|
||||
text: async () => "Rate limit exceeded",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = createNodeClient({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
setTimeoutImpl: setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl,
|
||||
});
|
||||
|
||||
await expect(apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
await expect(client.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();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
expect(clearTimeoutImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
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({
|
||||
const { setTimeoutImpl, clearTimeoutImpl } = createImmediateTimeouts();
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: new Headers({
|
||||
@@ -193,205 +177,146 @@ describe("apiRequest", () => {
|
||||
}),
|
||||
text: async () => "Rate limit exceeded",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = createNodeClient({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
setTimeoutImpl: setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl,
|
||||
now: () => 1_771_404_500_000,
|
||||
});
|
||||
|
||||
await expect(apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
await expect(client.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({
|
||||
it("falls back to HTTP status when response bodies are empty", async () => {
|
||||
const { setTimeoutImpl, clearTimeoutImpl } = createImmediateTimeouts();
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = createNodeClient({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
setTimeoutImpl: setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl,
|
||||
});
|
||||
|
||||
await expect(
|
||||
apiRequest("https://example.com", { method: "GET", url: "https://example.com/x" }),
|
||||
client.apiRequest("https://example.com", { method: "GET", url: "https://example.com/x" }),
|
||||
).rejects.toThrow("HTTP 500");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
vi.unstubAllGlobals();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
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", {
|
||||
it("downloads zip bytes and does not retry non-retryable errors", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => "nope",
|
||||
});
|
||||
const client = createNodeClient({ fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
const bytes = await client.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();
|
||||
|
||||
await expect(client.downloadZip("https://example.com", { slug: "demo" })).rejects.toThrow(
|
||||
"nope",
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry on non-retryable errors", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => "nope",
|
||||
it("retries request and text timeouts using injected timeout helpers", async () => {
|
||||
const { setTimeoutImpl, clearTimeoutImpl } = createImmediateTimeouts();
|
||||
const fetchImpl = createAbortingFetchMock();
|
||||
const client = createNodeClient({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
setTimeoutImpl: setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl,
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(downloadZip("https://example.com", { slug: "demo" })).rejects.toThrow("nope");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
await expect(client.apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
/timed out/i,
|
||||
);
|
||||
await expect(client.fetchText("https://example.com", { path: "/x" })).rejects.toThrow(
|
||||
/timed out/i,
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(6);
|
||||
expect(clearTimeoutImpl).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("aborts with Error timeouts and retries", async () => {
|
||||
const { clearTimeoutMock } = mockImmediateTimeouts();
|
||||
const fetchMock = createAbortingFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
it("normalizes non-Error throws from fetch", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw { message: "The operation was aborted", name: "AbortError" };
|
||||
});
|
||||
const client = createNodeClient({ fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
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();
|
||||
await expect(client.apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
"The operation was aborted",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiRequestForm", () => {
|
||||
it("posts form data and returns json", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
it("posts form data, retries 429, and uses the upload timeout", async () => {
|
||||
const successFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const successClient = createNodeClient({ fetchImpl: successFetch as unknown as typeof fetch });
|
||||
const form = new FormData();
|
||||
form.append("x", "1");
|
||||
const result = await apiRequestForm("https://example.com", {
|
||||
const result = await successClient.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];
|
||||
const [, init] = successFetch.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({
|
||||
const rateLimitedFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: async () => "rate limited",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const retryClient = createNodeClient({
|
||||
fetchImpl: rateLimitedFetch as unknown as typeof fetch,
|
||||
setTimeoutImpl: createImmediateTimeouts().setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl: vi.fn(),
|
||||
});
|
||||
await expect(
|
||||
apiRequestForm("https://example.com", {
|
||||
retryClient.apiRequestForm("https://example.com", {
|
||||
method: "POST",
|
||||
path: "/upload",
|
||||
form: new FormData(),
|
||||
}),
|
||||
).rejects.toThrow("rate limited");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
expect(rateLimitedFetch).toHaveBeenCalledTimes(3);
|
||||
|
||||
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");
|
||||
},
|
||||
const { setTimeoutImpl, clearTimeoutImpl } = createImmediateTimeouts();
|
||||
const abortingFetch = createAbortingFetchMock();
|
||||
const timeoutClient = createNodeClient({
|
||||
fetchImpl: abortingFetch as unknown as typeof fetch,
|
||||
setTimeoutImpl: setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl,
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(
|
||||
apiRequestForm("https://example.com", {
|
||||
timeoutClient.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();
|
||||
).rejects.toThrow(/timed out after 120s/i);
|
||||
expect(setTimeoutImpl.mock.calls[0]?.[1]).toBe(120_000);
|
||||
});
|
||||
});
|
||||
|
||||
+315
-192
@@ -28,38 +28,19 @@ const CURL_WRITE_OUT_FORMAT = [
|
||||
"%{header:ratelimit-reset}",
|
||||
"%{header:retry-after}",
|
||||
].join("\n");
|
||||
const isBun = typeof process !== "undefined" && Boolean(process.versions?.bun);
|
||||
|
||||
export function shouldUseProxyFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return Boolean(env.HTTPS_PROXY || env.HTTP_PROXY || env.https_proxy || env.http_proxy);
|
||||
}
|
||||
|
||||
if (typeof process !== "undefined" && process.versions?.node) {
|
||||
try {
|
||||
setGlobalDispatcher(
|
||||
shouldUseProxyFromEnv(process.env)
|
||||
? new EnvHttpProxyAgent({
|
||||
connect: { timeout: REQUEST_TIMEOUT_MS },
|
||||
})
|
||||
: new Agent({
|
||||
connect: { timeout: REQUEST_TIMEOUT_MS },
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// ignore dispatcher setup failures in non-node runtimes
|
||||
}
|
||||
}
|
||||
|
||||
export function registryUrl(path: string, registry: string): URL {
|
||||
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
||||
const relative = path.startsWith("/") ? path.slice(1) : path;
|
||||
return new URL(relative, base);
|
||||
}
|
||||
export type HttpRuntime = "node" | "bun";
|
||||
|
||||
type RequestArgs =
|
||||
| { method: "GET" | "POST" | "DELETE"; path: string; token?: string; body?: unknown }
|
||||
| { method: "GET" | "POST" | "DELETE"; url: string; token?: string; body?: unknown };
|
||||
|
||||
type FormRequestArgs =
|
||||
| { method: "POST"; path: string; token?: string; form: FormData }
|
||||
| { method: "POST"; url: string; token?: string; form: FormData };
|
||||
|
||||
type TextRequestArgs = { path: string; token?: string } | { url: string; token?: string };
|
||||
|
||||
type HeaderSource = Headers | Record<string, string> | null | undefined;
|
||||
|
||||
type RateLimitInfo = {
|
||||
@@ -69,6 +50,40 @@ type RateLimitInfo = {
|
||||
retryAfterSeconds?: number;
|
||||
};
|
||||
|
||||
type HttpClientDeps = {
|
||||
runtime: HttpRuntime;
|
||||
fetchImpl: typeof fetch;
|
||||
setTimeoutImpl: typeof setTimeout;
|
||||
clearTimeoutImpl: typeof clearTimeout;
|
||||
spawnSyncImpl: typeof spawnSync;
|
||||
mkdirImpl: typeof mkdir;
|
||||
mkdtempImpl: typeof mkdtemp;
|
||||
readFileImpl: typeof readFile;
|
||||
rmImpl: typeof rm;
|
||||
writeFileImpl: typeof writeFile;
|
||||
tmpdirPath: string;
|
||||
now: () => number;
|
||||
random: () => number;
|
||||
env: NodeJS.ProcessEnv;
|
||||
configureDispatcher: boolean;
|
||||
};
|
||||
|
||||
export type HttpClientOptions = Partial<Omit<HttpClientDeps, "runtime">> & {
|
||||
runtime?: HttpRuntime;
|
||||
};
|
||||
|
||||
type HttpClient = {
|
||||
apiRequest<T>(registry: string, args: RequestArgs): Promise<T>;
|
||||
apiRequest<T>(registry: string, args: RequestArgs, schema: ArkValidator<T>): Promise<T>;
|
||||
apiRequestForm<T>(registry: string, args: FormRequestArgs): Promise<T>;
|
||||
apiRequestForm<T>(registry: string, args: FormRequestArgs, schema: ArkValidator<T>): Promise<T>;
|
||||
fetchText(registry: string, args: TextRequestArgs): Promise<string>;
|
||||
downloadZip(
|
||||
registry: string,
|
||||
args: { slug: string; version?: string; token?: string },
|
||||
): Promise<Uint8Array>;
|
||||
};
|
||||
|
||||
class HttpStatusError extends Error {
|
||||
readonly status: number;
|
||||
readonly rateLimit: RateLimitInfo;
|
||||
@@ -81,6 +96,178 @@ class HttpStatusError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function detectHttpRuntime(
|
||||
processVersions: NodeJS.ProcessVersions | undefined = process.versions,
|
||||
): HttpRuntime {
|
||||
return processVersions?.bun ? "bun" : "node";
|
||||
}
|
||||
|
||||
export function shouldUseProxyFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return Boolean(env.HTTPS_PROXY || env.HTTP_PROXY || env.https_proxy || env.http_proxy);
|
||||
}
|
||||
|
||||
export function registryUrl(path: string, registry: string): URL {
|
||||
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
||||
const relative = path.startsWith("/") ? path.slice(1) : path;
|
||||
return new URL(relative, base);
|
||||
}
|
||||
|
||||
export function createHttpClient(options: HttpClientOptions = {}): HttpClient {
|
||||
const deps: HttpClientDeps = {
|
||||
runtime: options.runtime ?? detectHttpRuntime(),
|
||||
fetchImpl: options.fetchImpl ?? globalThis.fetch.bind(globalThis),
|
||||
setTimeoutImpl: options.setTimeoutImpl ?? globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeoutImpl: options.clearTimeoutImpl ?? globalThis.clearTimeout.bind(globalThis),
|
||||
spawnSyncImpl: options.spawnSyncImpl ?? spawnSync,
|
||||
mkdirImpl: options.mkdirImpl ?? mkdir,
|
||||
mkdtempImpl: options.mkdtempImpl ?? mkdtemp,
|
||||
readFileImpl: options.readFileImpl ?? readFile,
|
||||
rmImpl: options.rmImpl ?? rm,
|
||||
writeFileImpl: options.writeFileImpl ?? writeFile,
|
||||
tmpdirPath: options.tmpdirPath ?? tmpdir(),
|
||||
now: options.now ?? Date.now,
|
||||
random: options.random ?? Math.random,
|
||||
env: options.env ?? process.env,
|
||||
configureDispatcher: options.configureDispatcher ?? true,
|
||||
};
|
||||
|
||||
if (deps.runtime === "node" && deps.configureDispatcher) {
|
||||
configureNodeDispatcher(deps.env);
|
||||
}
|
||||
|
||||
const runWithRetries = createRetryRunner(deps);
|
||||
|
||||
async function apiRequest<T>(
|
||||
registry: string,
|
||||
args: RequestArgs,
|
||||
schema?: ArkValidator<T>,
|
||||
): Promise<T> {
|
||||
const url = "url" in args ? args.url : registryUrl(args.path, registry).toString();
|
||||
const json = await runWithRetries(async () => {
|
||||
if (deps.runtime === "bun") {
|
||||
return await fetchJsonViaCurl(deps, url, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
let body: string | undefined;
|
||||
if (args.method === "POST") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(args.body ?? {});
|
||||
}
|
||||
const response = await fetchWithTimeout(deps, url, {
|
||||
method: args.method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response), response.headers, deps.now);
|
||||
}
|
||||
return (await response.json()) as unknown;
|
||||
});
|
||||
if (schema) return parseArk(schema, json, "API response");
|
||||
return json as T;
|
||||
}
|
||||
|
||||
async function apiRequestForm<T>(
|
||||
registry: string,
|
||||
args: FormRequestArgs,
|
||||
schema?: ArkValidator<T>,
|
||||
): Promise<T> {
|
||||
const url = "url" in args ? args.url : registryUrl(args.path, registry).toString();
|
||||
const json = await runWithRetries(async () => {
|
||||
if (deps.runtime === "bun") {
|
||||
return await fetchJsonFormViaCurl(deps, url, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
const response = await fetchWithTimeout(
|
||||
deps,
|
||||
url,
|
||||
{
|
||||
method: args.method,
|
||||
headers,
|
||||
body: args.form,
|
||||
},
|
||||
UPLOAD_TIMEOUT_MS,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response), response.headers, deps.now);
|
||||
}
|
||||
return (await response.json()) as unknown;
|
||||
});
|
||||
if (schema) return parseArk(schema, json, "API response");
|
||||
return json as T;
|
||||
}
|
||||
|
||||
async function fetchTextRequest(registry: string, args: TextRequestArgs): Promise<string> {
|
||||
const url = "url" in args ? args.url : registryUrl(args.path, registry).toString();
|
||||
return await runWithRetries(async () => {
|
||||
if (deps.runtime === "bun") {
|
||||
return await fetchTextViaCurl(deps, url, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: "text/plain" };
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
const response = await fetchWithTimeout(deps, url, { method: "GET", headers });
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, text, response.headers, deps.now);
|
||||
}
|
||||
return text;
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadZipRequest(
|
||||
registry: string,
|
||||
args: { slug: string; version?: string; token?: string },
|
||||
) {
|
||||
const url = registryUrl(ApiRoutes.download, registry);
|
||||
url.searchParams.set("slug", args.slug);
|
||||
if (args.version) url.searchParams.set("version", args.version);
|
||||
return await runWithRetries(async () => {
|
||||
if (deps.runtime === "bun") {
|
||||
return await fetchBinaryViaCurl(deps, url.toString(), args.token);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
const response = await fetchWithTimeout(deps, url.toString(), { method: "GET", headers });
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response), response.headers, deps.now);
|
||||
}
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
apiRequest,
|
||||
apiRequestForm,
|
||||
fetchText: fetchTextRequest,
|
||||
downloadZip: downloadZipRequest,
|
||||
};
|
||||
}
|
||||
|
||||
function configureNodeDispatcher(env: NodeJS.ProcessEnv) {
|
||||
if (!process.versions?.node) return;
|
||||
try {
|
||||
setGlobalDispatcher(
|
||||
shouldUseProxyFromEnv(env)
|
||||
? new EnvHttpProxyAgent({
|
||||
connect: { timeout: REQUEST_TIMEOUT_MS },
|
||||
})
|
||||
: new Agent({
|
||||
connect: { timeout: REQUEST_TIMEOUT_MS },
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Ignore dispatcher setup failures in environments that partially emulate Node APIs.
|
||||
}
|
||||
}
|
||||
|
||||
const defaultHttpClient = createHttpClient();
|
||||
|
||||
export async function apiRequest<T>(registry: string, args: RequestArgs): Promise<T>;
|
||||
export async function apiRequest<T>(
|
||||
registry: string,
|
||||
@@ -92,37 +279,12 @@ export async function apiRequest<T>(
|
||||
args: RequestArgs,
|
||||
schema?: ArkValidator<T>,
|
||||
): Promise<T> {
|
||||
const url = "url" in args ? args.url : registryUrl(args.path, registry).toString();
|
||||
const json = await runWithRetries(async () => {
|
||||
if (isBun) {
|
||||
return await fetchJsonViaCurl(url, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
let body: string | undefined;
|
||||
if (args.method === "POST") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(args.body ?? {});
|
||||
}
|
||||
const response = await fetchWithTimeout(url, {
|
||||
method: args.method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response), response.headers);
|
||||
}
|
||||
return (await response.json()) as unknown;
|
||||
});
|
||||
if (schema) return parseArk(schema, json, "API response");
|
||||
return json as T;
|
||||
if (schema) {
|
||||
return await defaultHttpClient.apiRequest(registry, args, schema);
|
||||
}
|
||||
return await defaultHttpClient.apiRequest(registry, args);
|
||||
}
|
||||
|
||||
type FormRequestArgs =
|
||||
| { method: "POST"; path: string; token?: string; form: FormData }
|
||||
| { method: "POST"; url: string; token?: string; form: FormData };
|
||||
|
||||
export async function apiRequestForm<T>(registry: string, args: FormRequestArgs): Promise<T>;
|
||||
export async function apiRequestForm<T>(
|
||||
registry: string,
|
||||
@@ -134,98 +296,63 @@ export async function apiRequestForm<T>(
|
||||
args: FormRequestArgs,
|
||||
schema?: ArkValidator<T>,
|
||||
): Promise<T> {
|
||||
const url = "url" in args ? args.url : registryUrl(args.path, registry).toString();
|
||||
const json = await runWithRetries(async () => {
|
||||
if (isBun) {
|
||||
return await fetchJsonFormViaCurl(url, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
const response = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: args.method,
|
||||
headers,
|
||||
body: args.form,
|
||||
},
|
||||
UPLOAD_TIMEOUT_MS,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response), response.headers);
|
||||
}
|
||||
return (await response.json()) as unknown;
|
||||
});
|
||||
if (schema) return parseArk(schema, json, "API response");
|
||||
return json as T;
|
||||
if (schema) {
|
||||
return await defaultHttpClient.apiRequestForm(registry, args, schema);
|
||||
}
|
||||
return await defaultHttpClient.apiRequestForm(registry, args);
|
||||
}
|
||||
|
||||
type TextRequestArgs = { path: string; token?: string } | { url: string; token?: string };
|
||||
|
||||
export async function fetchText(registry: string, args: TextRequestArgs): Promise<string> {
|
||||
const url = "url" in args ? args.url : registryUrl(args.path, registry).toString();
|
||||
return runWithRetries(async () => {
|
||||
if (isBun) {
|
||||
return await fetchTextViaCurl(url, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: "text/plain" };
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
const response = await fetchWithTimeout(url, { method: "GET", headers });
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, text, response.headers);
|
||||
}
|
||||
return text;
|
||||
});
|
||||
return await defaultHttpClient.fetchText(registry, args);
|
||||
}
|
||||
|
||||
export async function downloadZip(
|
||||
registry: string,
|
||||
args: { slug: string; version?: string; token?: string },
|
||||
) {
|
||||
const url = registryUrl(ApiRoutes.download, registry);
|
||||
url.searchParams.set("slug", args.slug);
|
||||
if (args.version) url.searchParams.set("version", args.version);
|
||||
return runWithRetries(async () => {
|
||||
if (isBun) {
|
||||
return await fetchBinaryViaCurl(url.toString(), args.token);
|
||||
}
|
||||
return await defaultHttpClient.downloadZip(registry, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
|
||||
const response = await fetchWithTimeout(url.toString(), { method: "GET", headers });
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response), response.headers);
|
||||
}
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
});
|
||||
function createRetryRunner(deps: Pick<HttpClientDeps, "setTimeoutImpl" | "random" | "now">) {
|
||||
return async function runWithRetries<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return await pRetry(fn, {
|
||||
retries: RETRY_COUNT,
|
||||
minTimeout: 0,
|
||||
maxTimeout: 0,
|
||||
factor: 1,
|
||||
randomize: false,
|
||||
onFailedAttempt: async (attemptError) => {
|
||||
const delayMs = getRetryDelayMs(attemptError, deps.random);
|
||||
if (delayMs <= 0) return;
|
||||
await sleep(delayMs, deps.setTimeoutImpl);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
deps: Pick<HttpClientDeps, "fetchImpl" | "setTimeoutImpl" | "clearTimeoutImpl">,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs = REQUEST_TIMEOUT_MS,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutSeconds = Math.ceil(timeoutMs / 1000);
|
||||
const timeout = setTimeout(
|
||||
const timeout = deps.setTimeoutImpl(
|
||||
() => controller.abort(new Error(`Request timed out after ${timeoutSeconds}s`)),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
return await deps.fetchImpl(url, { ...init, signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) throw error;
|
||||
// Normalize non-Error throws (e.g. DOMException from AbortController) into proper Errors
|
||||
const message =
|
||||
typeof error === "object" && error !== null && "message" in error
|
||||
? String((error as { message: unknown }).message)
|
||||
: String(error);
|
||||
throw new Error(message, { cause: error });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
deps.clearTimeoutImpl(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,22 +360,7 @@ async function readResponseTextSafe(response: Response): Promise<string> {
|
||||
return await response.text().catch(() => "");
|
||||
}
|
||||
|
||||
async function runWithRetries<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return await pRetry(fn, {
|
||||
retries: RETRY_COUNT,
|
||||
minTimeout: 0,
|
||||
maxTimeout: 0,
|
||||
factor: 1,
|
||||
randomize: false,
|
||||
onFailedAttempt: async (attemptError) => {
|
||||
const delayMs = getRetryDelayMs(attemptError);
|
||||
if (delayMs <= 0) return;
|
||||
await sleep(delayMs);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getRetryDelayMs(attemptError: unknown): number {
|
||||
function getRetryDelayMs(attemptError: unknown, random: () => number): number {
|
||||
const failed = attemptError as {
|
||||
attemptNumber?: number;
|
||||
cause?: unknown;
|
||||
@@ -257,25 +369,30 @@ function getRetryDelayMs(attemptError: unknown): number {
|
||||
const attemptNumber = Math.max(1, Number(failed.attemptNumber ?? 1));
|
||||
const rootError = failed.cause ?? failed.error ?? attemptError;
|
||||
if (rootError instanceof HttpStatusError && rootError.rateLimit.retryAfterSeconds !== undefined) {
|
||||
return rootError.rateLimit.retryAfterSeconds * 1000 + jitterMs(RETRY_AFTER_JITTER_MS);
|
||||
return rootError.rateLimit.retryAfterSeconds * 1000 + jitterMs(RETRY_AFTER_JITTER_MS, random);
|
||||
}
|
||||
const baseMs = Math.min(RETRY_BACKOFF_MAX_MS, RETRY_BACKOFF_BASE_MS * 2 ** (attemptNumber - 1));
|
||||
return baseMs + jitterMs(RETRY_BACKOFF_BASE_MS);
|
||||
return baseMs + jitterMs(RETRY_BACKOFF_BASE_MS, random);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
function sleep(ms: number, setTimeoutImpl: typeof setTimeout): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
setTimeoutImpl(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function jitterMs(maxMs: number): number {
|
||||
function jitterMs(maxMs: number, random: () => number): number {
|
||||
if (maxMs <= 0) return 0;
|
||||
return Math.floor(Math.random() * maxMs);
|
||||
return Math.floor(random() * maxMs);
|
||||
}
|
||||
|
||||
function throwHttpStatusError(status: number, text: string, headers?: HeaderSource): never {
|
||||
const rateLimit = parseRateLimitInfo(headers);
|
||||
function throwHttpStatusError(
|
||||
status: number,
|
||||
text: string,
|
||||
headers: HeaderSource,
|
||||
now: () => number,
|
||||
): never {
|
||||
const rateLimit = parseRateLimitInfo(headers, now);
|
||||
const message = buildHttpErrorMessage(status, text, rateLimit);
|
||||
if (status === 429 || status >= 500) {
|
||||
throw new HttpStatusError(status, message, rateLimit);
|
||||
@@ -295,13 +412,10 @@ function buildHttpErrorMessage(status: number, text: string, rateLimit: RateLimi
|
||||
if (rateLimit.resetDelaySeconds !== undefined) {
|
||||
details.push(`reset in ${rateLimit.resetDelaySeconds}s`);
|
||||
}
|
||||
if (details.length === 0) {
|
||||
return base;
|
||||
}
|
||||
return `${base} (${details.join(", ")})`;
|
||||
return details.length === 0 ? base : `${base} (${details.join(", ")})`;
|
||||
}
|
||||
|
||||
function parseRateLimitInfo(headers?: HeaderSource): RateLimitInfo {
|
||||
function parseRateLimitInfo(headers: HeaderSource, now: () => number): RateLimitInfo {
|
||||
if (!headers) return {};
|
||||
const limit = parseIntHeader(
|
||||
getHeader(headers, "x-ratelimit-limit") ?? getHeader(headers, "ratelimit-limit"),
|
||||
@@ -309,16 +423,10 @@ function parseRateLimitInfo(headers?: HeaderSource): RateLimitInfo {
|
||||
const remaining = parseIntHeader(
|
||||
getHeader(headers, "x-ratelimit-remaining") ?? getHeader(headers, "ratelimit-remaining"),
|
||||
);
|
||||
const nowMs = Date.now();
|
||||
const nowMs = now();
|
||||
const retryAfterSeconds = parseRetryAfterSeconds(getHeader(headers, "retry-after"), nowMs);
|
||||
const resetDelaySeconds = parseResetDelaySeconds(headers, nowMs, retryAfterSeconds);
|
||||
|
||||
return {
|
||||
limit,
|
||||
remaining,
|
||||
resetDelaySeconds,
|
||||
retryAfterSeconds,
|
||||
};
|
||||
return { limit, remaining, resetDelaySeconds, retryAfterSeconds };
|
||||
}
|
||||
|
||||
function parseResetDelaySeconds(
|
||||
@@ -327,7 +435,6 @@ function parseResetDelaySeconds(
|
||||
retryAfterSeconds: number | undefined,
|
||||
): number | undefined {
|
||||
if (retryAfterSeconds !== undefined) return retryAfterSeconds;
|
||||
|
||||
const standardized = parseIntHeader(getHeader(headers, "ratelimit-reset"));
|
||||
if (standardized !== undefined) {
|
||||
return Math.max(1, standardized);
|
||||
@@ -345,7 +452,6 @@ function parseRetryAfterSeconds(value: string | undefined, nowMs: number): numbe
|
||||
|
||||
const asNumber = Number(trimmed);
|
||||
if (Number.isFinite(asNumber) && asNumber >= 0) {
|
||||
// Compatibility guard for older servers that accidentally sent Unix epoch seconds.
|
||||
if (asNumber > 31_536_000) {
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
return Math.max(1, Math.ceil(asNumber - nowSeconds));
|
||||
@@ -361,8 +467,7 @@ function parseRetryAfterSeconds(value: string | undefined, nowMs: number): numbe
|
||||
function parseIntHeader(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
return parsed;
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function getHeader(headers: HeaderSource, key: string): string | undefined {
|
||||
@@ -383,11 +488,13 @@ function getHeader(headers: HeaderSource, key: string): string | undefined {
|
||||
return typeof match?.[1] === "string" ? match[1].trim() : undefined;
|
||||
}
|
||||
|
||||
async function fetchJsonViaCurl(url: string, args: RequestArgs) {
|
||||
async function fetchJsonViaCurl(
|
||||
deps: Pick<HttpClientDeps, "spawnSyncImpl" | "now">,
|
||||
url: string,
|
||||
args: RequestArgs,
|
||||
) {
|
||||
const headers = ["-H", "Accept: application/json"];
|
||||
if (args.token) {
|
||||
headers.push("-H", `Authorization: Bearer ${args.token}`);
|
||||
}
|
||||
if (args.token) headers.push("-H", `Authorization: Bearer ${args.token}`);
|
||||
const curlArgs = [
|
||||
"--silent",
|
||||
"--show-error",
|
||||
@@ -406,24 +513,29 @@ async function fetchJsonViaCurl(url: string, args: RequestArgs) {
|
||||
curlArgs.push("--data-binary", JSON.stringify(args.body ?? {}));
|
||||
}
|
||||
|
||||
const result = spawnSync("curl", curlArgs, { encoding: "utf8" });
|
||||
const result = deps.spawnSyncImpl("curl", curlArgs, { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || "curl failed");
|
||||
}
|
||||
const { body, status, headers: responseHeaders } = parseCurlBodyAndMeta(result.stdout ?? "");
|
||||
if (status < 200 || status >= 300) {
|
||||
throwHttpStatusError(status, body, responseHeaders);
|
||||
throwHttpStatusError(status, body, responseHeaders, deps.now);
|
||||
}
|
||||
return JSON.parse(body || "null") as unknown;
|
||||
}
|
||||
|
||||
async function fetchJsonFormViaCurl(url: string, args: FormRequestArgs) {
|
||||
async function fetchJsonFormViaCurl(
|
||||
deps: Pick<
|
||||
HttpClientDeps,
|
||||
"spawnSyncImpl" | "mkdtempImpl" | "mkdirImpl" | "writeFileImpl" | "rmImpl" | "tmpdirPath" | "now"
|
||||
>,
|
||||
url: string,
|
||||
args: FormRequestArgs,
|
||||
) {
|
||||
const headers = ["-H", "Accept: application/json"];
|
||||
if (args.token) {
|
||||
headers.push("-H", `Authorization: Bearer ${args.token}`);
|
||||
}
|
||||
if (args.token) headers.push("-H", `Authorization: Bearer ${args.token}`);
|
||||
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "clawhub-upload-"));
|
||||
const tempDir = await deps.mkdtempImpl(join(deps.tmpdirPath, "clawhub-upload-"));
|
||||
try {
|
||||
const formArgs: string[] = [];
|
||||
for (const [key, value] of args.form.entries()) {
|
||||
@@ -431,8 +543,8 @@ async function fetchJsonFormViaCurl(url: string, args: FormRequestArgs) {
|
||||
const filename = typeof (value as File).name === "string" ? (value as File).name : "file";
|
||||
const filePath = join(tempDir, filename);
|
||||
const bytes = new Uint8Array(await value.arrayBuffer());
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, bytes);
|
||||
await deps.mkdirImpl(dirname(filePath), { recursive: true });
|
||||
await deps.writeFileImpl(filePath, bytes);
|
||||
formArgs.push("-F", `${key}=@${filePath};filename=${filename}`);
|
||||
} else {
|
||||
formArgs.push("-F", `${key}=${String(value)}`);
|
||||
@@ -454,25 +566,27 @@ async function fetchJsonFormViaCurl(url: string, args: FormRequestArgs) {
|
||||
url,
|
||||
];
|
||||
|
||||
const result = spawnSync("curl", curlArgs, { encoding: "utf8" });
|
||||
const result = deps.spawnSyncImpl("curl", curlArgs, { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || "curl failed");
|
||||
}
|
||||
const { body, status, headers: responseHeaders } = parseCurlBodyAndMeta(result.stdout ?? "");
|
||||
if (status < 200 || status >= 300) {
|
||||
throwHttpStatusError(status, body, responseHeaders);
|
||||
throwHttpStatusError(status, body, responseHeaders, deps.now);
|
||||
}
|
||||
return JSON.parse(body || "null") as unknown;
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await deps.rmImpl(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTextViaCurl(url: string, args: { token?: string }) {
|
||||
async function fetchTextViaCurl(
|
||||
deps: Pick<HttpClientDeps, "spawnSyncImpl" | "now">,
|
||||
url: string,
|
||||
args: { token?: string },
|
||||
) {
|
||||
const headers = ["-H", "Accept: text/plain"];
|
||||
if (args.token) {
|
||||
headers.push("-H", `Authorization: Bearer ${args.token}`);
|
||||
}
|
||||
if (args.token) headers.push("-H", `Authorization: Bearer ${args.token}`);
|
||||
const curlArgs = [
|
||||
"--silent",
|
||||
"--show-error",
|
||||
@@ -486,25 +600,30 @@ async function fetchTextViaCurl(url: string, args: { token?: string }) {
|
||||
...headers,
|
||||
url,
|
||||
];
|
||||
const result = spawnSync("curl", curlArgs, { encoding: "utf8" });
|
||||
const result = deps.spawnSyncImpl("curl", curlArgs, { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || "curl failed");
|
||||
}
|
||||
const { body, status, headers: responseHeaders } = parseCurlBodyAndMeta(result.stdout ?? "");
|
||||
if (status < 200 || status >= 300) {
|
||||
throwHttpStatusError(status, body, responseHeaders);
|
||||
throwHttpStatusError(status, body, responseHeaders, deps.now);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function fetchBinaryViaCurl(url: string, token?: string) {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "clawhub-download-"));
|
||||
async function fetchBinaryViaCurl(
|
||||
deps: Pick<
|
||||
HttpClientDeps,
|
||||
"spawnSyncImpl" | "mkdtempImpl" | "readFileImpl" | "rmImpl" | "tmpdirPath" | "now"
|
||||
>,
|
||||
url: string,
|
||||
token?: string,
|
||||
) {
|
||||
const tempDir = await deps.mkdtempImpl(join(deps.tmpdirPath, "clawhub-download-"));
|
||||
const filePath = join(tempDir, "payload.bin");
|
||||
try {
|
||||
const headers: string[] = [];
|
||||
if (token) {
|
||||
headers.push("-H", `Authorization: Bearer ${token}`);
|
||||
}
|
||||
if (token) headers.push("-H", `Authorization: Bearer ${token}`);
|
||||
|
||||
const curlArgs = [
|
||||
"--silent",
|
||||
@@ -519,19 +638,24 @@ async function fetchBinaryViaCurl(url: string, token?: string) {
|
||||
CURL_WRITE_OUT_FORMAT,
|
||||
url,
|
||||
];
|
||||
const result = spawnSync("curl", curlArgs, { encoding: "utf8" });
|
||||
const result = deps.spawnSyncImpl("curl", curlArgs, { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || "curl failed");
|
||||
}
|
||||
const { status, headers: responseHeaders } = parseCurlBodyAndMeta(result.stdout ?? "");
|
||||
if (status < 200 || status >= 300) {
|
||||
const body = await readFileSafe(filePath);
|
||||
throwHttpStatusError(status, body ? new TextDecoder().decode(body) : "", responseHeaders);
|
||||
const body = await readFileSafe(deps.readFileImpl, filePath);
|
||||
throwHttpStatusError(
|
||||
status,
|
||||
body ? new TextDecoder().decode(body) : "",
|
||||
responseHeaders,
|
||||
deps.now,
|
||||
);
|
||||
}
|
||||
const bytes = await readFileSafe(filePath);
|
||||
const bytes = await readFileSafe(deps.readFileImpl, filePath);
|
||||
return bytes ? new Uint8Array(bytes) : new Uint8Array();
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await deps.rmImpl(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,7 +667,6 @@ function parseCurlBodyAndMeta(output: string): {
|
||||
const marker = `\n${CURL_META_MARKER}\n`;
|
||||
const markerIndex = output.lastIndexOf(marker);
|
||||
if (markerIndex === -1) {
|
||||
// Backward compatibility for older tests that only provide "<body>\n<status>".
|
||||
const splitAt = output.lastIndexOf("\n");
|
||||
if (splitAt === -1) {
|
||||
const statusOnly = Number(output.trim());
|
||||
@@ -595,9 +718,9 @@ function setHeaderIfPresent(
|
||||
headers[key] = trimmed;
|
||||
}
|
||||
|
||||
async function readFileSafe(path: string) {
|
||||
async function readFileSafe(readFileImpl: typeof readFile, path: string) {
|
||||
try {
|
||||
return await readFile(path);
|
||||
return await readFileImpl(path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
PLATFORM_SKILL_LICENSE_URL,
|
||||
} from "./license.js";
|
||||
export * from "./openclawContract.js";
|
||||
export * from "./packages.js";
|
||||
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
|
||||
export * from "./schemas.js";
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { PackageCompatibility } from "./packages.js";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
export type OpenClawExternalPluginValidationIssue = {
|
||||
fieldPath: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type OpenClawExternalCodePluginValidation = {
|
||||
compatibility?: PackageCompatibility;
|
||||
issues: OpenClawExternalPluginValidationIssue[];
|
||||
};
|
||||
|
||||
export const OPENCLAW_EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS = [
|
||||
"openclaw.compat.pluginApi",
|
||||
"openclaw.build.openclawVersion",
|
||||
] as const;
|
||||
|
||||
function isRecord(value: unknown): value is JsonObject {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getTrimmedString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readOpenClawBlock(packageJson: unknown) {
|
||||
const root = isRecord(packageJson) ? packageJson : undefined;
|
||||
const openclaw = isRecord(root?.openclaw) ? root.openclaw : undefined;
|
||||
const compat = isRecord(openclaw?.compat) ? openclaw.compat : undefined;
|
||||
const build = isRecord(openclaw?.build) ? openclaw.build : undefined;
|
||||
const install = isRecord(openclaw?.install) ? openclaw.install : undefined;
|
||||
return { root, compat, build, install };
|
||||
}
|
||||
|
||||
export function normalizeOpenClawExternalPluginCompatibility(
|
||||
packageJson: unknown,
|
||||
): PackageCompatibility | undefined {
|
||||
const { root, compat, build, install } = readOpenClawBlock(packageJson);
|
||||
const version = getTrimmedString(root?.version);
|
||||
const minHostVersion = getTrimmedString(install?.minHostVersion);
|
||||
const compatibility: PackageCompatibility = {};
|
||||
|
||||
const pluginApi = getTrimmedString(compat?.pluginApi);
|
||||
if (pluginApi) {
|
||||
compatibility.pluginApiRange = pluginApi;
|
||||
}
|
||||
|
||||
const minGatewayVersion = getTrimmedString(compat?.minGatewayVersion) ?? minHostVersion;
|
||||
if (minGatewayVersion) {
|
||||
compatibility.minGatewayVersion = minGatewayVersion;
|
||||
}
|
||||
|
||||
const builtWithOpenClawVersion = getTrimmedString(build?.openclawVersion) ?? version;
|
||||
if (builtWithOpenClawVersion) {
|
||||
compatibility.builtWithOpenClawVersion = builtWithOpenClawVersion;
|
||||
}
|
||||
|
||||
const pluginSdkVersion = getTrimmedString(build?.pluginSdkVersion);
|
||||
if (pluginSdkVersion) {
|
||||
compatibility.pluginSdkVersion = pluginSdkVersion;
|
||||
}
|
||||
|
||||
return Object.keys(compatibility).length > 0 ? compatibility : undefined;
|
||||
}
|
||||
|
||||
export function listMissingOpenClawExternalCodePluginFieldPaths(packageJson: unknown): string[] {
|
||||
const { compat, build } = readOpenClawBlock(packageJson);
|
||||
const missing: string[] = [];
|
||||
if (!getTrimmedString(compat?.pluginApi)) {
|
||||
missing.push("openclaw.compat.pluginApi");
|
||||
}
|
||||
if (!getTrimmedString(build?.openclawVersion)) {
|
||||
missing.push("openclaw.build.openclawVersion");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function validateOpenClawExternalCodePluginPackageJson(
|
||||
packageJson: unknown,
|
||||
): OpenClawExternalCodePluginValidation {
|
||||
const issues = listMissingOpenClawExternalCodePluginFieldPaths(packageJson).map((fieldPath) => ({
|
||||
fieldPath,
|
||||
message: `${fieldPath} is required for external code plugins published to ClawHub.`,
|
||||
}));
|
||||
return {
|
||||
compatibility: normalizeOpenClawExternalPluginCompatibility(packageJson),
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -57,6 +57,57 @@ export const PackageVerificationSummarySchema = type({
|
||||
});
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
analysis: "string?",
|
||||
source: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageVtAnalysis = (typeof PackageVtAnalysisSchema)[inferred];
|
||||
|
||||
export const PackageLlmAnalysisDimensionSchema = type({
|
||||
name: "string",
|
||||
label: "string",
|
||||
rating: "string",
|
||||
detail: "string",
|
||||
});
|
||||
export type PackageLlmAnalysisDimension =
|
||||
(typeof PackageLlmAnalysisDimensionSchema)[inferred];
|
||||
|
||||
export const PackageLlmAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
confidence: "string?",
|
||||
summary: "string?",
|
||||
dimensions: PackageLlmAnalysisDimensionSchema.array().optional(),
|
||||
guidance: "string?",
|
||||
findings: "string?",
|
||||
model: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageLlmAnalysis = (typeof PackageLlmAnalysisSchema)[inferred];
|
||||
|
||||
export const PackageStaticFindingSchema = type({
|
||||
code: "string",
|
||||
severity: "string",
|
||||
file: "string",
|
||||
line: "number",
|
||||
message: "string",
|
||||
evidence: "string",
|
||||
});
|
||||
export type PackageStaticFinding = (typeof PackageStaticFindingSchema)[inferred];
|
||||
|
||||
export const PackageStaticScanSchema = type({
|
||||
status: "string",
|
||||
reasonCodes: "string[]",
|
||||
findings: PackageStaticFindingSchema.array(),
|
||||
summary: "string",
|
||||
engineVersion: "string",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
|
||||
|
||||
export const BundlePublishMetadataSchema = type({
|
||||
id: "string?",
|
||||
format: "string?",
|
||||
@@ -159,6 +210,10 @@ export const ApiV1PackageVersionResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
sha256hash: "string?",
|
||||
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
|
||||
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
|
||||
staticScan: PackageStaticScanSchema.or("null").optional(),
|
||||
}).or("null"),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const packageRoot = resolve(import.meta.dirname, "..");
|
||||
const repoRoot = resolve(packageRoot, "..", "..");
|
||||
const binPath = join(packageRoot, "bin", "clawdhub.js");
|
||||
const distCliPath = join(packageRoot, "dist", "cli.js");
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTmpDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function runNode(args: string[]) {
|
||||
return spawnSync("node", args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
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(async () => {
|
||||
while (tempDirs.length > 0) {
|
||||
await rm(tempDirs.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("built CLI artifact", () => {
|
||||
it("runs help from the published bin entrypoint", async () => {
|
||||
const result = runNode([binPath, "--help"]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toContain("ClawHub CLI");
|
||||
});
|
||||
|
||||
it("publishes a local code plugin in dry-run json mode from built output", async () => {
|
||||
const root = await makeTmpDir("clawhub-artifact-");
|
||||
const pluginDir = join(root, "demo-plugin");
|
||||
await mkdir(join(pluginDir, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@openclaw/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
openclaw: {
|
||||
compat: {
|
||||
pluginApi: ">=2026.3.24-beta.2",
|
||||
minGatewayVersion: "2026.3.24-beta.2",
|
||||
},
|
||||
build: {
|
||||
openclawVersion: "2026.3.24-beta.2",
|
||||
pluginSdkVersion: "2026.3.24-beta.2",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(pluginDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "demo.plugin",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(pluginDir, "src", "index.ts"), "export const demo = true;\n", "utf8");
|
||||
|
||||
runGit(root, ["init"]);
|
||||
runGit(root, ["remote", "add", "origin", "https://github.com/openclaw/demo-plugin.git"]);
|
||||
runGit(root, ["add", "."]);
|
||||
runGit(root, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]);
|
||||
|
||||
const result = runNode([
|
||||
binPath,
|
||||
"package",
|
||||
"publish",
|
||||
pluginDir,
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--registry",
|
||||
"https://clawhub.ai",
|
||||
"--site",
|
||||
"https://clawhub.ai",
|
||||
]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
const output = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
||||
expect(output.name).toBe("@openclaw/demo-plugin");
|
||||
expect(output.family).toBe("code-plugin");
|
||||
expect(output.version).toBe("1.0.0");
|
||||
expect(output.commit).toBeTypeOf("string");
|
||||
});
|
||||
|
||||
it("keeps the built dist free of compiled test files", async () => {
|
||||
expect(dirname(distCliPath)).toBe(join(packageRoot, "dist"));
|
||||
const result = runNode([
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
`import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
const queue = ['${join(packageRoot, "dist").replaceAll("\\", "\\\\")}'];
|
||||
const hits = [];
|
||||
while (queue.length > 0) {
|
||||
const dir = queue.pop();
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) queue.push(path);
|
||||
else if (entry.name.includes('.test.')) hits.push(path);
|
||||
}
|
||||
}
|
||||
if (hits.length > 0) {
|
||||
console.error(hits.join('\\n'));
|
||||
process.exit(1);
|
||||
}`,
|
||||
]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { join } from "node:path";
|
||||
import { vi } from "vitest";
|
||||
import type { GlobalOpts } from "../src/cli/types.js";
|
||||
|
||||
export function makeGlobalOpts(workdir = "/work"): GlobalOpts {
|
||||
return {
|
||||
workdir,
|
||||
dir: join(workdir, "skills"),
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
function buildRegistryUrl(path: string, registry: string) {
|
||||
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
||||
const relative = path.startsWith("/") ? path.slice(1) : path;
|
||||
return new URL(relative, base);
|
||||
}
|
||||
|
||||
export function createHttpModuleMocks() {
|
||||
const apiRequest = vi.fn();
|
||||
const apiRequestForm = vi.fn();
|
||||
const downloadZip = vi.fn();
|
||||
const fetchText = vi.fn();
|
||||
const registryUrl = vi.fn(buildRegistryUrl);
|
||||
|
||||
return {
|
||||
apiRequest,
|
||||
apiRequestForm,
|
||||
downloadZip,
|
||||
fetchText,
|
||||
registryUrl,
|
||||
moduleFactory: () => ({
|
||||
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
apiRequest(registry, args, schema),
|
||||
apiRequestForm: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
apiRequestForm(registry, args, schema),
|
||||
downloadZip: (registry: unknown, args: unknown) => downloadZip(registry, args),
|
||||
fetchText: (registry: unknown, args: unknown) => fetchText(registry, args),
|
||||
registryUrl: (...args: [string, string]) => registryUrl(...args),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRegistryModuleMocks() {
|
||||
const getRegistry = vi.fn(async (_opts?: unknown, _params?: unknown) => "https://clawhub.ai");
|
||||
|
||||
return {
|
||||
getRegistry,
|
||||
moduleFactory: () => ({
|
||||
getRegistry: (opts: unknown, params?: unknown) => getRegistry(opts, params),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAuthTokenModuleMocks() {
|
||||
const requireAuthToken = vi.fn(async () => "tkn");
|
||||
const getOptionalAuthToken = vi.fn(async () => undefined as string | undefined);
|
||||
|
||||
return {
|
||||
requireAuthToken,
|
||||
getOptionalAuthToken,
|
||||
moduleFactory: () => ({
|
||||
requireAuthToken: () => requireAuthToken(),
|
||||
getOptionalAuthToken: () => getOptionalAuthToken(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createUiModuleMocks(options?: { interactive?: boolean }) {
|
||||
const spinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
succeed: vi.fn(),
|
||||
start: vi.fn(),
|
||||
isSpinning: false,
|
||||
text: "",
|
||||
};
|
||||
const fail = vi.fn((message: string) => {
|
||||
throw new Error(message);
|
||||
});
|
||||
const promptConfirm = vi.fn(async () => true);
|
||||
const interactive = options?.interactive ?? false;
|
||||
|
||||
return {
|
||||
spinner,
|
||||
fail,
|
||||
promptConfirm,
|
||||
moduleFactory: () => ({
|
||||
createSpinner: vi.fn(() => spinner),
|
||||
fail: (message: string) => fail(message),
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => interactive,
|
||||
promptConfirm,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export function createGlobalStubRegistry() {
|
||||
const restorers: Array<() => void> = [];
|
||||
|
||||
return {
|
||||
stub<K extends keyof typeof globalThis>(name: K, value: (typeof globalThis)[K]) {
|
||||
const original = globalThis[name];
|
||||
restorers.push(() => {
|
||||
if (original === undefined) {
|
||||
Reflect.deleteProperty(globalThis, name);
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: original,
|
||||
});
|
||||
});
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
},
|
||||
restoreAll() {
|
||||
while (restorers.length > 0) {
|
||||
restorers.pop()?.();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createEnvStubRegistry() {
|
||||
const restorers: Array<() => void> = [];
|
||||
|
||||
return {
|
||||
stub(name: string, value: string) {
|
||||
const original = process.env[name];
|
||||
const hadOriginal = Object.prototype.hasOwnProperty.call(process.env, name);
|
||||
restorers.push(() => {
|
||||
if (hadOriginal) {
|
||||
process.env[name] = original;
|
||||
return;
|
||||
}
|
||||
delete process.env[name];
|
||||
});
|
||||
process.env[name] = value;
|
||||
},
|
||||
restoreAll() {
|
||||
while (restorers.length > 0) {
|
||||
restorers.pop()?.();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,5 +10,6 @@
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: false,
|
||||
testTimeout: 30_000,
|
||||
hookTimeout: 30_000,
|
||||
include: ["test-artifact/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: false,
|
||||
testTimeout: 15_000,
|
||||
hookTimeout: 15_000,
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**", "test-artifact/**"],
|
||||
},
|
||||
});
|
||||
Vendored
+1
@@ -1,6 +1,7 @@
|
||||
export type { ArkValidator } from "./ark.js";
|
||||
export { formatArkErrors, parseArk } from "./ark.js";
|
||||
export * from "./license.js";
|
||||
export * from "./openclawContract.js";
|
||||
export * from "./packages.js";
|
||||
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
|
||||
export * from "./schemas.js";
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
export { formatArkErrors, parseArk } from "./ark.js";
|
||||
export * from "./license.js";
|
||||
export * from "./openclawContract.js";
|
||||
export * from "./packages.js";
|
||||
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
|
||||
export * from "./schemas.js";
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"}
|
||||
Vendored
+2
-4
@@ -1,7 +1,5 @@
|
||||
import { type inferred } from "arktype";
|
||||
export declare const PLATFORM_SKILL_LICENSE: "MIT-0";
|
||||
export declare const PLATFORM_SKILL_LICENSE_NAME: "MIT No Attribution";
|
||||
export declare const PLATFORM_SKILL_LICENSE_SUMMARY: "Free to use, modify, and redistribute. No attribution required.";
|
||||
export declare const PLATFORM_SKILL_LICENSE_URL: "https://spdx.org/licenses/MIT-0.html";
|
||||
import { PLATFORM_SKILL_LICENSE, PLATFORM_SKILL_LICENSE_NAME, PLATFORM_SKILL_LICENSE_SUMMARY, PLATFORM_SKILL_LICENSE_URL } from "./licenseConstants.js";
|
||||
export { PLATFORM_SKILL_LICENSE, PLATFORM_SKILL_LICENSE_NAME, PLATFORM_SKILL_LICENSE_SUMMARY, PLATFORM_SKILL_LICENSE_URL, };
|
||||
export declare const SkillPlatformLicenseSchema: import("arktype/internal/variants/string.ts").StringType<"MIT-0", {}>;
|
||||
export type SkillPlatformLicense = (typeof SkillPlatformLicenseSchema)[inferred];
|
||||
|
||||
Vendored
+2
-4
@@ -1,7 +1,5 @@
|
||||
import { type } from "arktype";
|
||||
export const PLATFORM_SKILL_LICENSE = "MIT-0";
|
||||
export const PLATFORM_SKILL_LICENSE_NAME = "MIT No Attribution";
|
||||
export const PLATFORM_SKILL_LICENSE_SUMMARY = "Free to use, modify, and redistribute. No attribution required.";
|
||||
export const PLATFORM_SKILL_LICENSE_URL = "https://spdx.org/licenses/MIT-0.html";
|
||||
import { PLATFORM_SKILL_LICENSE, PLATFORM_SKILL_LICENSE_NAME, PLATFORM_SKILL_LICENSE_SUMMARY, PLATFORM_SKILL_LICENSE_URL, } from "./licenseConstants.js";
|
||||
export { PLATFORM_SKILL_LICENSE, PLATFORM_SKILL_LICENSE_NAME, PLATFORM_SKILL_LICENSE_SUMMARY, PLATFORM_SKILL_LICENSE_URL, };
|
||||
export const SkillPlatformLicenseSchema = type('"MIT-0"');
|
||||
//# sourceMappingURL=license.js.map
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"license.js","sourceRoot":"","sources":["../src/license.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAC;AAE9C,MAAM,CAAC,MAAM,sBAAsB,GAAG,OAAgB,CAAC;AACvD,MAAM,CAAC,MAAM,2BAA2B,GAAG,oBAA6B,CAAC;AACzE,MAAM,CAAC,MAAM,8BAA8B,GACzC,iEAA0E,CAAC;AAC7E,MAAM,CAAC,MAAM,0BAA0B,GAAG,sCAA+C,CAAC;AAE1F,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC"}
|
||||
{"version":3,"file":"license.js","sourceRoot":"","sources":["../src/license.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,IAAI,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,8BAA8B,EAC9B,0BAA0B,GAC3B,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EACL,sBAAsB,EACtB,2BAA2B,EAC3B,8BAA8B,EAC9B,0BAA0B,GAC3B,CAAC;AAEF,MAAM,CAAC,MAAM,0BAA0B,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC"}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export declare const PLATFORM_SKILL_LICENSE: "MIT-0";
|
||||
export declare const PLATFORM_SKILL_LICENSE_NAME: "MIT No Attribution";
|
||||
export declare const PLATFORM_SKILL_LICENSE_SUMMARY: "Free to use, modify, and redistribute. No attribution required.";
|
||||
export declare const PLATFORM_SKILL_LICENSE_URL: "https://spdx.org/licenses/MIT-0.html";
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export const PLATFORM_SKILL_LICENSE = 'MIT-0';
|
||||
export const PLATFORM_SKILL_LICENSE_NAME = 'MIT No Attribution';
|
||||
export const PLATFORM_SKILL_LICENSE_SUMMARY = 'Free to use, modify, and redistribute. No attribution required.';
|
||||
export const PLATFORM_SKILL_LICENSE_URL = 'https://spdx.org/licenses/MIT-0.html';
|
||||
//# sourceMappingURL=licenseConstants.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"licenseConstants.js","sourceRoot":"","sources":["../src/licenseConstants.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,sBAAsB,GAAG,OAAgB,CAAC;AACvD,MAAM,CAAC,MAAM,2BAA2B,GAAG,oBAA6B,CAAC;AACzE,MAAM,CAAC,MAAM,8BAA8B,GACzC,iEAA0E,CAAC;AAC7E,MAAM,CAAC,MAAM,0BAA0B,GAAG,sCAA+C,CAAC"}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import type { PackageCompatibility } from "./packages.js";
|
||||
export type OpenClawExternalPluginValidationIssue = {
|
||||
fieldPath: string;
|
||||
message: string;
|
||||
};
|
||||
export type OpenClawExternalCodePluginValidation = {
|
||||
compatibility?: PackageCompatibility;
|
||||
issues: OpenClawExternalPluginValidationIssue[];
|
||||
};
|
||||
export declare const OPENCLAW_EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS: readonly ["openclaw.compat.pluginApi", "openclaw.build.openclawVersion"];
|
||||
export declare function normalizeOpenClawExternalPluginCompatibility(packageJson: unknown): PackageCompatibility | undefined;
|
||||
export declare function listMissingOpenClawExternalCodePluginFieldPaths(packageJson: unknown): string[];
|
||||
export declare function validateOpenClawExternalCodePluginPackageJson(packageJson: unknown): OpenClawExternalCodePluginValidation;
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
export const OPENCLAW_EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS = [
|
||||
"openclaw.compat.pluginApi",
|
||||
"openclaw.build.openclawVersion",
|
||||
];
|
||||
function isRecord(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function getTrimmedString(value) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
function readOpenClawBlock(packageJson) {
|
||||
const root = isRecord(packageJson) ? packageJson : undefined;
|
||||
const openclaw = isRecord(root?.openclaw) ? root.openclaw : undefined;
|
||||
const compat = isRecord(openclaw?.compat) ? openclaw.compat : undefined;
|
||||
const build = isRecord(openclaw?.build) ? openclaw.build : undefined;
|
||||
const install = isRecord(openclaw?.install) ? openclaw.install : undefined;
|
||||
return { root, compat, build, install };
|
||||
}
|
||||
export function normalizeOpenClawExternalPluginCompatibility(packageJson) {
|
||||
const { root, compat, build, install } = readOpenClawBlock(packageJson);
|
||||
const version = getTrimmedString(root?.version);
|
||||
const minHostVersion = getTrimmedString(install?.minHostVersion);
|
||||
const compatibility = {};
|
||||
const pluginApi = getTrimmedString(compat?.pluginApi);
|
||||
if (pluginApi) {
|
||||
compatibility.pluginApiRange = pluginApi;
|
||||
}
|
||||
const minGatewayVersion = getTrimmedString(compat?.minGatewayVersion) ?? minHostVersion;
|
||||
if (minGatewayVersion) {
|
||||
compatibility.minGatewayVersion = minGatewayVersion;
|
||||
}
|
||||
const builtWithOpenClawVersion = getTrimmedString(build?.openclawVersion) ?? version;
|
||||
if (builtWithOpenClawVersion) {
|
||||
compatibility.builtWithOpenClawVersion = builtWithOpenClawVersion;
|
||||
}
|
||||
const pluginSdkVersion = getTrimmedString(build?.pluginSdkVersion);
|
||||
if (pluginSdkVersion) {
|
||||
compatibility.pluginSdkVersion = pluginSdkVersion;
|
||||
}
|
||||
return Object.keys(compatibility).length > 0 ? compatibility : undefined;
|
||||
}
|
||||
export function listMissingOpenClawExternalCodePluginFieldPaths(packageJson) {
|
||||
const { compat, build } = readOpenClawBlock(packageJson);
|
||||
const missing = [];
|
||||
if (!getTrimmedString(compat?.pluginApi)) {
|
||||
missing.push("openclaw.compat.pluginApi");
|
||||
}
|
||||
if (!getTrimmedString(build?.openclawVersion)) {
|
||||
missing.push("openclaw.build.openclawVersion");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
export function validateOpenClawExternalCodePluginPackageJson(packageJson) {
|
||||
const issues = listMissingOpenClawExternalCodePluginFieldPaths(packageJson).map((fieldPath) => ({
|
||||
fieldPath,
|
||||
message: `${fieldPath} is required for external code plugins published to ClawHub.`,
|
||||
}));
|
||||
return {
|
||||
compatibility: normalizeOpenClawExternalPluginCompatibility(packageJson),
|
||||
issues,
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=openclawContract.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"openclawContract.js","sourceRoot":"","sources":["../src/openclawContract.ts"],"names":[],"mappings":"AAcA,MAAM,CAAC,MAAM,kDAAkD,GAAG;IAChE,2BAA2B;IAC3B,gCAAgC;CACxB,CAAC;AAEX,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,CAAC,KAAK,CAAC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;AAC9E,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAc;IACtC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;AAC9E,CAAC;AAED,SAAS,iBAAiB,CAAC,WAAoB;IAC7C,MAAM,IAAI,GAAG,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;IAC7D,MAAM,QAAQ,GAAG,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC;IACtE,MAAM,MAAM,GAAG,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC;IACxE,MAAM,KAAK,GAAG,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;IACrE,MAAM,OAAO,GAAG,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;IAC3E,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC;AAC1C,CAAC;AAED,MAAM,UAAU,4CAA4C,CAC1D,WAAoB;IAEpB,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;IACxE,MAAM,OAAO,GAAG,gBAAgB,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IAChD,MAAM,cAAc,GAAG,gBAAgB,CAAC,OAAO,EAAE,cAAc,CAAC,CAAC;IACjE,MAAM,aAAa,GAAyB,EAAE,CAAC;IAE/C,MAAM,SAAS,GAAG,gBAAgB,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IACtD,IAAI,SAAS,EAAE,CAAC;QACd,aAAa,CAAC,cAAc,GAAG,SAAS,CAAC;IAC3C,CAAC;IAED,MAAM,iBAAiB,GAAG,gBAAgB,CAAC,MAAM,EAAE,iBAAiB,CAAC,IAAI,cAAc,CAAC;IACxF,IAAI,iBAAiB,EAAE,CAAC;QACtB,aAAa,CAAC,iBAAiB,GAAG,iBAAiB,CAAC;IACtD,CAAC;IAED,MAAM,wBAAwB,GAAG,gBAAgB,CAAC,KAAK,EAAE,eAAe,CAAC,IAAI,OAAO,CAAC;IACrF,IAAI,wBAAwB,EAAE,CAAC;QAC7B,aAAa,CAAC,wBAAwB,GAAG,wBAAwB,CAAC;IACpE,CAAC;IAED,MAAM,gBAAgB,GAAG,gBAAgB,CAAC,KAAK,EAAE,gBAAgB,CAAC,CAAC;IACnE,IAAI,gBAAgB,EAAE,CAAC;QACrB,aAAa,CAAC,gBAAgB,GAAG,gBAAgB,CAAC;IACpD,CAAC;IAED,OAAO,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,SAAS,CAAC;AAC3E,CAAC;AAED,MAAM,UAAU,+CAA+C,CAAC,WAAoB;IAClF,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;IACzD,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,IAAI,CAAC,gBAAgB,CAAC,MAAM,EAAE,SAAS,CAAC,EAAE,CAAC;QACzC,OAAO,CAAC,IAAI,CAAC,2BAA2B,CAAC,CAAC;IAC5C,CAAC;IACD,IAAI,CAAC,gBAAgB,CAAC,KAAK,EAAE,eAAe,CAAC,EAAE,CAAC;QAC9C,OAAO,CAAC,IAAI,CAAC,gCAAgC,CAAC,CAAC;IACjD,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,MAAM,UAAU,6CAA6C,CAC3D,WAAoB;IAEpB,MAAM,MAAM,GAAG,+CAA+C,CAAC,WAAW,CAAC,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;QAC9F,SAAS;QACT,OAAO,EAAE,GAAG,SAAS,8DAA8D;KACpF,CAAC,CAAC,CAAC;IACJ,OAAO;QACL,aAAa,EAAE,4CAA4C,CAAC,WAAW,CAAC;QACxE,MAAM;KACP,CAAC;AACJ,CAAC"}
|
||||
Vendored
+102
@@ -46,6 +46,63 @@ export declare const PackageVerificationSummarySchema: import("arktype/internal/
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
|
||||
}, {}>;
|
||||
export type PackageVerificationSummary = (typeof PackageVerificationSummarySchema)[inferred];
|
||||
export declare const PackageVtAnalysisSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
verdict?: string | undefined;
|
||||
analysis?: string | undefined;
|
||||
source?: string | undefined;
|
||||
}, {}>;
|
||||
export type PackageVtAnalysis = (typeof PackageVtAnalysisSchema)[inferred];
|
||||
export declare const PackageLlmAnalysisDimensionSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
name: string;
|
||||
label: string;
|
||||
rating: string;
|
||||
detail: string;
|
||||
}, {}>;
|
||||
export type PackageLlmAnalysisDimension = (typeof PackageLlmAnalysisDimensionSchema)[inferred];
|
||||
export declare const PackageLlmAnalysisSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
verdict?: string | undefined;
|
||||
confidence?: string | undefined;
|
||||
summary?: string | undefined;
|
||||
dimensions?: {
|
||||
name: string;
|
||||
label: string;
|
||||
rating: string;
|
||||
detail: string;
|
||||
}[] | undefined;
|
||||
guidance?: string | undefined;
|
||||
findings?: string | undefined;
|
||||
model?: string | undefined;
|
||||
}, {}>;
|
||||
export type PackageLlmAnalysis = (typeof PackageLlmAnalysisSchema)[inferred];
|
||||
export declare const PackageStaticFindingSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
code: string;
|
||||
severity: string;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
evidence: string;
|
||||
}, {}>;
|
||||
export type PackageStaticFinding = (typeof PackageStaticFindingSchema)[inferred];
|
||||
export declare const PackageStaticScanSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
status: string;
|
||||
reasonCodes: string[];
|
||||
findings: {
|
||||
code: string;
|
||||
severity: string;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
evidence: string;
|
||||
}[];
|
||||
summary: string;
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
}, {}>;
|
||||
export type PackageStaticScan = (typeof PackageStaticScanSchema)[inferred];
|
||||
export declare const BundlePublishMetadataSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
id?: string | undefined;
|
||||
format?: string | undefined;
|
||||
@@ -120,6 +177,7 @@ export declare const ApiV1PackageListResponseSchema: import("arktype/internal/va
|
||||
}[];
|
||||
nextCursor: string | null;
|
||||
}, {}>;
|
||||
export type ApiV1PackageListResponse = (typeof ApiV1PackageListResponseSchema)[inferred];
|
||||
export declare const ApiV1PackageSearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
results: {
|
||||
score: number;
|
||||
@@ -141,6 +199,7 @@ export declare const ApiV1PackageSearchResponseSchema: import("arktype/internal/
|
||||
};
|
||||
}[];
|
||||
}, {}>;
|
||||
export type ApiV1PackageSearchResponse = (typeof ApiV1PackageSearchResponseSchema)[inferred];
|
||||
export declare const ApiV1PackageResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
package: {
|
||||
name: string;
|
||||
@@ -198,6 +257,7 @@ export declare const ApiV1PackageResponseSchema: import("arktype/internal/varian
|
||||
image?: string | null | undefined;
|
||||
} | null;
|
||||
}, {}>;
|
||||
export type ApiV1PackageResponse = (typeof ApiV1PackageResponseSchema)[inferred];
|
||||
export declare const ApiV1PackageVersionListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
items: {
|
||||
version: string;
|
||||
@@ -207,6 +267,7 @@ export declare const ApiV1PackageVersionListResponseSchema: import("arktype/inte
|
||||
}[];
|
||||
nextCursor: string | null;
|
||||
}, {}>;
|
||||
export type ApiV1PackageVersionListResponse = (typeof ApiV1PackageVersionListResponseSchema)[inferred];
|
||||
export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
package: {
|
||||
name: string;
|
||||
@@ -255,10 +316,51 @@ export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal
|
||||
hasProvenance?: boolean | undefined;
|
||||
scanStatus?: "clean" | "suspicious" | "malicious" | "pending" | "not-run" | undefined;
|
||||
} | null | undefined;
|
||||
sha256hash?: string | undefined;
|
||||
vtAnalysis?: {
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
verdict?: string | undefined;
|
||||
analysis?: string | undefined;
|
||||
source?: string | undefined;
|
||||
} | null | undefined;
|
||||
llmAnalysis?: {
|
||||
status: string;
|
||||
checkedAt: number;
|
||||
verdict?: string | undefined;
|
||||
confidence?: string | undefined;
|
||||
summary?: string | undefined;
|
||||
dimensions?: {
|
||||
name: string;
|
||||
label: string;
|
||||
rating: string;
|
||||
detail: string;
|
||||
}[] | undefined;
|
||||
guidance?: string | undefined;
|
||||
findings?: string | undefined;
|
||||
model?: string | undefined;
|
||||
} | null | undefined;
|
||||
staticScan?: {
|
||||
status: string;
|
||||
reasonCodes: string[];
|
||||
findings: {
|
||||
code: string;
|
||||
severity: string;
|
||||
file: string;
|
||||
line: number;
|
||||
message: string;
|
||||
evidence: string;
|
||||
}[];
|
||||
summary: string;
|
||||
engineVersion: string;
|
||||
checkedAt: number;
|
||||
} | null | undefined;
|
||||
} | null;
|
||||
}, {}>;
|
||||
export type ApiV1PackageVersionResponse = (typeof ApiV1PackageVersionResponseSchema)[inferred];
|
||||
export declare const ApiV1PackagePublishResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
packageId: string;
|
||||
releaseId: string;
|
||||
}, {}>;
|
||||
export type ApiV1PackagePublishResponse = (typeof ApiV1PackagePublishResponseSchema)[inferred];
|
||||
|
||||
Vendored
+44
@@ -40,6 +40,46 @@ export const PackageVerificationSummarySchema = type({
|
||||
hasProvenance: "boolean?",
|
||||
scanStatus: '"clean"|"suspicious"|"malicious"|"pending"|"not-run"?',
|
||||
});
|
||||
export const PackageVtAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
analysis: "string?",
|
||||
source: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export const PackageLlmAnalysisDimensionSchema = type({
|
||||
name: "string",
|
||||
label: "string",
|
||||
rating: "string",
|
||||
detail: "string",
|
||||
});
|
||||
export const PackageLlmAnalysisSchema = type({
|
||||
status: "string",
|
||||
verdict: "string?",
|
||||
confidence: "string?",
|
||||
summary: "string?",
|
||||
dimensions: PackageLlmAnalysisDimensionSchema.array().optional(),
|
||||
guidance: "string?",
|
||||
findings: "string?",
|
||||
model: "string?",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export const PackageStaticFindingSchema = type({
|
||||
code: "string",
|
||||
severity: "string",
|
||||
file: "string",
|
||||
line: "number",
|
||||
message: "string",
|
||||
evidence: "string",
|
||||
});
|
||||
export const PackageStaticScanSchema = type({
|
||||
status: "string",
|
||||
reasonCodes: "string[]",
|
||||
findings: PackageStaticFindingSchema.array(),
|
||||
summary: "string",
|
||||
engineVersion: "string",
|
||||
checkedAt: "number",
|
||||
});
|
||||
export const BundlePublishMetadataSchema = type({
|
||||
id: "string?",
|
||||
format: "string?",
|
||||
@@ -132,6 +172,10 @@ export const ApiV1PackageVersionResponseSchema = type({
|
||||
compatibility: PackageCompatibilitySchema.or("null").optional(),
|
||||
capabilities: PackageCapabilitySummarySchema.or("null").optional(),
|
||||
verification: PackageVerificationSummarySchema.or("null").optional(),
|
||||
sha256hash: "string?",
|
||||
vtAnalysis: PackageVtAnalysisSchema.or("null").optional(),
|
||||
llmAnalysis: PackageLlmAnalysisSchema.or("null").optional(),
|
||||
staticScan: PackageStaticScanSchema.or("null").optional(),
|
||||
}).or("null"),
|
||||
});
|
||||
export const ApiV1PackagePublishResponseSchema = type({
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -15,6 +15,7 @@ export declare const ApiRoutes: {
|
||||
readonly resolve: "/api/v1/resolve";
|
||||
readonly download: "/api/v1/download";
|
||||
readonly skills: "/api/v1/skills";
|
||||
readonly plugins: "/api/v1/plugins";
|
||||
readonly packages: "/api/v1/packages";
|
||||
readonly codePlugins: "/api/v1/code-plugins";
|
||||
readonly bundlePlugins: "/api/v1/bundle-plugins";
|
||||
|
||||
Vendored
+1
@@ -15,6 +15,7 @@ export const ApiRoutes = {
|
||||
resolve: "/api/v1/resolve",
|
||||
download: "/api/v1/download",
|
||||
skills: "/api/v1/skills",
|
||||
plugins: "/api/v1/plugins",
|
||||
packages: "/api/v1/packages",
|
||||
codePlugins: "/api/v1/code-plugins",
|
||||
bundlePlugins: "/api/v1/bundle-plugins",
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAC"}
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;CAChB,CAAC"}
|
||||
@@ -11,6 +11,18 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./licenseConstants": {
|
||||
"types": "./dist/licenseConstants.d.ts",
|
||||
"default": "./dist/licenseConstants.js"
|
||||
},
|
||||
"./routes": {
|
||||
"types": "./dist/routes.d.ts",
|
||||
"default": "./dist/routes.js"
|
||||
},
|
||||
"./textFiles": {
|
||||
"types": "./dist/textFiles.d.ts",
|
||||
"default": "./dist/textFiles.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type { ArkValidator } from "./ark.js";
|
||||
export { formatArkErrors, parseArk } from "./ark.js";
|
||||
export * from "./license.js";
|
||||
export * from "./openclawContract.js";
|
||||
export * from "./packages.js";
|
||||
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
|
||||
export * from "./schemas.js";
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { type inferred, type } from "arktype";
|
||||
import {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_NAME,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
PLATFORM_SKILL_LICENSE_URL,
|
||||
} from "./licenseConstants.js";
|
||||
|
||||
export const PLATFORM_SKILL_LICENSE = "MIT-0" as const;
|
||||
export const PLATFORM_SKILL_LICENSE_NAME = "MIT No Attribution" as const;
|
||||
export const PLATFORM_SKILL_LICENSE_SUMMARY =
|
||||
"Free to use, modify, and redistribute. No attribution required." as const;
|
||||
export const PLATFORM_SKILL_LICENSE_URL = "https://spdx.org/licenses/MIT-0.html" as const;
|
||||
export {
|
||||
PLATFORM_SKILL_LICENSE,
|
||||
PLATFORM_SKILL_LICENSE_NAME,
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
PLATFORM_SKILL_LICENSE_URL,
|
||||
};
|
||||
|
||||
export const SkillPlatformLicenseSchema = type('"MIT-0"');
|
||||
export type SkillPlatformLicense = (typeof SkillPlatformLicenseSchema)[inferred];
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const PLATFORM_SKILL_LICENSE = 'MIT-0' as const;
|
||||
export const PLATFORM_SKILL_LICENSE_NAME = 'MIT No Attribution' as const;
|
||||
export const PLATFORM_SKILL_LICENSE_SUMMARY =
|
||||
'Free to use, modify, and redistribute. No attribution required.' as const;
|
||||
export const PLATFORM_SKILL_LICENSE_URL = 'https://spdx.org/licenses/MIT-0.html' as const;
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { PackageCompatibility } from "./packages.js";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
export type OpenClawExternalPluginValidationIssue = {
|
||||
fieldPath: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type OpenClawExternalCodePluginValidation = {
|
||||
compatibility?: PackageCompatibility;
|
||||
issues: OpenClawExternalPluginValidationIssue[];
|
||||
};
|
||||
|
||||
export const OPENCLAW_EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS = [
|
||||
"openclaw.compat.pluginApi",
|
||||
"openclaw.build.openclawVersion",
|
||||
] as const;
|
||||
|
||||
function isRecord(value: unknown): value is JsonObject {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getTrimmedString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readOpenClawBlock(packageJson: unknown) {
|
||||
const root = isRecord(packageJson) ? packageJson : undefined;
|
||||
const openclaw = isRecord(root?.openclaw) ? root.openclaw : undefined;
|
||||
const compat = isRecord(openclaw?.compat) ? openclaw.compat : undefined;
|
||||
const build = isRecord(openclaw?.build) ? openclaw.build : undefined;
|
||||
const install = isRecord(openclaw?.install) ? openclaw.install : undefined;
|
||||
return { root, compat, build, install };
|
||||
}
|
||||
|
||||
export function normalizeOpenClawExternalPluginCompatibility(
|
||||
packageJson: unknown,
|
||||
): PackageCompatibility | undefined {
|
||||
const { root, compat, build, install } = readOpenClawBlock(packageJson);
|
||||
const version = getTrimmedString(root?.version);
|
||||
const minHostVersion = getTrimmedString(install?.minHostVersion);
|
||||
const compatibility: PackageCompatibility = {};
|
||||
|
||||
const pluginApi = getTrimmedString(compat?.pluginApi);
|
||||
if (pluginApi) {
|
||||
compatibility.pluginApiRange = pluginApi;
|
||||
}
|
||||
|
||||
const minGatewayVersion = getTrimmedString(compat?.minGatewayVersion) ?? minHostVersion;
|
||||
if (minGatewayVersion) {
|
||||
compatibility.minGatewayVersion = minGatewayVersion;
|
||||
}
|
||||
|
||||
const builtWithOpenClawVersion = getTrimmedString(build?.openclawVersion) ?? version;
|
||||
if (builtWithOpenClawVersion) {
|
||||
compatibility.builtWithOpenClawVersion = builtWithOpenClawVersion;
|
||||
}
|
||||
|
||||
const pluginSdkVersion = getTrimmedString(build?.pluginSdkVersion);
|
||||
if (pluginSdkVersion) {
|
||||
compatibility.pluginSdkVersion = pluginSdkVersion;
|
||||
}
|
||||
|
||||
return Object.keys(compatibility).length > 0 ? compatibility : undefined;
|
||||
}
|
||||
|
||||
export function listMissingOpenClawExternalCodePluginFieldPaths(packageJson: unknown): string[] {
|
||||
const { compat, build } = readOpenClawBlock(packageJson);
|
||||
const missing: string[] = [];
|
||||
if (!getTrimmedString(compat?.pluginApi)) {
|
||||
missing.push("openclaw.compat.pluginApi");
|
||||
}
|
||||
if (!getTrimmedString(build?.openclawVersion)) {
|
||||
missing.push("openclaw.build.openclawVersion");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function validateOpenClawExternalCodePluginPackageJson(
|
||||
packageJson: unknown,
|
||||
): OpenClawExternalCodePluginValidation {
|
||||
const issues = listMissingOpenClawExternalCodePluginFieldPaths(packageJson).map((fieldPath) => ({
|
||||
fieldPath,
|
||||
message: `${fieldPath} is required for external code plugins published to ClawHub.`,
|
||||
}));
|
||||
return {
|
||||
compatibility: normalizeOpenClawExternalPluginCompatibility(packageJson),
|
||||
issues,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user