Compare commits

...
62 changed files with 4031 additions and 1640 deletions
+3
View File
@@ -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
+207
View File
@@ -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
+12
View File
@@ -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`).
+2 -2
View File
@@ -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).
+4 -1
View File
@@ -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)
+4 -1
View File
@@ -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)
+10 -41
View File
@@ -1,3 +1,7 @@
import {
listMissingOpenClawExternalCodePluginFieldPaths,
normalizeOpenClawExternalPluginCompatibility,
} from "clawhub-schema";
import type {
BundlePublishMetadata,
PackageCapabilitySummary,
@@ -165,41 +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 version =
typeof packageJson?.version === "string" ? packageJson.version.trim() : undefined;
const minHostVersion =
typeof install?.minHostVersion === "string" ? install.minHostVersion.trim() : undefined;
const compatibility: PackageCompatibility = {};
if (typeof compat?.pluginApi === "string") {
compatibility.pluginApiRange = compat.pluginApi.trim();
}
if (typeof compat?.minGatewayVersion === "string") {
compatibility.minGatewayVersion = compat.minGatewayVersion.trim();
} else if (minHostVersion) {
compatibility.minGatewayVersion = minHostVersion;
}
if (typeof build?.openclawVersion === "string") {
compatibility.builtWithOpenClawVersion = build.openclawVersion.trim();
} else if (version) {
compatibility.builtWithOpenClawVersion = version;
}
if (typeof build?.pluginSdkVersion === "string") {
compatibility.pluginSdkVersion = build.pluginSdkVersion.trim();
}
return Object.keys(compatibility).length > 0 ? compatibility : undefined;
return normalizeOpenClawExternalPluginCompatibility(packageJson);
}
export function extractCodePluginArtifacts(params: {
@@ -212,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");
@@ -223,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([
@@ -311,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()) ||
+55 -3
View File
@@ -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`
+18
View File
@@ -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).
+2 -2
View File
@@ -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
View File
@@ -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 \
+1 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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!");
});
+27 -2
View File
@@ -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
+7 -2
View File
@@ -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",
+19
View File
@@ -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);
+22 -5
View File
@@ -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 });
}
+329 -98
View File
@@ -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 });
}
});
});
+26 -2
View File
@@ -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],
);
});
+46 -41
View File
@@ -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(),
+36 -22
View File
@@ -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");
});
+10 -5
View File
@@ -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 () =>
+109 -225
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -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,
};
}
@@ -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,
}),
};
}
+54
View File
@@ -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()?.();
}
},
};
}
+2 -1
View File
@@ -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/**"],
},
});
+12
View File
@@ -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/**"],
},
});
+1
View File
@@ -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
View File
@@ -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";
+1 -1
View File
@@ -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"}
+13
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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"}
+6
View File
@@ -177,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;
@@ -198,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;
@@ -255,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;
@@ -264,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;
@@ -353,8 +357,10 @@ export declare const ApiV1PackageVersionResponseSchema: import("arktype/internal
} | 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];
File diff suppressed because one or more lines are too long
+1
View File
@@ -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";
+91
View File
@@ -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,
};
}
+7
View File
@@ -152,6 +152,7 @@ export const ApiV1PackageListResponseSchema = type({
items: PackageListItemSchema.array(),
nextCursor: "string|null",
});
export type ApiV1PackageListResponse = (typeof ApiV1PackageListResponseSchema)[inferred];
export const ApiV1PackageSearchResponseSchema = type({
results: type({
@@ -159,6 +160,7 @@ export const ApiV1PackageSearchResponseSchema = type({
package: PackageListItemSchema,
}).array(),
});
export type ApiV1PackageSearchResponse = (typeof ApiV1PackageSearchResponseSchema)[inferred];
export const ApiV1PackageResponseSchema = type({
package: type({
@@ -184,6 +186,7 @@ export const ApiV1PackageResponseSchema = type({
image: "string|null?",
}).or("null"),
});
export type ApiV1PackageResponse = (typeof ApiV1PackageResponseSchema)[inferred];
export const ApiV1PackageVersionListResponseSchema = type({
items: type({
@@ -194,6 +197,8 @@ export const ApiV1PackageVersionListResponseSchema = type({
}).array(),
nextCursor: "string|null",
});
export type ApiV1PackageVersionListResponse =
(typeof ApiV1PackageVersionListResponseSchema)[inferred];
export const ApiV1PackageVersionResponseSchema = type({
package: type({
@@ -216,9 +221,11 @@ export const ApiV1PackageVersionResponseSchema = type({
staticScan: PackageStaticScanSchema.or("null").optional(),
}).or("null"),
});
export type ApiV1PackageVersionResponse = (typeof ApiV1PackageVersionResponseSchema)[inferred];
export const ApiV1PackagePublishResponseSchema = type({
ok: "true",
packageId: "string",
releaseId: "string",
});
export type ApiV1PackagePublishResponse = (typeof ApiV1PackagePublishResponseSchema)[inferred];
+131 -19
View File
@@ -1,3 +1,5 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createElement } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -20,6 +22,7 @@ const generateUploadUrl = vi.fn();
const publishRelease = vi.fn();
const fetchMock = vi.fn();
const useAuthStatusMock = vi.fn();
const originalFetch = globalThis.fetch;
vi.mock("convex/react", () => ({
useMutation: () => generateUploadUrl,
@@ -31,15 +34,10 @@ vi.mock("../lib/useAuthStatus", () => ({
useAuthStatus: () => useAuthStatusMock(),
}));
import { Route } from "../routes/publish-plugin";
import { PublishPluginRoute, Route } from "../routes/publish-plugin";
function renderPublishRoute() {
const route = Route as unknown as {
__config: {
component: unknown;
};
};
render(createElement(route.__config.component as never));
render(createElement(PublishPluginRoute as never));
}
function withRelativePath(file: File, path: string) {
@@ -50,12 +48,31 @@ function withRelativePath(file: File, path: string) {
return file;
}
function makeCodePluginPackageJson(overrides: Record<string, unknown>) {
return JSON.stringify({
openclaw: {
extensions: ["./index.ts"],
compat: {
pluginApi: ">=2026.3.24-beta.2",
},
build: {
openclawVersion: "2026.3.24-beta.2",
},
},
...overrides,
});
}
function getFileInput() {
const input = document.querySelector('input[type="file"]');
if (!(input instanceof HTMLInputElement)) throw new Error("Missing file input");
return input;
}
function getFileInputs() {
return Array.from(document.querySelectorAll('input[type="file"]')) as HTMLInputElement[];
}
describe("plugins publish route", () => {
beforeEach(() => {
generateUploadUrl.mockReset();
@@ -76,19 +93,23 @@ describe("plugins publish route", () => {
storageId: `storage:${((init?.body as File | undefined)?.name ?? "unknown").replaceAll("/", "_")}`,
}),
}));
vi.stubGlobal("fetch", fetchMock);
Object.defineProperty(globalThis, "fetch", {
value: fetchMock,
configurable: true,
writable: true,
});
});
afterEach(() => {
vi.unstubAllGlobals();
Object.defineProperty(globalThis, "fetch", {
value: originalFetch,
configurable: true,
writable: true,
});
});
it("registers the publish form on /publish-plugin", () => {
const route = Route as unknown as {
__path: string;
};
expect(route.__path).toBe("/publish-plugin");
expect(Route).toBeTruthy();
});
it("keeps metadata inputs locked until plugin code is uploaded", () => {
@@ -102,13 +123,43 @@ describe("plugins publish route", () => {
expect(screen.getByRole("button", { name: "Publish" }).getAttribute("disabled")).not.toBeNull();
});
it("opens only the directory picker when clicking Choose folder", () => {
renderPublishRoute();
const [archiveInput, directoryInput] = getFileInputs();
const archiveClick = vi.fn();
const directoryClick = vi.fn();
archiveInput.click = archiveClick;
directoryInput.click = directoryClick;
fireEvent.click(screen.getByRole("button", { name: "Choose folder" }));
expect(directoryClick).toHaveBeenCalledTimes(1);
expect(archiveClick).not.toHaveBeenCalled();
});
it("opens only the archive picker when clicking Browse files", () => {
renderPublishRoute();
const [archiveInput, directoryInput] = getFileInputs();
const archiveClick = vi.fn();
const directoryClick = vi.fn();
archiveInput.click = archiveClick;
directoryInput.click = directoryClick;
fireEvent.click(screen.getByRole("button", { name: "Browse files" }));
expect(archiveClick).toHaveBeenCalledTimes(1);
expect(directoryClick).not.toHaveBeenCalled();
});
it("publishes a code plugin folder with source metadata and normalized file paths", async () => {
renderPublishRoute();
const packageJson = withRelativePath(
new File(
[
JSON.stringify({
makeCodePluginPackageJson({
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.3",
@@ -181,6 +232,55 @@ describe("plugins publish route", () => {
});
});
it("surfaces missing OpenClaw compatibility metadata before publish", async () => {
renderPublishRoute();
const packageJson = withRelativePath(
new File(
[
makeCodePluginPackageJson({
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.3",
openclaw: {
extensions: ["./index.ts"],
},
}),
],
"package.json",
{ type: "application/json" },
),
"demo-plugin/package.json",
);
const manifest = withRelativePath(
new File(
[
JSON.stringify({
id: "demo.plugin",
name: "Demo Plugin",
configSchema: { type: "object", additionalProperties: false },
}),
],
"openclaw.plugin.json",
{ type: "application/json" },
),
"demo-plugin/openclaw.plugin.json",
);
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest] } });
await waitFor(() => {
expect(
screen.getByText(/Missing required OpenClaw package metadata:/i),
).toBeTruthy();
});
expect(screen.getByText(/openclaw\.compat\.pluginApi/i)).toBeTruthy();
expect(screen.getByText(/openclaw\.build\.openclawVersion/i)).toBeTruthy();
expect(screen.getByRole("button", { name: "Publish" }).getAttribute("disabled")).not.toBeNull();
expect(publishRelease).not.toHaveBeenCalled();
});
it("publishes a bundle plugin folder with bundle metadata", async () => {
renderPublishRoute();
@@ -263,6 +363,16 @@ describe("plugins publish route", () => {
JSON.stringify({
name: "@opik/opik-openclaw",
version: "0.2.9",
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",
},
},
repository: {
type: "git",
url: "https://github.com/comet-ml/opik-openclaw.git",
@@ -291,7 +401,9 @@ describe("plugins publish route", () => {
expect(screen.getByDisplayValue("0.2.9")).toBeTruthy();
expect(screen.getByDisplayValue("comet-ml/opik-openclaw")).toBeTruthy();
expect(screen.getByText(/Metadata detected and prefilled/i)).toBeTruthy();
expect(screen.getByText(/Autofilled package type, plugin name, display name, version, source repo\./i)).toBeTruthy();
expect(
screen.getByText(/Autofilled package type, plugin name, display name, version, source repo, compatibility\./i),
).toBeTruthy();
expect(screen.getByText("Package manifest")).toBeTruthy();
expect(screen.getByText("Plugin manifest")).toBeTruthy();
expect(screen.queryByText("opik-openclaw-0.2.9/package.json")).toBeNull();
@@ -302,7 +414,7 @@ describe("plugins publish route", () => {
renderPublishRoute();
const packageJson = withRelativePath(
new File([JSON.stringify({ name: "demo-plugin", version: "1.0.0" })], "package.json", {
new File([makeCodePluginPackageJson({ name: "demo-plugin", version: "1.0.0" })], "package.json", {
type: "application/json",
}),
"demo-plugin/package.json",
@@ -369,7 +481,7 @@ describe("plugins publish route", () => {
renderPublishRoute();
const packageJson = withRelativePath(
new File([JSON.stringify({ name: "demo-plugin", version: "1.0.0" })], "package.json", {
new File([makeCodePluginPackageJson({ name: "demo-plugin", version: "1.0.0" })], "package.json", {
type: "application/json",
}),
"demo-plugin/package.json",
@@ -400,7 +512,7 @@ describe("plugins publish route", () => {
renderPublishRoute();
const packageJson = withRelativePath(
new File([JSON.stringify({ name: "demo-plugin", version: "1.0.0" })], "package.json", {
new File([makeCodePluginPackageJson({ name: "demo-plugin", version: "1.0.0" })], "package.json", {
type: "application/json",
}),
"demo-plugin/package.json",
+159
View File
@@ -0,0 +1,159 @@
import { Package } from 'lucide-react';
import type { PackageCompatibility } from 'clawhub-schema';
import { useRef, useState } from 'react';
import { expandDroppedItems } from '../lib/uploadFiles';
import { formatBytes } from '../routes/upload/-utils';
import { formatPackageCompatibility } from '../lib/pluginPublishPrefill';
export function PackageSourceChooser(props: {
files: File[];
totalBytes: number;
normalizedPaths: string[];
normalizedPathSet: Set<string>;
ignoredPaths: string[];
detectedPrefillFields: string[];
family: 'code-plugin' | 'bundle-plugin';
validationError: string | null;
codePluginFieldIssues: string[];
codePluginCompatibility: PackageCompatibility | null;
onPickFiles: (selected: File[]) => Promise<void>;
}) {
const [isDragging, setIsDragging] = useState(false);
const archiveInputRef = useRef<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const isMetadataLocked = props.files.length === 0;
const setDirectoryInputRef = (node: HTMLInputElement | null) => {
directoryInputRef.current = node;
if (node) {
node.setAttribute('webkitdirectory', '');
node.setAttribute('directory', '');
}
};
return (
<div className="card upload-panel">
<input
ref={archiveInputRef}
className="upload-file-input"
type="file"
multiple
accept=".zip,.tgz,.tar.gz,application/zip,application/gzip,application/x-gzip,application/x-tar"
onChange={(event) => {
const selected = Array.from(event.target.files ?? []);
void props.onPickFiles(selected);
}}
/>
<input
ref={setDirectoryInputRef}
className="upload-file-input"
type="file"
multiple
onChange={(event) => {
const selected = Array.from(event.target.files ?? []);
void props.onPickFiles(selected);
}}
/>
<div
className={`upload-dropzone${isDragging ? ' is-dragging' : ''}`}
onDragOver={(event) => {
event.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragging(false);
void (async () => {
const dropped = event.dataTransfer.items?.length
? await expandDroppedItems(event.dataTransfer.items)
: Array.from(event.dataTransfer.files);
await props.onPickFiles(dropped);
})();
}}
>
<div className="plugin-dropzone-art" aria-hidden="true">
<Package size={28} />
</div>
<div className="upload-dropzone-copy">
<div className="upload-dropzone-title-row">
<strong>Upload plugin code first</strong>
<span className="upload-dropzone-count">
{props.files.length} files · {formatBytes(props.totalBytes)}
</span>
</div>
<span className="upload-dropzone-hint">
Drag a folder, zip, or tgz here. We inspect the package to unlock and prefill the rest
of the form.
</span>
<div className="plugin-dropzone-actions">
<button
className="btn upload-picker-btn"
type="button"
onClick={() => archiveInputRef.current?.click()}
>
Browse files
</button>
<button
className="btn upload-picker-btn plugin-dropzone-secondary"
type="button"
onClick={() => directoryInputRef.current?.click()}
>
Choose folder
</button>
</div>
</div>
</div>
<div className={`plugin-upload-summary${isMetadataLocked ? '' : ' is-ready'}`}>
{props.normalizedPaths.length === 0 ? (
<div className="stat">No plugin package selected yet.</div>
) : (
<>
<div className="plugin-upload-summary-row">
<strong>Package detected</strong>
<span className="upload-dropzone-count">
{props.files.length} files · {formatBytes(props.totalBytes)}
</span>
</div>
<div className="plugin-upload-summary-copy">
{props.detectedPrefillFields.length > 0
? `Autofilled ${props.detectedPrefillFields.join(', ')}.`
: 'Package files were detected. Review and fill the release details below.'}
</div>
<div className="plugin-upload-summary-tags">
{props.normalizedPathSet.has('package.json') ? (
<span className="tag">Package manifest</span>
) : null}
{props.normalizedPathSet.has('openclaw.plugin.json') ? (
<span className="tag">Plugin manifest</span>
) : null}
{props.normalizedPathSet.has('openclaw.bundle.json') ? (
<span className="tag">Bundle manifest</span>
) : null}
{props.normalizedPathSet.has('readme.md') || props.normalizedPathSet.has('readme.mdx') ? (
<span className="tag">README</span>
) : null}
{props.ignoredPaths.length > 0 ? (
<span className="tag">Ignored {props.ignoredPaths.length} files</span>
) : null}
</div>
</>
)}
</div>
{props.validationError ? <div className="tag tag-accent">{props.validationError}</div> : null}
{props.family === 'code-plugin' && props.codePluginFieldIssues.length > 0 ? (
<div className="tag tag-accent">
Missing required OpenClaw package metadata: {props.codePluginFieldIssues.join(', ')}. Add these
fields to <code>package.json</code> before publishing. See{' '}
<a href="/plugins/sdk-setup#package-metadata">Plugin Setup and Config</a>.
</div>
) : null}
{props.family === 'code-plugin' && props.codePluginCompatibility ? (
<div className="plugin-upload-summary-copy">
Compatibility: {formatPackageCompatibility(props.codePluginCompatibility)}
</div>
) : null}
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import type { PackageCompatibility } from 'clawhub-schema';
import {
normalizeOpenClawExternalPluginCompatibility,
validateOpenClawExternalCodePluginPackageJson,
} from 'clawhub-schema';
type JsonRecord = Record<string, unknown>;
export type PluginPublishPrefill = {
family?: 'code-plugin' | 'bundle-plugin';
name?: string;
displayName?: string;
version?: string;
sourceRepo?: string;
bundleFormat?: string;
hostTargets?: string;
compatibility?: PackageCompatibility;
missingRequiredFields?: string[];
};
function isRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function getString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function getStringList(value: unknown) {
if (Array.isArray(value)) return value.map(getString).filter(Boolean) as string[];
if (typeof value === 'string') {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
return [];
}
async function readJsonUploadFile(
files: Array<{ file: File; path: string }>,
expectedPath: string,
): Promise<JsonRecord | null> {
const normalizedExpectedPath = expectedPath.toLowerCase();
const expectedFileName = normalizedExpectedPath.split('/').at(-1);
const entry =
files.find((file) => file.path.toLowerCase() === normalizedExpectedPath) ??
files.find((file) => file.path.toLowerCase().endsWith(`/${normalizedExpectedPath}`)) ??
files.find((file) => {
const normalizedPath = file.path.toLowerCase();
return expectedFileName ? normalizedPath.split('/').at(-1) === expectedFileName : false;
});
if (!entry) return null;
try {
const parsed = JSON.parse((await entry.file.text()).replace(/^\uFEFF/, '')) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
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 (url.hostname !== 'github.com' && url.hostname !== 'www.github.com') return undefined;
const [owner, repo] = url.pathname.replace(/^\/+|\/+$/g, '').split('/');
if (!owner || !repo) return undefined;
return `${owner}/${repo}`;
} catch {
return undefined;
}
}
function extractSourceRepo(packageJson: JsonRecord | null) {
if (!packageJson) return undefined;
const repository = packageJson.repository;
if (typeof repository === 'string') return normalizeGitHubRepo(repository);
if (isRecord(repository) && typeof repository.url === 'string') {
return normalizeGitHubRepo(repository.url);
}
if (typeof packageJson.homepage === 'string') return normalizeGitHubRepo(packageJson.homepage);
if (isRecord(packageJson.bugs) && typeof packageJson.bugs.url === 'string') {
return normalizeGitHubRepo(packageJson.bugs.url);
}
return undefined;
}
export async function derivePluginPrefill(
files: Array<{ file: File; path: string }>,
): Promise<PluginPublishPrefill> {
const packageJson = await readJsonUploadFile(files, 'package.json');
const pluginManifest = await readJsonUploadFile(files, 'openclaw.plugin.json');
const bundleManifest = await readJsonUploadFile(files, 'openclaw.bundle.json');
const openclaw = isRecord(packageJson?.openclaw) ? packageJson.openclaw : undefined;
const hostTargets = bundleManifest
? [...new Set([...getStringList(bundleManifest.hostTargets), ...getStringList(openclaw?.hostTargets)])]
: [];
return {
family: pluginManifest ? 'code-plugin' : bundleManifest ? 'bundle-plugin' : undefined,
name: getString(packageJson?.name) ?? getString(pluginManifest?.id) ?? getString(bundleManifest?.id),
displayName:
getString(packageJson?.displayName) ??
getString(pluginManifest?.name) ??
getString(bundleManifest?.name),
version: getString(packageJson?.version),
sourceRepo: extractSourceRepo(packageJson),
bundleFormat: getString(bundleManifest?.format) ?? getString(openclaw?.bundleFormat),
hostTargets: hostTargets.length > 0 ? hostTargets.join(', ') : undefined,
compatibility: pluginManifest ? normalizeOpenClawExternalPluginCompatibility(packageJson) : undefined,
missingRequiredFields: pluginManifest
? validateOpenClawExternalCodePluginPackageJson(packageJson).issues.map(
(issue) => issue.fieldPath,
)
: undefined,
};
}
export function listPrefilledFields(prefill: PluginPublishPrefill) {
const fields: string[] = [];
if (prefill.family) fields.push('package type');
if (prefill.name) fields.push('plugin name');
if (prefill.displayName) fields.push('display name');
if (prefill.version) fields.push('version');
if (prefill.sourceRepo) fields.push('source repo');
if (prefill.compatibility) fields.push('compatibility');
if (prefill.bundleFormat) fields.push('bundle format');
if (prefill.hostTargets) fields.push('host targets');
return fields;
}
export function formatPackageCompatibility(compatibility: PackageCompatibility) {
return [
compatibility.pluginApiRange ? `pluginApi=${compatibility.pluginApiRange}` : null,
compatibility.builtWithOpenClawVersion
? `builtWith=${compatibility.builtWithOpenClawVersion}`
: null,
compatibility.pluginSdkVersion ? `sdk=${compatibility.pluginSdkVersion}` : null,
compatibility.minGatewayVersion ? `minGateway=${compatibility.minGatewayVersion}` : null,
]
.filter(Boolean)
.join(', ');
}
+37 -269
View File
@@ -1,9 +1,10 @@
import { createFileRoute, useSearch } from "@tanstack/react-router";
import { Package } from "lucide-react";
import type { PackageCompatibility } from "clawhub-schema";
import { useAction, useMutation, useQuery } from "convex/react";
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { startTransition, useEffect, useMemo, useState } from "react";
import semver from "semver";
import { api } from "../../convex/_generated/api";
import { PackageSourceChooser } from "../components/PackageSourceChooser";
import {
MAX_PUBLISH_FILE_BYTES,
MAX_PUBLISH_TOTAL_BYTES,
@@ -13,9 +14,13 @@ import {
filterIgnoredPackageFiles,
normalizePackageUploadFiles,
} from "../lib/packageUpload";
import { expandDroppedItems, expandFilesWithReport } from "../lib/uploadFiles";
import {
derivePluginPrefill,
listPrefilledFields,
} from "../lib/pluginPublishPrefill";
import { expandFilesWithReport } from "../lib/uploadFiles";
import { useAuthStatus } from "../lib/useAuthStatus";
import { formatBytes, formatPublishError, hashFile, uploadFile } from "./upload/-utils";
import { formatPublishError, hashFile, uploadFile } from "./upload/-utils";
export const Route = createFileRoute("/publish-plugin")({
validateSearch: (search) => ({
@@ -38,7 +43,7 @@ const apiRefs = api as unknown as {
};
};
function PublishPluginRoute() {
export function PublishPluginRoute() {
const search = useSearch({ from: "/publish-plugin" });
const { isAuthenticated } = useAuthStatus();
const publishers = useQuery(api.publishers.listMine) as
@@ -73,19 +78,12 @@ function PublishPluginRoute() {
const [files, setFiles] = useState<File[]>([]);
const [ignoredPaths, setIgnoredPaths] = useState<string[]>([]);
const [detectedPrefillFields, setDetectedPrefillFields] = useState<string[]>([]);
const [codePluginFieldIssues, setCodePluginFieldIssues] = useState<string[]>([]);
const [codePluginCompatibility, setCodePluginCompatibility] = useState<PackageCompatibility | null>(
null,
);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const archiveInputRef = useRef<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const setDirectoryInputRef = (node: HTMLInputElement | null) => {
directoryInputRef.current = node;
if (node) {
node.setAttribute("webkitdirectory", "");
node.setAttribute("directory", "");
}
};
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
const normalizedPaths = useMemo(
@@ -126,6 +124,8 @@ function PublishPluginRoute() {
setError(null);
const prefill = await derivePluginPrefill(normalized);
setDetectedPrefillFields(listPrefilledFields(prefill));
setCodePluginFieldIssues(prefill.missingRequiredFields ?? []);
setCodePluginCompatibility(prefill.compatibility ?? null);
if (prefill.family) setFamily(prefill.family);
if (prefill.name) setName(prefill.name);
if (prefill.displayName) setDisplayName(prefill.displayName);
@@ -163,132 +163,19 @@ function PublishPluginRoute() {
) : null}
</header>
<div className="card upload-panel">
<div
className={`upload-dropzone${isDragging ? " is-dragging" : ""}`}
role="button"
tabIndex={0}
onClick={(event) => {
if ((event.target as HTMLElement | null)?.closest("button")) return;
archiveInputRef.current?.click();
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
archiveInputRef.current?.click();
}}
onDragOver={(event) => {
event.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragging(false);
void (async () => {
const dropped = event.dataTransfer.items?.length
? await expandDroppedItems(event.dataTransfer.items)
: Array.from(event.dataTransfer.files);
await onPickFiles(dropped);
})();
}}
>
<input
ref={archiveInputRef}
className="upload-file-input"
type="file"
multiple
accept=".zip,.tgz,.tar.gz,application/zip,application/gzip,application/x-gzip,application/x-tar"
onChange={(event) => {
const selected = Array.from(event.target.files ?? []);
void onPickFiles(selected);
}}
/>
<input
ref={setDirectoryInputRef}
className="upload-file-input"
type="file"
multiple
onChange={(event) => {
const selected = Array.from(event.target.files ?? []);
void onPickFiles(selected);
}}
/>
<div className="plugin-dropzone-art" aria-hidden="true">
<Package size={28} />
</div>
<div className="upload-dropzone-copy">
<div className="upload-dropzone-title-row">
<strong>Upload plugin code first</strong>
<span className="upload-dropzone-count">
{files.length} files · {formatBytes(totalBytes)}
</span>
</div>
<span className="upload-dropzone-hint">
Drag a folder, zip, or tgz here. We inspect the package to unlock and prefill the rest
of the form.
</span>
<div className="plugin-dropzone-actions">
<button
className="btn upload-picker-btn"
type="button"
onClick={(event) => {
event.stopPropagation();
archiveInputRef.current?.click();
}}
>
Browse files
</button>
<button
className="btn upload-picker-btn plugin-dropzone-secondary"
type="button"
onClick={(event) => {
event.stopPropagation();
directoryInputRef.current?.click();
}}
>
Choose folder
</button>
</div>
</div>
</div>
<div className={`plugin-upload-summary${isMetadataLocked ? "" : " is-ready"}`}>
{normalizedPaths.length === 0 ? (
<div className="stat">No plugin package selected yet.</div>
) : (
<>
<div className="plugin-upload-summary-row">
<strong>Package detected</strong>
<span className="upload-dropzone-count">
{files.length} files · {formatBytes(totalBytes)}
</span>
</div>
<div className="plugin-upload-summary-copy">
{detectedPrefillFields.length > 0
? `Autofilled ${detectedPrefillFields.join(", ")}.`
: "Package files were detected. Review and fill the release details below."}
</div>
<div className="plugin-upload-summary-tags">
{normalizedPathSet.has("package.json") ? <span className="tag">Package manifest</span> : null}
{normalizedPathSet.has("openclaw.plugin.json") ? (
<span className="tag">Plugin manifest</span>
) : null}
{normalizedPathSet.has("openclaw.bundle.json") ? (
<span className="tag">Bundle manifest</span>
) : null}
{normalizedPathSet.has("readme.md") || normalizedPathSet.has("readme.mdx") ? (
<span className="tag">README</span>
) : null}
{ignoredPaths.length > 0 ? (
<span className="tag">Ignored {ignoredPaths.length} files</span>
) : null}
</div>
</>
)}
</div>
{validationError ? <div className="tag tag-accent">{validationError}</div> : null}
</div>
<PackageSourceChooser
files={files}
totalBytes={totalBytes}
normalizedPaths={normalizedPaths}
normalizedPathSet={normalizedPathSet}
ignoredPaths={ignoredPaths}
detectedPrefillFields={detectedPrefillFields}
family={family}
validationError={validationError}
codePluginFieldIssues={codePluginFieldIssues}
codePluginCompatibility={codePluginCompatibility}
onPickFiles={onPickFiles}
/>
<div
className={`card plugin-publish-form${isMetadataLocked ? " is-locked" : ""}`}
@@ -408,7 +295,8 @@ function PublishPluginRoute() {
files.length === 0 ||
Boolean(validationError) ||
isSubmitting ||
(family === "code-plugin" && (!sourceRepo.trim() || !sourceCommit.trim()))
(family === "code-plugin" &&
(!sourceRepo.trim() || !sourceCommit.trim() || codePluginFieldIssues.length > 0))
}
onClick={() => {
startTransition(() => {
@@ -418,6 +306,12 @@ function PublishPluginRoute() {
setError(validationError);
return;
}
if (family === "code-plugin" && codePluginFieldIssues.length > 0) {
setError(
`Missing required OpenClaw package metadata: ${codePluginFieldIssues.join(", ")}`,
);
return;
}
setStatus("Uploading files…");
setError(null);
const uploaded = await buildPackageUploadEntries(files, {
@@ -479,129 +373,3 @@ function PublishPluginRoute() {
</main>
);
}
type JsonRecord = Record<string, unknown>;
type PluginPublishPrefill = {
family?: "code-plugin" | "bundle-plugin";
name?: string;
displayName?: string;
version?: string;
sourceRepo?: string;
bundleFormat?: string;
hostTargets?: string;
};
function isRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function getString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function getStringList(value: unknown) {
if (Array.isArray(value)) return value.map(getString).filter(Boolean) as string[];
if (typeof value === "string") {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
return [];
}
async function readJsonUploadFile(
files: Array<{ file: File; path: string }>,
expectedPath: string,
): Promise<JsonRecord | null> {
const normalizedExpectedPath = expectedPath.toLowerCase();
const expectedFileName = normalizedExpectedPath.split("/").at(-1);
const entry =
files.find((file) => file.path.toLowerCase() === normalizedExpectedPath) ??
files.find((file) => file.path.toLowerCase().endsWith(`/${normalizedExpectedPath}`)) ??
files.find((file) => {
const normalizedPath = file.path.toLowerCase();
return expectedFileName ? normalizedPath.split("/").at(-1) === expectedFileName : false;
});
if (!entry) return null;
try {
const parsed = JSON.parse((await entry.file.text()).replace(/^\uFEFF/, "")) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
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 (url.hostname !== "github.com" && url.hostname !== "www.github.com") return undefined;
const [owner, repo] = url.pathname.replace(/^\/+|\/+$/g, "").split("/");
if (!owner || !repo) return undefined;
return `${owner}/${repo}`;
} catch {
return undefined;
}
}
function extractSourceRepo(packageJson: JsonRecord | null) {
if (!packageJson) return undefined;
const repository = packageJson.repository;
if (typeof repository === "string") return normalizeGitHubRepo(repository);
if (isRecord(repository) && typeof repository.url === "string") {
return normalizeGitHubRepo(repository.url);
}
if (typeof packageJson.homepage === "string") return normalizeGitHubRepo(packageJson.homepage);
if (isRecord(packageJson.bugs) && typeof packageJson.bugs.url === "string") {
return normalizeGitHubRepo(packageJson.bugs.url);
}
return undefined;
}
async function derivePluginPrefill(
files: Array<{ file: File; path: string }>,
): Promise<PluginPublishPrefill> {
const packageJson = await readJsonUploadFile(files, "package.json");
const pluginManifest = await readJsonUploadFile(files, "openclaw.plugin.json");
const bundleManifest = await readJsonUploadFile(files, "openclaw.bundle.json");
const openclaw = isRecord(packageJson?.openclaw) ? packageJson.openclaw : undefined;
const hostTargets = bundleManifest
? [...new Set([...getStringList(bundleManifest.hostTargets), ...getStringList(openclaw?.hostTargets)])]
: [];
return {
family: pluginManifest ? "code-plugin" : bundleManifest ? "bundle-plugin" : undefined,
name: getString(packageJson?.name) ?? getString(pluginManifest?.id) ?? getString(bundleManifest?.id),
displayName:
getString(packageJson?.displayName) ??
getString(pluginManifest?.name) ??
getString(bundleManifest?.name),
version: getString(packageJson?.version),
sourceRepo: extractSourceRepo(packageJson),
bundleFormat: getString(bundleManifest?.format) ?? getString(openclaw?.bundleFormat),
hostTargets: hostTargets.length > 0 ? hostTargets.join(", ") : undefined,
};
}
function listPrefilledFields(prefill: PluginPublishPrefill) {
const fields: string[] = [];
if (prefill.family) fields.push("package type");
if (prefill.name) fields.push("plugin name");
if (prefill.displayName) fields.push("display name");
if (prefill.version) fields.push("version");
if (prefill.sourceRepo) fields.push("source repo");
if (prefill.bundleFormat) fields.push("bundle format");
if (prefill.hostTargets) fields.push("host targets");
return fields;
}
+1 -1
View File
@@ -23,7 +23,7 @@ describe("Settings", () => {
render(<Settings />);
expect(screen.getByText(/sign in to access settings\./i)).toBeInTheDocument();
expect(screen.getByText(/sign in to access settings\./i)).toBeTruthy();
expect(useQueryMock.mock.calls.some(([, args]) => args === "skip")).toBe(true);
});
});
+2 -5
View File
@@ -15,6 +15,7 @@ export default defineConfig({
"**/dist/**",
"**/coverage/**",
"**/convex/_generated/**",
"packages/clawdhub/**",
"e2e/**",
"**/*.e2e.test.ts",
],
@@ -33,7 +34,6 @@ export default defineConfig({
"convex/lib/skillZip.ts",
"convex/lib/tokens.ts",
"convex/httpApi.ts",
"packages/clawdhub/src/**/*.ts",
"packages/schema/src/**/*.ts",
],
exclude: [
@@ -44,10 +44,7 @@ export default defineConfig({
"dist/",
"coverage/",
"convex/_generated/",
"packages/clawdhub/src/cli/**",
"packages/clawdhub/src/cli.ts",
"packages/clawdhub/src/config.ts",
"packages/clawdhub/src/types.ts",
"packages/clawdhub/**",
"packages/schema/dist/",
"e2e/**",
],