mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83708f24ba | ||
|
|
6c60356b54 | ||
|
|
5b920dcd6f | ||
|
|
f285efa059 | ||
|
|
d314617728 | ||
|
|
171178fb19 |
@@ -31,6 +31,9 @@ jobs:
|
||||
- name: Coverage
|
||||
run: bun run coverage
|
||||
|
||||
- name: ClawHub CLI Verify
|
||||
run: bun run --cwd packages/clawdhub verify
|
||||
|
||||
- name: Typecheck
|
||||
run: |
|
||||
bunx tsc --noEmit
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
name: Package Publish
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
source:
|
||||
description: Package source to publish. Usually owner/repo, owner/repo@ref, or a GitHub URL.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
ref:
|
||||
description: Optional ref to append to the source when source is not already pinned.
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Preview only. When true, no publish mutation is performed.
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
json:
|
||||
description: Emit structured JSON output.
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
registry:
|
||||
description: ClawHub registry URL.
|
||||
required: false
|
||||
type: string
|
||||
default: https://clawhub.ai
|
||||
site:
|
||||
description: ClawHub site URL.
|
||||
required: false
|
||||
type: string
|
||||
default: https://clawhub.ai
|
||||
owner:
|
||||
description: Optional owner handle override for org/shared publishing.
|
||||
required: false
|
||||
type: string
|
||||
version:
|
||||
description: Optional package version override.
|
||||
required: false
|
||||
type: string
|
||||
tags:
|
||||
description: Optional comma-separated tags override.
|
||||
required: false
|
||||
type: string
|
||||
default: latest
|
||||
clawhub_version:
|
||||
description: CLI version to run.
|
||||
required: false
|
||||
type: string
|
||||
default: latest
|
||||
secrets:
|
||||
clawhub_token:
|
||||
required: false
|
||||
outputs:
|
||||
publish_json:
|
||||
description: Structured JSON output from clawhub package publish.
|
||||
value: ${{ jobs.publish.outputs.publish_json }}
|
||||
release_id:
|
||||
description: Published release id when dry_run is false.
|
||||
value: ${{ jobs.publish.outputs.release_id }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
publish_json: ${{ steps.capture.outputs.publish_json }}
|
||||
release_id: ${{ steps.capture.outputs.release_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: oven-sh/setup-bun@e3914758a49697077f7bcd190d36582a61667aad
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Validate publish mode inputs
|
||||
env:
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
||||
run: |
|
||||
if [[ "$DRY_RUN" != "true" && -z "$CLAWHUB_TOKEN" ]]; then
|
||||
echo "::error::secrets.clawhub_token is required when dry_run is false."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Write ClawHub config
|
||||
if: secrets.clawhub_token != ''
|
||||
env:
|
||||
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
||||
CLAWHUB_REGISTRY: ${{ inputs.registry }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(os.environ["RUNNER_TEMP"]) / "clawhub-config.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"registry": os.environ["CLAWHUB_REGISTRY"],
|
||||
"token": os.environ["CLAWHUB_TOKEN"],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(path)
|
||||
PY
|
||||
echo "CLAWHUB_CONFIG_PATH=$RUNNER_TEMP/clawhub-config.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Resolve publish command
|
||||
env:
|
||||
INPUT_SOURCE: ${{ inputs.source }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
INPUT_JSON: ${{ inputs.json }}
|
||||
INPUT_OWNER: ${{ inputs.owner }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_TAGS: ${{ inputs.tags }}
|
||||
INPUT_SITE: ${{ inputs.site }}
|
||||
INPUT_REGISTRY: ${{ inputs.registry }}
|
||||
INPUT_CLAWHUB_VERSION: ${{ inputs.clawhub_version }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
source = os.environ["INPUT_SOURCE"].strip()
|
||||
if not source:
|
||||
source = os.environ["GITHUB_REPOSITORY"]
|
||||
ref = os.environ["INPUT_REF"].strip()
|
||||
if ref and "@" not in source and not source.startswith("http"):
|
||||
source = f"{source}@{ref}"
|
||||
|
||||
cmd = [
|
||||
"bunx",
|
||||
f"clawhub@{os.environ['INPUT_CLAWHUB_VERSION'].strip() or 'latest'}",
|
||||
"package",
|
||||
"publish",
|
||||
source,
|
||||
"--site",
|
||||
os.environ["INPUT_SITE"],
|
||||
"--registry",
|
||||
os.environ["INPUT_REGISTRY"],
|
||||
]
|
||||
|
||||
if os.environ["INPUT_DRY_RUN"] == "true":
|
||||
cmd.append("--dry-run")
|
||||
if os.environ["INPUT_JSON"] == "true":
|
||||
cmd.append("--json")
|
||||
|
||||
owner = os.environ["INPUT_OWNER"].strip()
|
||||
version = os.environ["INPUT_VERSION"].strip()
|
||||
tags = os.environ["INPUT_TAGS"].strip()
|
||||
if owner:
|
||||
cmd += ["--owner", owner]
|
||||
if version:
|
||||
cmd += ["--version", version]
|
||||
if tags:
|
||||
cmd += ["--tags", tags]
|
||||
|
||||
path = Path(os.environ["RUNNER_TEMP"]) / "clawhub-package-publish-command.sh"
|
||||
shell_line = " ".join(shlex.quote(part) for part in cmd)
|
||||
path.write_text("#!/usr/bin/env bash\nset -euo pipefail\n" + shell_line + "\n", encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
print(shell_line)
|
||||
PY
|
||||
|
||||
- name: Run package publish
|
||||
run: |
|
||||
"$RUNNER_TEMP/clawhub-package-publish-command.sh" | tee "$RUNNER_TEMP/package-publish.json"
|
||||
|
||||
- name: Capture workflow outputs
|
||||
id: capture
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
output_path = Path(os.environ["RUNNER_TEMP"]) / "package-publish.json"
|
||||
raw = output_path.read_text(encoding="utf-8").strip()
|
||||
parsed = json.loads(raw)
|
||||
|
||||
github_output = Path(os.environ["GITHUB_OUTPUT"])
|
||||
with github_output.open("a", encoding="utf-8") as fh:
|
||||
fh.write("publish_json<<__CLAWHUB_JSON__\n")
|
||||
fh.write(json.dumps(parsed, indent=2))
|
||||
fh.write("\n__CLAWHUB_JSON__\n")
|
||||
release_id = str(parsed.get("releaseId", "") or "")
|
||||
fh.write(f"release_id={release_id}\n")
|
||||
PY
|
||||
|
||||
- name: Upload publish JSON artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: clawhub-package-publish-json
|
||||
path: ${{ runner.temp }}/package-publish.json
|
||||
if-no-files-found: error
|
||||
@@ -117,6 +117,17 @@ To test the CLI against your local instance:
|
||||
CLAWHUB_REGISTRY=http://127.0.0.1:3210 CLAWHUB_SITE=http://localhost:3000 clawhub search "padel"
|
||||
```
|
||||
|
||||
Use the package-local verification contract when working on the CLI:
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/clawdhub test
|
||||
bun run --cwd packages/clawdhub verify:build
|
||||
bun run --cwd packages/clawdhub test:artifact
|
||||
bun run --cwd packages/clawdhub verify
|
||||
```
|
||||
|
||||
`bun test packages/clawdhub/` is not the supported workflow. Source tests and built-artifact smoke tests are intentionally split.
|
||||
|
||||
Manual smoke tests are documented in [`docs/manual-testing.md`](docs/manual-testing.md).
|
||||
|
||||
## Skill & Soul Publishing
|
||||
@@ -137,6 +148,7 @@ clawhub publish <path-to-skill-directory>
|
||||
bun run lint # oxlint
|
||||
bun run test # Vitest (80% coverage threshold)
|
||||
bun run build # Vite + Nitro
|
||||
bun run --cwd packages/clawdhub verify
|
||||
```
|
||||
|
||||
These are the same checks that run in CI (`.github/workflows/ci.yml`).
|
||||
|
||||
@@ -61,8 +61,8 @@ Common CLI flows:
|
||||
- Browse unified catalog (skills + plugins): `clawhub package explore`, `clawhub package inspect <name>`
|
||||
- Manage local installs: `clawhub install <slug>`, `clawhub uninstall <slug>`, `clawhub list`, `clawhub update --all`
|
||||
- Inspect without installing: `clawhub inspect <slug>`
|
||||
- Publish/sync: `clawhub publish <path>`, `clawhub sync`
|
||||
- Publish plugins: `clawhub package publish <path> [--owner <handle>] --source-repo <owner/repo> --source-commit <sha>`
|
||||
- Publish/sync skills: `clawhub skill publish <path>`, `clawhub sync`
|
||||
- Publish plugins: `clawhub package publish <source>`
|
||||
- Canonicalize owned skills: `clawhub skill rename <slug> <new-slug>`, `clawhub skill merge <source> <target>`
|
||||
|
||||
Docs: [`docs/quickstart.md`](docs/quickstart.md), [`docs/cli.md`](docs/cli.md).
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const packageRootPath = fileURLToPath(new URL('./packages/clawdhub/', import.meta.url))
|
||||
const distCliUrl = new URL('./packages/clawdhub/dist/cli.js', import.meta.url)
|
||||
const distCliPath = fileURLToPath(distCliUrl)
|
||||
const srcRootPath = fileURLToPath(new URL('./packages/clawdhub/src/', import.meta.url))
|
||||
@@ -19,7 +20,8 @@ const shouldBuild = await (async () => {
|
||||
})()
|
||||
|
||||
if (shouldBuild) {
|
||||
const proc = Bun.spawn(['bunx', 'tsc', '-p', 'packages/clawdhub/tsconfig.json'], {
|
||||
const proc = Bun.spawn(['bun', 'run', 'build'], {
|
||||
cwd: packageRootPath,
|
||||
stdin: 'inherit',
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
@@ -34,6 +36,7 @@ async function getLatestMtime(root: string) {
|
||||
let latest = 0
|
||||
const glob = new Bun.Glob('**/*.ts')
|
||||
for await (const rel of glob.scan({ cwd: root, onlyFiles: true })) {
|
||||
if (rel.endsWith('.test.ts')) continue
|
||||
const path = `${root}${root.endsWith('/') ? '' : '/'}${rel}`
|
||||
try {
|
||||
const entry = await stat(path)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const packageRootPath = fileURLToPath(new URL('./packages/clawdhub/', import.meta.url))
|
||||
const distCliUrl = new URL('./packages/clawdhub/dist/cli.js', import.meta.url)
|
||||
const distCliPath = fileURLToPath(distCliUrl)
|
||||
const srcRootPath = fileURLToPath(new URL('./packages/clawdhub/src/', import.meta.url))
|
||||
@@ -19,7 +20,8 @@ const shouldBuild = await (async () => {
|
||||
})()
|
||||
|
||||
if (shouldBuild) {
|
||||
const proc = Bun.spawn(['bunx', 'tsc', '-p', 'packages/clawdhub/tsconfig.json'], {
|
||||
const proc = Bun.spawn(['bun', 'run', 'build'], {
|
||||
cwd: packageRootPath,
|
||||
stdin: 'inherit',
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
@@ -34,6 +36,7 @@ async function getLatestMtime(root: string) {
|
||||
let latest = 0
|
||||
const glob = new Bun.Glob('**/*.ts')
|
||||
for await (const rel of glob.scan({ cwd: root, onlyFiles: true })) {
|
||||
if (rel.endsWith('.test.ts')) continue
|
||||
const path = `${root}${root.endsWith('/') ? '' : '/'}${rel}`
|
||||
try {
|
||||
const entry = await stat(path)
|
||||
|
||||
@@ -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
@@ -132,12 +132,13 @@ Stores your API token + cached registry URL.
|
||||
- refuses by default
|
||||
- overwrites with `--force` (or prompt, if interactive)
|
||||
|
||||
### `publish <path>`
|
||||
### `skill publish <path>`
|
||||
|
||||
- Publishes via `POST /api/v1/skills` (multipart).
|
||||
- Requires semver: `--version 1.2.3`.
|
||||
- Publishing a skill means it is released under `MIT-0` on ClawHub.
|
||||
- Published skills are free to use, modify, and redistribute without attribution.
|
||||
- Legacy alias: `publish <path>`.
|
||||
|
||||
### `delete <slug>`
|
||||
|
||||
@@ -208,11 +209,62 @@ Stores your API token + cached registry URL.
|
||||
- `--fuzzy` resolves the handle via fuzzy user search (admin only).
|
||||
- `--yes` skips confirmation.
|
||||
|
||||
### `package publish <path>`
|
||||
### `package publish <source>`
|
||||
|
||||
- Publishes a code plugin or bundle plugin via `POST /api/v1/packages`.
|
||||
- `<source>` accepts:
|
||||
- Local folder path: `./my-plugin`
|
||||
- GitHub repo: `owner/repo` or `owner/repo@ref`
|
||||
- GitHub URL: `https://github.com/owner/repo`
|
||||
- Metadata is auto-detected from `package.json`, `openclaw.plugin.json`, and `openclaw.bundle.json`.
|
||||
- For GitHub sources, source attribution is auto-populated from the repo, resolved commit, ref, and subpath.
|
||||
- For local folders, source attribution is auto-detected from local git when the origin remote points at GitHub.
|
||||
- `--dry-run` previews the resolved publish payload without uploading.
|
||||
- `--json` emits machine-readable output for CI.
|
||||
- `--owner <handle>` lets admins publish under a shared owner account while keeping their own token as the actor.
|
||||
- Code plugins still require `--source-repo` and `--source-commit`.
|
||||
- Existing flags (`--family`, `--name`, `--version`, `--source-repo`, `--source-commit`, `--source-ref`, `--source-path`) still work as overrides.
|
||||
- Private GitHub repos require `GITHUB_TOKEN`.
|
||||
|
||||
#### GitHub Actions
|
||||
|
||||
ClawHub also ships an official reusable workflow at
|
||||
[`/.github/workflows/package-publish.yml`](/Users/tengjizhang/.codex/worktrees/7d03/clawhub/.github/workflows/package-publish.yml)
|
||||
for plugin repos.
|
||||
|
||||
Typical caller setup:
|
||||
|
||||
```yaml
|
||||
name: Package Publish
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
dry-run:
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: openclaw/clawhub/.github/workflows/package-publish.yml@main
|
||||
with:
|
||||
dry_run: true
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/')
|
||||
uses: openclaw/clawhub/.github/workflows/package-publish.yml@main
|
||||
with:
|
||||
dry_run: false
|
||||
secrets:
|
||||
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The reusable workflow defaults `source` to the caller repo.
|
||||
- `pull_request` should use `dry_run: true` so CI stays non-polluting.
|
||||
- Real publishes should be limited to trusted events such as `workflow_dispatch` or tag pushes.
|
||||
- The workflow uploads the JSON result as an artifact and exposes it as workflow outputs.
|
||||
|
||||
### `sync`
|
||||
|
||||
|
||||
@@ -8,6 +8,24 @@ read_when:
|
||||
|
||||
# GitHub import (public repos)
|
||||
|
||||
## CLI
|
||||
|
||||
For plugin authors, the recommended GitHub import path is now the CLI:
|
||||
|
||||
```bash
|
||||
clawhub package publish owner/repo
|
||||
clawhub package publish owner/repo@v1.0.0
|
||||
clawhub package publish https://github.com/owner/repo
|
||||
|
||||
# Preview only
|
||||
clawhub package publish owner/repo --dry-run
|
||||
|
||||
# CI-friendly output
|
||||
clawhub package publish owner/repo --dry-run --json
|
||||
```
|
||||
|
||||
This keeps package metadata zero-config where possible and auto-populates GitHub provenance.
|
||||
|
||||
Goal: paste a GitHub URL → auto-detect skill → preview files → publish (selective) → persist provenance.
|
||||
|
||||
Non-goal (v1): private repos (no OAuth/PAT support).
|
||||
|
||||
@@ -47,9 +47,9 @@ read_when:
|
||||
- `SKILL.md`
|
||||
- `notes.md`
|
||||
- Publish:
|
||||
- `bun clawhub publish . --slug clawhub-manual-<ts> --name "Manual <ts>" --version 1.0.0 --tags latest`
|
||||
- `bun clawhub skill publish . --slug clawhub-manual-<ts> --name "Manual <ts>" --version 1.0.0 --tags latest`
|
||||
- Publish update with empty changelog:
|
||||
- `bun clawhub publish . --slug clawhub-manual-<ts> --name "Manual <ts>" --version 1.0.1 --tags latest`
|
||||
- `bun clawhub skill publish . --slug clawhub-manual-<ts> --name "Manual <ts>" --version 1.0.1 --tags latest`
|
||||
|
||||
## Delete / undelete (owner/admin)
|
||||
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ EOF
|
||||
Publish:
|
||||
|
||||
```bash
|
||||
bun clawhub publish . \
|
||||
bun clawhub skill publish . \
|
||||
--slug clawhub-demo-$(date +%s) \
|
||||
--name "Demo $(date +%s)" \
|
||||
--version 1.0.0 \
|
||||
|
||||
@@ -77,7 +77,7 @@ clawhub sync --root /path/to/skills
|
||||
- Options:
|
||||
- keep local edits; skip updating
|
||||
- overwrite: `clawhub update <slug> --force`
|
||||
- publish as fork: copy to new folder/slug then `clawhub publish ... --fork-of upstream@version`
|
||||
- publish as fork: copy to new folder/slug then `clawhub skill publish ... --fork-of upstream@version`
|
||||
|
||||
## `GET /api/*` works locally but not on Vercel
|
||||
|
||||
|
||||
+110
-8
@@ -47,6 +47,34 @@ function getSite() {
|
||||
);
|
||||
}
|
||||
|
||||
function buildE2ESkillMarkdown(slug: string) {
|
||||
return `# ${slug}
|
||||
|
||||
## What it does
|
||||
|
||||
This skill is used by the ClawHub CLI end-to-end suite to verify publish, install,
|
||||
update, delete, and undelete flows against a real registry.
|
||||
|
||||
## Usage
|
||||
|
||||
- Run the skill after installation to confirm the package can be discovered.
|
||||
- Use the published version history to verify update behavior.
|
||||
- Delete and undelete the listing to confirm ownership actions still work.
|
||||
|
||||
## Notes
|
||||
|
||||
This content is intentionally specific and non-templated so the publish pipeline
|
||||
accepts it during automated tests.
|
||||
`;
|
||||
}
|
||||
|
||||
function allowLiveMutations() {
|
||||
const value = process.env.CLAWHUB_E2E_ALLOW_MUTATIONS?.trim();
|
||||
return value === "1" || value?.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
const itIfLiveMutations = allowLiveMutations() ? it : it.skip;
|
||||
|
||||
async function makeTempConfig(registry: string, token: string | null) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "clawhub-e2e-"));
|
||||
const path = join(dir, "config.json");
|
||||
@@ -268,7 +296,81 @@ describe("clawhub e2e", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes, deletes, and undeletes a skill (logged-in)", async () => {
|
||||
it("package publish --dry-run from a GitHub repo shows a summary", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"package",
|
||||
"publish",
|
||||
"pwrdrvr/openclaw-codex-app-server",
|
||||
"--dry-run",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/Dry run/i);
|
||||
expect(result.stdout).toMatch(/openclaw-codex-app-server/);
|
||||
expect(result.stdout).toMatch(/code-plugin/i);
|
||||
expect(result.stdout).toMatch(/openclaw\.plugin\.json/);
|
||||
}, 30_000);
|
||||
|
||||
it("package publish --dry-run --json from GitHub outputs valid JSON", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawhub",
|
||||
"package",
|
||||
"publish",
|
||||
"pwrdrvr/openclaw-codex-app-server",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--site",
|
||||
site,
|
||||
"--registry",
|
||||
registry,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const output = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
||||
expect(String(output.name)).toMatch(/openclaw-codex-app-server/);
|
||||
expect(output.family).toBe("code-plugin");
|
||||
expect(Number(output.files)).toBeGreaterThan(0);
|
||||
expect(output).not.toHaveProperty("releaseId");
|
||||
}, 30_000);
|
||||
|
||||
it("package publish help shows the new source argument and flags", async () => {
|
||||
const result = spawnSync("bun", ["clawhub", "package", "publish", "--help"], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/<source>/);
|
||||
expect(result.stdout).toMatch(/--dry-run/);
|
||||
expect(result.stdout).toMatch(/--json/);
|
||||
});
|
||||
|
||||
itIfLiveMutations("publishes, deletes, and undeletes a skill (logged-in)", async () => {
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
@@ -284,7 +386,7 @@ describe("clawhub e2e", () => {
|
||||
|
||||
try {
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), `# ${slug}\n\nHello.\n`, "utf8");
|
||||
await writeFile(join(skillDir, "SKILL.md"), buildE2ESkillMarkdown(slug), "utf8");
|
||||
|
||||
const publish1 = spawnSync(
|
||||
"bun",
|
||||
@@ -506,22 +608,22 @@ describe("clawhub e2e", () => {
|
||||
}, 180_000);
|
||||
|
||||
it("delete returns proper error for non-existent skill", async () => {
|
||||
const registry = process.env.CLAWDHUB_REGISTRY?.trim() || "https://clawdhub.com";
|
||||
const site = process.env.CLAWDHUB_SITE?.trim() || "https://clawdhub.com";
|
||||
const registry = getRegistry();
|
||||
const site = getSite();
|
||||
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null;
|
||||
if (!token) {
|
||||
throw new Error("Missing token. Set CLAWDHUB_E2E_TOKEN or run: bun clawdhub auth login");
|
||||
throw new Error("Missing token. Set CLAWHUB_E2E_TOKEN or run: bun clawhub auth login");
|
||||
}
|
||||
|
||||
const cfg = await makeTempConfig(registry, token);
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawdhub-e2e-delete-"));
|
||||
const workdir = await mkdtemp(join(tmpdir(), "clawhub-e2e-delete-"));
|
||||
const nonExistentSlug = `non-existent-skill-${Date.now()}`;
|
||||
|
||||
try {
|
||||
const del = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"clawdhub",
|
||||
"clawhub",
|
||||
"delete",
|
||||
nonExistentSlug,
|
||||
"--yes",
|
||||
@@ -534,7 +636,7 @@ describe("clawhub e2e", () => {
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path, CLAWDHUB_DISABLE_TELEMETRY: "1" },
|
||||
env: { ...process.env, CLAWHUB_CONFIG_PATH: cfg.path, CLAWHUB_DISABLE_TELEMETRY: "1" },
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("prod http smoke", () => {
|
||||
|
||||
expect(html).toContain("<title>ClawHub");
|
||||
expect(html).toContain('href="/skills"');
|
||||
expect(html).toContain('href="/upload"');
|
||||
expect(html).toContain('href="/publish-skill"');
|
||||
expect(html).not.toContain("Something went wrong!");
|
||||
});
|
||||
|
||||
|
||||
@@ -36,13 +36,38 @@ clawhub search "postgres backups"
|
||||
clawhub install my-skill-pack
|
||||
clawhub update --all
|
||||
clawhub update --all --no-input --force
|
||||
clawhub publish ./my-skill-pack --slug my-skill-pack --name "My Skill Pack" --version 1.2.0 --changelog "Fixes + docs"
|
||||
clawhub skill publish ./my-skill-pack --slug my-skill-pack --name "My Skill Pack" --version 1.2.0 --changelog "Fixes + docs"
|
||||
clawhub package explore --family skill
|
||||
clawhub package explore --family code-plugin
|
||||
clawhub package inspect @openclaw/example-plugin
|
||||
clawhub package publish ./example-plugin --owner openclaw --source-repo openclaw/example-plugin --source-commit abc123
|
||||
clawhub package publish openclaw/example-plugin
|
||||
clawhub package publish openclaw/example-plugin@v1.0.0
|
||||
clawhub package publish https://github.com/openclaw/example-plugin --dry-run
|
||||
clawhub package publish ./example-plugin
|
||||
```
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
This repo also provides an official reusable workflow for plugin repos:
|
||||
|
||||
- [`/.github/workflows/package-publish.yml`](/Users/tengjizhang/.codex/worktrees/7d03/clawhub/.github/workflows/package-publish.yml)
|
||||
|
||||
Use `dry_run: true` on pull requests and reserve real publishes for trusted events
|
||||
such as `workflow_dispatch` or tag pushes with a `CLAWHUB_TOKEN` secret.
|
||||
|
||||
## Development
|
||||
|
||||
The supported verification flow for this package is package-local:
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/clawdhub test
|
||||
bun run --cwd packages/clawdhub verify:build
|
||||
bun run --cwd packages/clawdhub test:artifact
|
||||
bun run --cwd packages/clawdhub verify
|
||||
```
|
||||
|
||||
`test` runs source tests only. `test:artifact` builds `dist/` and runs a small smoke suite against the built CLI entrypoint.
|
||||
|
||||
## Sync (upload local skills)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,9 +15,14 @@
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"build": "node ./scripts/build.mjs",
|
||||
"dev": "node --enable-source-maps dist/cli.js",
|
||||
"prepublishOnly": "npm run build"
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "bun run test:src",
|
||||
"test:src": "vitest run -c vitest.config.ts",
|
||||
"verify:build": "tsc -p tsconfig.json --noEmit",
|
||||
"test:artifact": "bun run build && vitest run -c vitest.artifact.config.ts",
|
||||
"verify": "bun run test:src && bun run verify:build && bun run test:artifact"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.1.0",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const distDir = resolve(packageRoot, "dist");
|
||||
|
||||
await rm(distDir, { recursive: true, force: true });
|
||||
|
||||
const tscBin = require.resolve("typescript/bin/tsc");
|
||||
const result = spawnSync(process.execPath, [tscBin, "-p", "tsconfig.json"], {
|
||||
cwd: packageRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -276,7 +276,7 @@ program
|
||||
|
||||
program
|
||||
.command("publish")
|
||||
.description("Publish skill from folder")
|
||||
.description("Legacy alias: publish a skill from folder")
|
||||
.argument("<path>", "Skill folder path")
|
||||
.option("--slug <slug>", "Skill slug")
|
||||
.option("--name <name>", "Display name")
|
||||
@@ -330,6 +330,21 @@ program
|
||||
});
|
||||
|
||||
const skill = program.command("skill").description("Manage published skills");
|
||||
skill
|
||||
.command("publish")
|
||||
.description("Publish a skill from folder")
|
||||
.argument("<path>", "Skill folder path")
|
||||
.option("--slug <slug>", "Skill slug")
|
||||
.option("--name <name>", "Display name")
|
||||
.option("--version <version>", "Version (semver)")
|
||||
.option("--fork-of <slug[@version]>", "Mark as a fork of an existing skill")
|
||||
.option("--changelog <text>", "Changelog text")
|
||||
.option("--tags <tags>", "Comma-separated tags", "latest")
|
||||
.action(async (folder, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPublish(opts, folder, options);
|
||||
});
|
||||
|
||||
const packageCmd = program
|
||||
.command("package")
|
||||
.description("Browse and publish OpenClaw packages");
|
||||
@@ -372,8 +387,8 @@ packageCmd
|
||||
|
||||
packageCmd
|
||||
.command("publish")
|
||||
.description("Publish a code plugin or bundle plugin from folder")
|
||||
.argument("<path>", "Package folder path")
|
||||
.description("Publish a code plugin or bundle plugin from a folder or GitHub source")
|
||||
.argument("<source>", "Package folder path, GitHub repo (owner/repo[@ref]), or URL")
|
||||
.option("--family <family>", "code-plugin|bundle-plugin")
|
||||
.option("--name <name>", "Package name")
|
||||
.option("--display-name <name>", "Display name")
|
||||
@@ -387,9 +402,11 @@ packageCmd
|
||||
.option("--source-commit <sha>", "Git commit SHA")
|
||||
.option("--source-ref <ref>", "Git ref/tag/branch")
|
||||
.option("--source-path <path>", "Repo subpath", ".")
|
||||
.action(async (folder, options) => {
|
||||
.option("--dry-run", "Preview what would be published without uploading")
|
||||
.option("--json", "Output JSON (for CI pipelines)")
|
||||
.action(async (source, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdPublishPackage(opts, folder, options);
|
||||
await cmdPublishPackage(opts, source, options);
|
||||
});
|
||||
|
||||
skill
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import { createRegistryModuleMocks, makeGlobalOpts } from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
const mockReadGlobalConfig = vi.fn(
|
||||
async () => null as { registry?: string; token?: string } | null,
|
||||
@@ -12,25 +12,14 @@ vi.mock("../../config.js", () => ({
|
||||
writeGlobalConfig: (cfg: unknown) => mockWriteGlobalConfig(cfg),
|
||||
}));
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => "https://clawhub.ai");
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}));
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const mockGetRegistry = registryMocks.getRegistry;
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
|
||||
const { cmdLogout } = await import("./auth");
|
||||
|
||||
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLog.mockClear();
|
||||
@@ -40,7 +29,7 @@ describe("cmdLogout", () => {
|
||||
it("removes token and logs a clear message", async () => {
|
||||
mockReadGlobalConfig.mockResolvedValueOnce({ registry: "https://clawhub.ai", token: "tkn" });
|
||||
|
||||
await cmdLogout(makeOpts());
|
||||
await cmdLogout(makeGlobalOpts());
|
||||
|
||||
expect(mockWriteGlobalConfig).toHaveBeenCalledWith({
|
||||
registry: "https://clawhub.ai",
|
||||
@@ -56,7 +45,7 @@ describe("cmdLogout", () => {
|
||||
mockReadGlobalConfig.mockResolvedValueOnce({ token: "tkn" });
|
||||
mockGetRegistry.mockResolvedValueOnce("https://registry.example");
|
||||
|
||||
await cmdLogout(makeOpts());
|
||||
await cmdLogout(makeGlobalOpts());
|
||||
|
||||
expect(mockGetRegistry).toHaveBeenCalled();
|
||||
expect(mockWriteGlobalConfig).toHaveBeenCalledWith({
|
||||
|
||||
@@ -1,62 +1,42 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: vi.fn(async () => "https://clawhub.ai"),
|
||||
}));
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
mockApiRequest(registry, args, schema),
|
||||
}));
|
||||
|
||||
const mockFail = vi.fn((message: string) => {
|
||||
throw new Error(message);
|
||||
});
|
||||
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => ({ succeed: vi.fn(), fail: vi.fn() })),
|
||||
fail: (message: string) => mockFail(message),
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => false,
|
||||
promptConfirm: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdDeleteSkill, cmdHideSkill, cmdUndeleteSkill, cmdUnhideSkill } = await import("./delete");
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("delete/undelete", () => {
|
||||
it("requires --yes when input is disabled", async () => {
|
||||
await expect(cmdDeleteSkill(makeOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdUndeleteSkill(makeOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdHideSkill(makeOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdUnhideSkill(makeOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdDeleteSkill(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdUndeleteSkill(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdHideSkill(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdUnhideSkill(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
});
|
||||
|
||||
it("calls delete endpoint with --yes", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true });
|
||||
await cmdDeleteSkill(makeOpts(), "demo", { yes: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true });
|
||||
await cmdDeleteSkill(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "DELETE", path: "/api/v1/skills/demo" }),
|
||||
expect.anything(),
|
||||
@@ -64,9 +44,9 @@ describe("delete/undelete", () => {
|
||||
});
|
||||
|
||||
it("calls undelete endpoint with --yes", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true });
|
||||
await cmdUndeleteSkill(makeOpts(), "demo", { yes: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true });
|
||||
await cmdUndeleteSkill(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/undelete" }),
|
||||
expect.anything(),
|
||||
@@ -74,15 +54,15 @@ describe("delete/undelete", () => {
|
||||
});
|
||||
|
||||
it("supports hide/unhide aliases", async () => {
|
||||
mockApiRequest.mockResolvedValue({ ok: true });
|
||||
await cmdHideSkill(makeOpts(), "demo", { yes: true }, false);
|
||||
await cmdUnhideSkill(makeOpts(), "demo", { yes: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
httpMocks.apiRequest.mockResolvedValue({ ok: true });
|
||||
await cmdHideSkill(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
await cmdUnhideSkill(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "DELETE", path: "/api/v1/skills/demo" }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/undelete" }),
|
||||
expect.anything(),
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { zipSync } from "fflate";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchGitHubSource, resolveLocalGitInfo, resolveSourceInput } from "./github";
|
||||
|
||||
async function makeTmpDir() {
|
||||
return await mkdtemp(join(tmpdir(), "clawhub-github-test-"));
|
||||
}
|
||||
|
||||
function runGit(cwd: string, args: string[]) {
|
||||
const result = spawnSync("git", ["-C", cwd, ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("github publish source helpers", () => {
|
||||
it.each([
|
||||
["owner/repo", { kind: "github", owner: "owner", repo: "repo", path: ".", url: "https://github.com/owner/repo" }],
|
||||
[
|
||||
"owner/repo@v1.0.0",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
ref: "v1.0.0",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
[
|
||||
"owner/repo@main",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
ref: "main",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
[
|
||||
"https://github.com/owner/repo",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
[
|
||||
"https://github.com/owner/repo/tree/main",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
ref: "main",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
[
|
||||
"https://github.com/owner/repo/tree/main/plugins/demo",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
ref: "main",
|
||||
path: "plugins/demo",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
[
|
||||
"https://github.com/owner/repo/blob/main/plugins/demo/index.ts",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
ref: "main",
|
||||
path: "plugins/demo",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
[
|
||||
"https://github.com/owner/repo.git",
|
||||
{
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
},
|
||||
],
|
||||
])("parses %s as a GitHub source", async (input, expected) => {
|
||||
const workdir = await makeTmpDir();
|
||||
try {
|
||||
await expect(resolveSourceInput(input, { workdir })).resolves.toEqual(expected);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["./local-folder", "/absolute/path", "~/path", ".", "@scope/package", "owner/repo/extra"])(
|
||||
"treats %s as a local path",
|
||||
async (input) => {
|
||||
const workdir = await makeTmpDir();
|
||||
try {
|
||||
const resolved = await resolveSourceInput(input, { workdir });
|
||||
expect(resolved.kind).toBe("local");
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("prefers an existing local directory over GitHub shorthand", async () => {
|
||||
const workdir = await makeTmpDir();
|
||||
try {
|
||||
const localDir = join(workdir, "owner", "repo");
|
||||
await mkdir(localDir, { recursive: true });
|
||||
|
||||
await expect(resolveSourceInput("owner/repo", { workdir })).resolves.toEqual({
|
||||
kind: "local",
|
||||
path: localDir,
|
||||
});
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves git metadata for a nested folder in a real git repo", async () => {
|
||||
const root = await makeTmpDir();
|
||||
try {
|
||||
const nested = join(root, "plugins", "demo");
|
||||
await mkdir(nested, { recursive: true });
|
||||
await writeFile(join(nested, "package.json"), '{"name":"demo"}\n', "utf8");
|
||||
|
||||
runGit(root, ["init", "-b", "main"]);
|
||||
runGit(root, ["remote", "add", "origin", "git@github.com:openclaw/demo-repo.git"]);
|
||||
runGit(root, ["add", "."]);
|
||||
runGit(root, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]);
|
||||
const commit = runGit(root, ["rev-parse", "HEAD"]);
|
||||
const gitRoot = runGit(root, ["rev-parse", "--show-toplevel"]);
|
||||
runGit(root, ["-c", "tag.gpgSign=false", "tag", "v1.0.0"]);
|
||||
|
||||
expect(resolveLocalGitInfo(nested)).toEqual({
|
||||
root: gitRoot,
|
||||
path: "plugins/demo",
|
||||
repo: "openclaw/demo-repo",
|
||||
commit,
|
||||
ref: "v1.0.0",
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for a non-git folder", async () => {
|
||||
const workdir = await makeTmpDir();
|
||||
try {
|
||||
const folder = join(workdir, "not-a-repo");
|
||||
await mkdir(folder, { recursive: true });
|
||||
expect(resolveLocalGitInfo(folder)).toBeNull();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("extracts GitHub archives that contain explicit directory entries", async () => {
|
||||
const archiveBytes = zipSync({
|
||||
"repo-root/.agents/": new Uint8Array(),
|
||||
"repo-root/.agents/config.json": new TextEncoder().encode('{"ok":true}\n'),
|
||||
"repo-root/package.json": new TextEncoder().encode('{"name":"demo","version":"1.0.0"}\n'),
|
||||
"repo-root/openclaw.plugin.json": new TextEncoder().encode('{"id":"demo","configSchema":{"type":"object"}}\n'),
|
||||
});
|
||||
const archiveBody = archiveBytes.buffer.slice(
|
||||
archiveBytes.byteOffset,
|
||||
archiveBytes.byteOffset + archiveBytes.byteLength,
|
||||
) as ArrayBuffer;
|
||||
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ default_branch: "main" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ sha: "0123456789abcdef0123456789abcdef01234567" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(archiveBody, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/zip" },
|
||||
}),
|
||||
);
|
||||
const originalFetch = globalThis.fetch;
|
||||
Object.defineProperty(globalThis, "fetch", {
|
||||
value: fetchMock,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const fetched = await fetchGitHubSource({
|
||||
kind: "github",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
path: ".",
|
||||
url: "https://github.com/owner/repo",
|
||||
});
|
||||
|
||||
try {
|
||||
expect(await readFile(join(fetched.dir, ".agents", "config.json"), "utf8")).toContain(
|
||||
'"ok":true',
|
||||
);
|
||||
expect(await readFile(join(fetched.dir, "package.json"), "utf8")).toContain('"name":"demo"');
|
||||
} finally {
|
||||
await fetched.cleanup();
|
||||
Object.defineProperty(globalThis, "fetch", {
|
||||
value: originalFetch,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { unzipSync } from "fflate";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
const GITHUB_API = "https://api.github.com";
|
||||
const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]);
|
||||
const ZIP_USER_AGENT = "clawhub/package-publish";
|
||||
|
||||
export type ResolvedPublishSource =
|
||||
| {
|
||||
kind: "local";
|
||||
path: string;
|
||||
}
|
||||
| {
|
||||
kind: "github";
|
||||
owner: string;
|
||||
repo: string;
|
||||
ref?: string;
|
||||
path: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type LocalGitInfo = {
|
||||
root: string;
|
||||
path: string;
|
||||
repo?: string;
|
||||
commit?: string;
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
export type FetchedGitHubSource = {
|
||||
dir: string;
|
||||
source: {
|
||||
kind: "github";
|
||||
url: string;
|
||||
repo: string;
|
||||
ref: string;
|
||||
commit: string;
|
||||
path: string;
|
||||
importedAt: number;
|
||||
};
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function resolveSourceInput(
|
||||
input: string,
|
||||
options: { workdir: string },
|
||||
): Promise<ResolvedPublishSource> {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) throw new Error("Path required");
|
||||
|
||||
if (trimmed.startsWith("https://")) {
|
||||
return parseGitHubUrl(trimmed);
|
||||
}
|
||||
|
||||
const shorthand = parseGitHubShorthand(trimmed);
|
||||
if (shorthand) {
|
||||
const localPath = resolveLocalPath(options.workdir, trimmed);
|
||||
const localStat = await stat(localPath).catch(() => null);
|
||||
if (localStat?.isDirectory()) {
|
||||
return { kind: "local", path: localPath };
|
||||
}
|
||||
return shorthand;
|
||||
}
|
||||
|
||||
return { kind: "local", path: resolveLocalPath(options.workdir, trimmed) };
|
||||
}
|
||||
|
||||
export async function fetchGitHubSource(source: Extract<ResolvedPublishSource, { kind: "github" }>) {
|
||||
const token = process.env.GITHUB_TOKEN?.trim() || undefined;
|
||||
const repo = `${source.owner}/${source.repo}`;
|
||||
const repoUrl = `https://github.com/${repo}`;
|
||||
const resolvedRef = source.ref?.trim() || (await resolveDefaultBranch(source.owner, source.repo, token));
|
||||
const commit = await resolveCommitSha(source.owner, source.repo, resolvedRef, token);
|
||||
const archiveBytes = await downloadGitHubZip(source.owner, source.repo, commit, token);
|
||||
const entries = stripSingleTopLevelFolder(unzipSync(archiveBytes));
|
||||
const publishPath = normalizeRepoSubpath(source.path);
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "clawhub-github-publish-"));
|
||||
|
||||
try {
|
||||
const subdirEntries = filterEntriesForSubpath(entries, publishPath);
|
||||
if (Object.keys(subdirEntries).length === 0) {
|
||||
throw new Error(`GitHub path "${publishPath}" does not contain any files`);
|
||||
}
|
||||
await writeEntries(tempDir, subdirEntries);
|
||||
} catch (error) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
dir: tempDir,
|
||||
source: {
|
||||
kind: "github" as const,
|
||||
url: repoUrl,
|
||||
repo,
|
||||
ref: resolvedRef,
|
||||
commit,
|
||||
path: publishPath,
|
||||
importedAt: Date.now(),
|
||||
},
|
||||
cleanup: async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
},
|
||||
} satisfies FetchedGitHubSource;
|
||||
}
|
||||
|
||||
export function resolveLocalGitInfo(folder: string): LocalGitInfo | null {
|
||||
const root = runGit(folder, ["rev-parse", "--show-toplevel"]);
|
||||
if (!root) return null;
|
||||
|
||||
const prefix = runGit(folder, ["rev-parse", "--show-prefix"]);
|
||||
const commit = runGit(folder, ["rev-parse", "HEAD"]) || undefined;
|
||||
const ref =
|
||||
runGit(folder, ["describe", "--tags", "--exact-match"]) ||
|
||||
runGit(folder, ["branch", "--show-current"]) ||
|
||||
commit;
|
||||
const repo = normalizeGitHubRepo(runGit(folder, ["remote", "get-url", "origin"]) || "");
|
||||
|
||||
return {
|
||||
root: root,
|
||||
path: normalizePath(prefix || "") || ".",
|
||||
repo: repo || undefined,
|
||||
commit,
|
||||
ref: ref || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeGitHubRepo(value: string) {
|
||||
const trimmed = value
|
||||
.trim()
|
||||
.replace(/^git\+/, "")
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/^git@github\.com:/i, "https://github.com/");
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const shorthand = trimmed.match(/^([a-z0-9_.-]+)\/([a-z0-9_.-]+)$/i);
|
||||
if (shorthand) return `${shorthand[1]}/${shorthand[2]}`;
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (!GITHUB_HOSTS.has(url.hostname)) return undefined;
|
||||
const segments = decodePathSegments(url.pathname);
|
||||
const owner = segments[0] ?? "";
|
||||
const repo = (segments[1] ?? "").replace(/\.git$/i, "");
|
||||
if (!owner || !repo) return undefined;
|
||||
return `${owner}/${repo}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseGitHubShorthand(input: string): Extract<ResolvedPublishSource, { kind: "github" }> | null {
|
||||
const atIndex = input.lastIndexOf("@");
|
||||
const rawRepo = atIndex > 0 ? input.slice(0, atIndex) : input;
|
||||
const rawRef = atIndex > 0 ? input.slice(atIndex + 1).trim() : "";
|
||||
if (
|
||||
!rawRepo ||
|
||||
rawRepo.startsWith(".") ||
|
||||
rawRepo.startsWith("~") ||
|
||||
rawRepo.startsWith("/") ||
|
||||
rawRepo.includes("\\")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const match = rawRepo.match(/^([a-z0-9_.-]+)\/([a-z0-9_.-]+)$/i);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
kind: "github",
|
||||
owner: match[1],
|
||||
repo: match[2],
|
||||
...(rawRef ? { ref: rawRef } : {}),
|
||||
path: ".",
|
||||
url: `https://github.com/${match[1]}/${match[2]}`,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGitHubUrl(input: string): Extract<ResolvedPublishSource, { kind: "github" }> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input);
|
||||
} catch {
|
||||
throw new Error("Invalid GitHub URL");
|
||||
}
|
||||
if (url.protocol !== "https:") throw new Error("Only https:// GitHub URLs are supported");
|
||||
if (!GITHUB_HOSTS.has(url.hostname)) throw new Error("Only github.com URLs are supported");
|
||||
|
||||
const segments = decodePathSegments(url.pathname);
|
||||
const owner = segments[0] ?? "";
|
||||
const repo = (segments[1] ?? "").replace(/\.git$/i, "");
|
||||
if (!owner || !repo) throw new Error("GitHub URL must be /<owner>/<repo>");
|
||||
|
||||
const kind = segments[2] ?? "";
|
||||
if (!kind || (kind !== "tree" && kind !== "blob")) {
|
||||
return {
|
||||
kind: "github",
|
||||
owner,
|
||||
repo,
|
||||
path: ".",
|
||||
url: `https://github.com/${owner}/${repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
const ref = segments[3] ?? "";
|
||||
if (!ref) throw new Error("Missing ref in GitHub URL");
|
||||
const rest = segments.slice(4).join("/");
|
||||
const normalizedPath = normalizeRepoSubpath(rest || ".");
|
||||
if (kind === "blob") {
|
||||
if (!rest) throw new Error("Missing path in GitHub URL");
|
||||
const parent = normalizeRepoSubpath(rest.split("/").slice(0, -1).join("/") || ".");
|
||||
return {
|
||||
kind: "github",
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
path: parent,
|
||||
url: `https://github.com/${owner}/${repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "github",
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
path: normalizedPath,
|
||||
url: `https://github.com/${owner}/${repo}`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRepoSubpath(value: string) {
|
||||
const normalized = normalizePath(value.trim());
|
||||
if (!normalized || normalized === ".") return ".";
|
||||
const segments = normalized.split("/");
|
||||
if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
|
||||
throw new Error("Invalid GitHub path");
|
||||
}
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function resolveLocalPath(workdir: string, input: string) {
|
||||
if (input === "~") return homedir();
|
||||
if (input.startsWith("~/")) return resolve(homedir(), input.slice(2));
|
||||
return resolve(workdir, input);
|
||||
}
|
||||
|
||||
function normalizePath(pathValue: string) {
|
||||
return pathValue
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.join("/")
|
||||
.replace(/^\.\/+/, "");
|
||||
}
|
||||
|
||||
function decodePathSegments(pathname: string) {
|
||||
return pathname
|
||||
.split("/")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
.map((segment) => {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
throw new Error("Invalid GitHub URL");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveDefaultBranch(owner: string, repo: string, token?: string) {
|
||||
const response = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`, {
|
||||
headers: buildGitHubHeaders(token),
|
||||
});
|
||||
if (!response.ok) throw new Error(`GitHub repo not found: ${owner}/${repo}`);
|
||||
const parsed = (await response.json()) as { default_branch?: unknown };
|
||||
const defaultBranch =
|
||||
typeof parsed.default_branch === "string" ? parsed.default_branch.trim() : "";
|
||||
if (!defaultBranch) throw new Error("GitHub repo default branch missing");
|
||||
return defaultBranch;
|
||||
}
|
||||
|
||||
async function resolveCommitSha(owner: string, repo: string, ref: string, token?: string) {
|
||||
const response = await fetch(
|
||||
`${GITHUB_API}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
|
||||
{
|
||||
headers: buildGitHubHeaders(token),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`GitHub ref not found: ${owner}/${repo}@${ref}`);
|
||||
const parsed = (await response.json()) as { sha?: unknown };
|
||||
const sha = typeof parsed.sha === "string" ? parsed.sha.trim().toLowerCase() : "";
|
||||
if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error("GitHub commit sha missing");
|
||||
return sha;
|
||||
}
|
||||
|
||||
async function downloadGitHubZip(owner: string, repo: string, ref: string, token?: string) {
|
||||
const response = await fetch(
|
||||
`${GITHUB_API}/repos/${owner}/${repo}/zipball/${encodeURIComponent(ref)}`,
|
||||
{
|
||||
headers: buildGitHubHeaders(token),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(`GitHub archive download failed: ${owner}/${repo}@${ref}`);
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
function buildGitHubHeaders(token?: string) {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": ZIP_USER_AGENT,
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function stripSingleTopLevelFolder(entries: Record<string, Uint8Array>) {
|
||||
const paths = Object.keys(entries);
|
||||
if (paths.length === 0) return {};
|
||||
const firstRoot = paths[0]?.split("/")[0] ?? "";
|
||||
if (!firstRoot) return entries;
|
||||
const prefix = `${firstRoot}/`;
|
||||
if (!paths.every((path) => path.startsWith(prefix))) return entries;
|
||||
|
||||
const stripped: Record<string, Uint8Array> = {};
|
||||
for (const [path, bytes] of Object.entries(entries)) {
|
||||
const next = path.slice(prefix.length);
|
||||
if (!next) continue;
|
||||
stripped[next] = bytes;
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
function filterEntriesForSubpath(entries: Record<string, Uint8Array>, subpath: string) {
|
||||
if (subpath === ".") return entries;
|
||||
const prefix = `${subpath}/`;
|
||||
const filtered: Record<string, Uint8Array> = {};
|
||||
for (const [path, bytes] of Object.entries(entries)) {
|
||||
if (!path.startsWith(prefix)) continue;
|
||||
const relPath = path.slice(prefix.length);
|
||||
if (!relPath) continue;
|
||||
filtered[relPath] = bytes;
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async function writeEntries(root: string, entries: Record<string, Uint8Array>) {
|
||||
for (const [path, bytes] of Object.entries(entries)) {
|
||||
if (!path || path.endsWith("/")) continue;
|
||||
const absPath = join(root, ...path.split("/"));
|
||||
await mkdir(dirname(absPath), { recursive: true });
|
||||
await writeFile(absPath, Buffer.from(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
function runGit(cwd: string, args: string[]) {
|
||||
const result = spawnSync("git", ["-C", cwd, ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
if (result.status !== 0) return null;
|
||||
const value = result.stdout.trim();
|
||||
return value || null;
|
||||
}
|
||||
@@ -1,63 +1,29 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
import { ApiRoutes } from "../../schema/index.js";
|
||||
import type { GlobalOpts } from "../types";
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
const mockFetchText = vi.fn();
|
||||
const mockRegistryUrl = vi.fn((path: string, registry: string) => {
|
||||
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
||||
const relative = path.startsWith("/") ? path.slice(1) : path;
|
||||
return new URL(relative, base);
|
||||
});
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
|
||||
fetchText: (...args: unknown[]) => mockFetchText(...args),
|
||||
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
|
||||
}));
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => "https://clawhub.ai");
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}));
|
||||
|
||||
const mockGetOptionalAuthToken = vi.fn(async () => undefined as string | undefined);
|
||||
vi.mock("../authToken.js", () => ({
|
||||
getOptionalAuthToken: () => mockGetOptionalAuthToken(),
|
||||
}));
|
||||
|
||||
const mockSpinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
start: vi.fn(),
|
||||
succeed: vi.fn(),
|
||||
isSpinning: false,
|
||||
text: "",
|
||||
};
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
}));
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdInspect } = await import("./inspect");
|
||||
|
||||
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const mockWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLog.mockClear();
|
||||
@@ -66,7 +32,7 @@ afterEach(() => {
|
||||
|
||||
describe("cmdInspect", () => {
|
||||
it("fetches latest version files when --files is set", async () => {
|
||||
mockApiRequest
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
skill: {
|
||||
slug: "demo",
|
||||
@@ -85,10 +51,10 @@ describe("cmdInspect", () => {
|
||||
version: { version: "1.2.3", createdAt: 3, changelog: "init", files: [] },
|
||||
});
|
||||
|
||||
await cmdInspect(makeOpts(), "demo", { files: true });
|
||||
await cmdInspect(makeGlobalOpts(), "demo", { files: true });
|
||||
|
||||
const firstArgs = mockApiRequest.mock.calls[0]?.[1];
|
||||
const secondArgs = mockApiRequest.mock.calls[1]?.[1];
|
||||
const firstArgs = httpMocks.apiRequest.mock.calls[0]?.[1];
|
||||
const secondArgs = httpMocks.apiRequest.mock.calls[1]?.[1];
|
||||
expect(firstArgs?.path).toBe(`${ApiRoutes.skills}/${encodeURIComponent("demo")}`);
|
||||
expect(secondArgs?.path).toBe(
|
||||
`${ApiRoutes.skills}/${encodeURIComponent("demo")}/versions/${encodeURIComponent("1.2.3")}`,
|
||||
@@ -96,7 +62,7 @@ describe("cmdInspect", () => {
|
||||
});
|
||||
|
||||
it("uses tag param when fetching a file", async () => {
|
||||
mockApiRequest
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
skill: {
|
||||
slug: "demo",
|
||||
@@ -114,11 +80,11 @@ describe("cmdInspect", () => {
|
||||
skill: { slug: "demo", displayName: "Demo" },
|
||||
version: { version: "2.0.0", createdAt: 3, changelog: "init", files: [] },
|
||||
});
|
||||
mockFetchText.mockResolvedValue("content");
|
||||
httpMocks.fetchText.mockResolvedValue("content");
|
||||
|
||||
await cmdInspect(makeOpts(), "demo", { file: "SKILL.md", tag: "latest" });
|
||||
await cmdInspect(makeGlobalOpts(), "demo", { file: "SKILL.md", tag: "latest" });
|
||||
|
||||
const fetchArgs = mockFetchText.mock.calls[0]?.[1];
|
||||
const fetchArgs = httpMocks.fetchText.mock.calls[0]?.[1];
|
||||
const url = new URL(String(fetchArgs?.url));
|
||||
expect(url.pathname).toBe("/api/v1/skills/demo/file");
|
||||
expect(url.searchParams.get("path")).toBe("SKILL.md");
|
||||
@@ -127,7 +93,7 @@ describe("cmdInspect", () => {
|
||||
});
|
||||
|
||||
it("prints security summary when version security metadata exists", async () => {
|
||||
mockApiRequest
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
skill: {
|
||||
slug: "demo",
|
||||
@@ -157,7 +123,7 @@ describe("cmdInspect", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await cmdInspect(makeOpts(), "demo", { version: "2.0.0" });
|
||||
await cmdInspect(makeGlobalOpts(), "demo", { version: "2.0.0" });
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("License: MIT-0"));
|
||||
expect(mockLog).toHaveBeenCalledWith("Security: SUSPICIOUS");
|
||||
@@ -168,7 +134,7 @@ describe("cmdInspect", () => {
|
||||
|
||||
it("rejects when both version and tag are provided", async () => {
|
||||
await expect(
|
||||
cmdInspect(makeOpts(), "demo", { version: "1.0.0", tag: "latest" }),
|
||||
cmdInspect(makeGlobalOpts(), "demo", { version: "1.0.0", tag: "latest" }),
|
||||
).rejects.toThrow("Use either --version or --tag");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,63 +1,39 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: vi.fn(async () => "https://clawhub.ai"),
|
||||
}));
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
const mockRegistryUrl = vi.fn((path: string, registry: string) => {
|
||||
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
||||
const relative = path.startsWith("/") ? path.slice(1) : path;
|
||||
return new URL(relative, base);
|
||||
});
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
mockApiRequest(registry, args, schema),
|
||||
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => ({ succeed: vi.fn(), fail: vi.fn() })),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => false,
|
||||
promptConfirm: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdBanUser, cmdSetRole } = await import("./moderation");
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("cmdBanUser", () => {
|
||||
it("requires --yes when input is disabled", async () => {
|
||||
await expect(cmdBanUser(makeOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
await expect(cmdBanUser(makeGlobalOpts(), "demo", {}, false)).rejects.toThrow(/--yes/i);
|
||||
});
|
||||
|
||||
it("posts handle payload", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 1 });
|
||||
await cmdBanUser(makeOpts(), "hightower6eu", { yes: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 1 });
|
||||
await cmdBanUser(makeGlobalOpts(), "hightower6eu", { yes: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -69,14 +45,14 @@ describe("cmdBanUser", () => {
|
||||
});
|
||||
|
||||
it("includes reason when provided", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 });
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 });
|
||||
await cmdBanUser(
|
||||
makeOpts(),
|
||||
makeGlobalOpts(),
|
||||
"hightower6eu",
|
||||
{ yes: true, reason: "malware distribution" },
|
||||
false,
|
||||
);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -88,9 +64,9 @@ describe("cmdBanUser", () => {
|
||||
});
|
||||
|
||||
it("posts user id payload when --id is set", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 });
|
||||
await cmdBanUser(makeOpts(), "user_123", { yes: true, id: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 });
|
||||
await cmdBanUser(makeGlobalOpts(), "user_123", { yes: true, id: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -102,7 +78,7 @@ describe("cmdBanUser", () => {
|
||||
});
|
||||
|
||||
it("resolves user via fuzzy search", async () => {
|
||||
mockApiRequest
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
@@ -116,8 +92,8 @@ describe("cmdBanUser", () => {
|
||||
total: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 });
|
||||
await cmdBanUser(makeOpts(), "moonshine-100rze", { yes: true, fuzzy: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenNthCalledWith(
|
||||
await cmdBanUser(makeGlobalOpts(), "moonshine-100rze", { yes: true, fuzzy: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
@@ -126,7 +102,7 @@ describe("cmdBanUser", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockApiRequest).toHaveBeenNthCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
@@ -139,7 +115,7 @@ describe("cmdBanUser", () => {
|
||||
});
|
||||
|
||||
it("fails fuzzy search with multiple matches when not interactive", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
userId: "users_1",
|
||||
@@ -159,26 +135,28 @@ describe("cmdBanUser", () => {
|
||||
total: 2,
|
||||
});
|
||||
await expect(
|
||||
cmdBanUser(makeOpts(), "moonshine", { yes: true, fuzzy: true }, false),
|
||||
cmdBanUser(makeGlobalOpts(), "moonshine", { yes: true, fuzzy: true }, false),
|
||||
).rejects.toThrow(/multiple users matched/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdSetRole", () => {
|
||||
it("requires --yes when input is disabled", async () => {
|
||||
await expect(cmdSetRole(makeOpts(), "demo", "moderator", {}, false)).rejects.toThrow(/--yes/i);
|
||||
});
|
||||
|
||||
it("rejects invalid roles", async () => {
|
||||
await expect(cmdSetRole(makeOpts(), "demo", "owner", { yes: true }, false)).rejects.toThrow(
|
||||
/role/i,
|
||||
await expect(cmdSetRole(makeGlobalOpts(), "demo", "moderator", {}, false)).rejects.toThrow(
|
||||
/--yes/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid roles", async () => {
|
||||
await expect(
|
||||
cmdSetRole(makeGlobalOpts(), "demo", "owner", { yes: true }, false),
|
||||
).rejects.toThrow(/role/i);
|
||||
});
|
||||
|
||||
it("posts handle payload", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, role: "moderator" });
|
||||
await cmdSetRole(makeOpts(), "hightower6eu", "moderator", { yes: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true, role: "moderator" });
|
||||
await cmdSetRole(makeGlobalOpts(), "hightower6eu", "moderator", { yes: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -190,9 +168,9 @@ describe("cmdSetRole", () => {
|
||||
});
|
||||
|
||||
it("posts user id payload when --id is set", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({ ok: true, role: "admin" });
|
||||
await cmdSetRole(makeOpts(), "user_123", "admin", { yes: true, id: true }, false);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true, role: "admin" });
|
||||
await cmdSetRole(makeGlobalOpts(), "user_123", "admin", { yes: true, id: true }, false);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
|
||||
@@ -1,65 +1,47 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: vi.fn(async () => "https://clawhub.ai"),
|
||||
}));
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
mockApiRequest(registry, args, schema),
|
||||
}));
|
||||
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => ({ succeed: vi.fn(), fail: vi.fn() })),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => false,
|
||||
promptConfirm: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdMergeSkill, cmdRenameSkill } = await import("./ownership");
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ownership commands", () => {
|
||||
it("rename requires --yes when input is disabled", async () => {
|
||||
await expect(cmdRenameSkill(makeOpts(), "demo", "demo-new", {}, false)).rejects.toThrow(
|
||||
/--yes/i,
|
||||
);
|
||||
await expect(
|
||||
cmdRenameSkill(makeGlobalOpts(), "demo", "demo-new", {}, false),
|
||||
).rejects.toThrow(/--yes/i);
|
||||
});
|
||||
|
||||
it("rename calls rename endpoint", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
slug: "demo-new",
|
||||
previousSlug: "demo",
|
||||
});
|
||||
|
||||
await cmdRenameSkill(makeOpts(), "Demo", "Demo-New", { yes: true }, false);
|
||||
await cmdRenameSkill(makeGlobalOpts(), "Demo", "Demo-New", { yes: true }, false);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -67,20 +49,20 @@ describe("ownership commands", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
const requestArgs = mockApiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
expect(requestArgs.body).toContain('"newSlug":"demo-new"');
|
||||
});
|
||||
|
||||
it("merge calls merge endpoint", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
sourceSlug: "demo-old",
|
||||
targetSlug: "demo",
|
||||
});
|
||||
|
||||
await cmdMergeSkill(makeOpts(), "Demo-Old", "Demo", { yes: true }, false);
|
||||
await cmdMergeSkill(makeGlobalOpts(), "Demo-Old", "Demo", { yes: true }, false);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -88,7 +70,7 @@ describe("ownership commands", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
const requestArgs = mockApiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
expect(requestArgs.body).toContain('"targetSlug":"demo"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,80 +3,100 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
const mockApiRequestForm = vi.fn();
|
||||
const mockFetchText = vi.fn();
|
||||
const mockRegistryUrl = vi.fn((path: string, registry: string) => {
|
||||
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
||||
const relative = path.startsWith("/") ? path.slice(1) : path;
|
||||
return new URL(relative, base);
|
||||
});
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
|
||||
apiRequestForm: (...args: unknown[]) => mockApiRequestForm(...args),
|
||||
fetchText: (...args: unknown[]) => mockFetchText(...args),
|
||||
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
|
||||
}));
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
const mockGetRegistry = vi.fn(async (_opts?: unknown, _params?: unknown) => "https://clawhub.ai");
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: (opts: unknown, params?: unknown) => mockGetRegistry(opts, params),
|
||||
}));
|
||||
|
||||
const mockGetOptionalAuthToken = vi.fn(async () => undefined as string | undefined);
|
||||
const mockRequireAuthToken = vi.fn(async () => "tkn");
|
||||
vi.mock("../authToken.js", () => ({
|
||||
getOptionalAuthToken: () => mockGetOptionalAuthToken(),
|
||||
requireAuthToken: () => mockRequireAuthToken(),
|
||||
}));
|
||||
|
||||
const mockSpinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
succeed: vi.fn(),
|
||||
start: vi.fn(),
|
||||
isSpinning: false,
|
||||
text: "",
|
||||
};
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
}));
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdExplorePackages, cmdInspectPackage, cmdPublishPackage } = await import("./packages");
|
||||
|
||||
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const mockWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
function makeOpts(workdir = "/work"): GlobalOpts {
|
||||
return {
|
||||
workdir,
|
||||
dir: join(workdir, "skills"),
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
function makeOpts(workdir = "/work") {
|
||||
return makeGlobalOpts(workdir);
|
||||
}
|
||||
|
||||
async function makeTmpWorkdir() {
|
||||
return await mkdtemp(join(tmpdir(), "clawhub-package-"));
|
||||
}
|
||||
|
||||
function runGit(cwd: string, args: string[]) {
|
||||
const result = spawnSync("git", ["-C", cwd, ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function getPublishForm() {
|
||||
const publishCall = httpMocks.apiRequestForm.mock.calls.find((call) => {
|
||||
const req = call[1] as { path?: string } | undefined;
|
||||
return req?.path === "/api/v1/packages";
|
||||
});
|
||||
if (!publishCall) throw new Error("Missing publish call");
|
||||
const form = (publishCall[1] as { form?: FormData }).form;
|
||||
if (!(form instanceof FormData)) throw new Error("Missing publish form");
|
||||
return form;
|
||||
}
|
||||
|
||||
function getPublishPayload() {
|
||||
const form = getPublishForm();
|
||||
const payloadEntry = form.get("payload");
|
||||
if (typeof payloadEntry !== "string") throw new Error("Missing publish payload");
|
||||
return JSON.parse(payloadEntry) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getUploadedFileNames() {
|
||||
const form = getPublishForm();
|
||||
return (form.getAll("files") as Array<Blob & { name?: string }>)
|
||||
.map((file) => String(file.name ?? ""))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function makeCodePluginPackageJson(overrides: Record<string, unknown>) {
|
||||
return JSON.stringify({
|
||||
openclaw: {
|
||||
extensions: ["./dist/index.js"],
|
||||
compat: {
|
||||
pluginApi: ">=2026.3.24-beta.2",
|
||||
},
|
||||
build: {
|
||||
openclawVersion: "2026.3.24-beta.2",
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLog.mockClear();
|
||||
mockWrite.mockClear();
|
||||
uiMocks.spinner.text = "";
|
||||
});
|
||||
|
||||
describe("package commands", () => {
|
||||
it("searches package catalog via /api/v1/packages/search", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
results: [
|
||||
{
|
||||
score: 10,
|
||||
@@ -98,7 +118,7 @@ describe("package commands", () => {
|
||||
executesCode: true,
|
||||
});
|
||||
|
||||
const request = mockApiRequest.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
const url = new URL(String(request?.url));
|
||||
expect(url.pathname).toBe("/api/v1/packages/search");
|
||||
expect(url.searchParams.get("q")).toBe("demo plugin");
|
||||
@@ -107,14 +127,14 @@ describe("package commands", () => {
|
||||
});
|
||||
|
||||
it("supports skill family package browse requests", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
await cmdExplorePackages(makeOpts(), "", { family: "skill", limit: 7 });
|
||||
|
||||
const request = mockApiRequest.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
const request = httpMocks.apiRequest.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
const url = new URL(String(request?.url));
|
||||
expect(url.pathname).toBe("/api/v1/packages");
|
||||
expect(url.searchParams.get("family")).toBe("skill");
|
||||
@@ -122,7 +142,7 @@ describe("package commands", () => {
|
||||
});
|
||||
|
||||
it("uses tag param when fetching a package file", async () => {
|
||||
mockApiRequest
|
||||
httpMocks.apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
package: {
|
||||
name: "demo",
|
||||
@@ -154,11 +174,11 @@ describe("package commands", () => {
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
mockFetchText.mockResolvedValue("content");
|
||||
httpMocks.fetchText.mockResolvedValue("content");
|
||||
|
||||
await cmdInspectPackage(makeOpts(), "demo", { file: "README.md", tag: "latest" });
|
||||
|
||||
const fetchArgs = mockFetchText.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
const fetchArgs = httpMocks.fetchText.mock.calls[0]?.[1] as { url?: string } | undefined;
|
||||
const url = new URL(String(fetchArgs?.url));
|
||||
expect(url.pathname).toBe("/api/v1/packages/demo/file");
|
||||
expect(url.searchParams.get("path")).toBe("README.md");
|
||||
@@ -166,14 +186,15 @@ describe("package commands", () => {
|
||||
expect(url.searchParams.get("version")).toBeNull();
|
||||
});
|
||||
|
||||
it("publishes a code plugin package with source metadata", async () => {
|
||||
it("publishes a code plugin package with an exact explicit payload", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(123_456_789);
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
JSON.stringify({
|
||||
makeCodePluginPackageJson({
|
||||
name: "@scope/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
@@ -184,7 +205,7 @@ describe("package commands", () => {
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "demo.plugin" }), "utf8");
|
||||
await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8");
|
||||
|
||||
mockApiRequestForm.mockResolvedValueOnce({
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "pkg_1",
|
||||
releaseId: "rel_1",
|
||||
@@ -197,31 +218,91 @@ describe("package commands", () => {
|
||||
sourceRef: "refs/tags/v1.0.0",
|
||||
});
|
||||
|
||||
const publishCall = mockApiRequestForm.mock.calls.find((call) => {
|
||||
const req = call[1] as { path?: string } | undefined;
|
||||
return req?.path === "/api/v1/packages";
|
||||
expect(getPublishPayload()).toEqual({
|
||||
name: "@scope/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
ownerHandle: "openclaw",
|
||||
family: "code-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "",
|
||||
tags: ["latest"],
|
||||
source: {
|
||||
kind: "github",
|
||||
url: "https://github.com/openclaw/demo-plugin",
|
||||
repo: "openclaw/demo-plugin",
|
||||
ref: "refs/tags/v1.0.0",
|
||||
commit: "abc123",
|
||||
path: ".",
|
||||
importedAt: 123_456_789,
|
||||
},
|
||||
});
|
||||
if (!publishCall) throw new Error("Missing publish call");
|
||||
const publishForm = (publishCall[1] as { form?: FormData }).form as FormData;
|
||||
const payloadEntry = publishForm.get("payload");
|
||||
if (typeof payloadEntry !== "string") throw new Error("Missing publish payload");
|
||||
const payload = JSON.parse(payloadEntry);
|
||||
expect(payload.name).toBe("@scope/demo-plugin");
|
||||
expect(payload.ownerHandle).toBe("openclaw");
|
||||
expect(payload.family).toBe("code-plugin");
|
||||
expect(payload.version).toBe("1.0.0");
|
||||
expect(payload.source).toMatchObject({
|
||||
repo: "openclaw/demo-plugin",
|
||||
commit: "abc123",
|
||||
ref: "refs/tags/v1.0.0",
|
||||
});
|
||||
const files = publishForm.getAll("files") as Array<Blob & { name?: string }>;
|
||||
expect(files.map((file) => String(file.name ?? "")).sort()).toEqual([
|
||||
expect(getUploadedFileNames()).toEqual([
|
||||
".gitignore",
|
||||
"dist/index.js",
|
||||
"openclaw.plugin.json",
|
||||
"package.json",
|
||||
]);
|
||||
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
|
||||
"OK. Published @scope/demo-plugin@1.0.0 (rel_1)",
|
||||
);
|
||||
expect(uiMocks.spinner.fail).not.toHaveBeenCalled();
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
expect(mockWrite).not.toHaveBeenCalled();
|
||||
dateSpy.mockRestore();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes a bundle plugin package with manifest-driven family detection", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "demo-bundle");
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "demo-bundle",
|
||||
displayName: "Demo Bundle",
|
||||
version: "0.4.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(folder, "openclaw.bundle.json"),
|
||||
JSON.stringify({ id: "demo.bundle", hostTargets: ["desktop", "mobile"] }),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "dist", "plugin.wasm"), "binary", "utf8");
|
||||
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "pkg_bundle",
|
||||
releaseId: "rel_bundle",
|
||||
});
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-bundle", {
|
||||
bundleFormat: "openclaw-bundle",
|
||||
hostTargets: "desktop,mobile",
|
||||
});
|
||||
|
||||
expect(getPublishPayload()).toEqual({
|
||||
name: "demo-bundle",
|
||||
displayName: "Demo Bundle",
|
||||
family: "bundle-plugin",
|
||||
version: "0.4.0",
|
||||
changelog: "",
|
||||
tags: ["latest"],
|
||||
bundle: {
|
||||
format: "openclaw-bundle",
|
||||
hostTargets: ["desktop", "mobile"],
|
||||
},
|
||||
});
|
||||
expect(getUploadedFileNames()).toEqual([
|
||||
"dist/plugin.wasm",
|
||||
"openclaw.bundle.json",
|
||||
"package.json",
|
||||
]);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -234,7 +315,7 @@ describe("package commands", () => {
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
JSON.stringify({ name: "demo-plugin", version: "1.0.0" }),
|
||||
makeCodePluginPackageJson({ name: "demo-plugin", version: "1.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "demo.plugin" }), "utf8");
|
||||
@@ -242,6 +323,364 @@ describe("package commands", () => {
|
||||
await expect(cmdPublishPackage(makeOpts(workdir), "demo-plugin", {})).rejects.toThrow(
|
||||
"--source-repo and --source-commit required for code plugins",
|
||||
);
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects code-plugin publish when openclaw.plugin.json is missing", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({ name: "demo-plugin", displayName: "Demo", version: "1.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(
|
||||
cmdPublishPackage(makeOpts(workdir), "demo-plugin", {
|
||||
family: "code-plugin",
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
sourceCommit: "abc123",
|
||||
}),
|
||||
).rejects.toThrow("openclaw.plugin.json required");
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects code-plugin publish when required OpenClaw compatibility metadata is missing", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
openclaw: {
|
||||
extensions: ["./index.ts"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(folder, "openclaw.plugin.json"),
|
||||
JSON.stringify({ id: "demo.plugin", configSchema: { type: "object" } }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(
|
||||
cmdPublishPackage(makeOpts(workdir), "demo-plugin", {
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
sourceCommit: "abc123",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"openclaw.compat.pluginApi is required for external code plugins published to ClawHub.",
|
||||
);
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects bundle-plugin publish when host targets cannot be resolved", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "demo-bundle");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
JSON.stringify({ name: "demo-bundle", displayName: "Demo Bundle", version: "0.1.0" }),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(
|
||||
cmdPublishPackage(makeOpts(workdir), "demo-bundle", { family: "bundle-plugin" }),
|
||||
).rejects.toThrow("Bundle plugins need openclaw.bundle.json or --host-targets");
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("respects package ignore rules and built-in ignored directories", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "ignored-plugin");
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await mkdir(join(folder, "node_modules", "pkg"), { recursive: true });
|
||||
await mkdir(join(folder, ".git"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "ignored-plugin",
|
||||
displayName: "Ignored Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "ignored.plugin" }), "utf8");
|
||||
await writeFile(join(folder, ".clawhubignore"), "ignored.txt\n", "utf8");
|
||||
await writeFile(join(folder, "dist", "index.js"), "export {};\n", "utf8");
|
||||
await writeFile(join(folder, "ignored.txt"), "ignore me\n", "utf8");
|
||||
await writeFile(join(folder, "node_modules", "pkg", "index.js"), "module.exports = {};\n", "utf8");
|
||||
await writeFile(join(folder, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8");
|
||||
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "pkg_ignored",
|
||||
releaseId: "rel_ignored",
|
||||
});
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "ignored-plugin", {
|
||||
sourceRepo: "openclaw/ignored-plugin",
|
||||
sourceCommit: "abc123",
|
||||
});
|
||||
|
||||
expect(getUploadedFileNames()).toEqual([
|
||||
".clawhubignore",
|
||||
"dist/index.js",
|
||||
"openclaw.plugin.json",
|
||||
"package.json",
|
||||
]);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reports publish failures through the spinner without writing to stdout", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "broken-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "broken-plugin",
|
||||
displayName: "Broken Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "broken.plugin" }), "utf8");
|
||||
|
||||
httpMocks.apiRequestForm.mockRejectedValueOnce(new Error("Registry rejected upload"));
|
||||
|
||||
await expect(
|
||||
cmdPublishPackage(makeOpts(workdir), "broken-plugin", {
|
||||
sourceRepo: "openclaw/broken-plugin",
|
||||
sourceCommit: "deadbeef",
|
||||
}),
|
||||
).rejects.toThrow("Registry rejected upload");
|
||||
|
||||
expect(uiMocks.spinner.fail).toHaveBeenCalledWith("Registry rejected upload");
|
||||
expect(uiMocks.spinner.succeed).not.toHaveBeenCalled();
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
expect(mockWrite).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-detects local git source metadata and matches the explicit payload", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(987_654_321);
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "@scope/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "demo.plugin" }), "utf8");
|
||||
await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8");
|
||||
|
||||
runGit(folder, ["init", "-b", "main"]);
|
||||
runGit(folder, ["remote", "add", "origin", "git@github.com:openclaw/demo-plugin.git"]);
|
||||
runGit(folder, ["add", "."]);
|
||||
runGit(folder, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]);
|
||||
const commit = runGit(folder, ["rev-parse", "HEAD"]);
|
||||
runGit(folder, ["-c", "tag.gpgSign=false", "tag", "v1.0.0"]);
|
||||
|
||||
httpMocks.apiRequestForm.mockResolvedValue({
|
||||
ok: true,
|
||||
packageId: "pkg_1",
|
||||
releaseId: "rel_1",
|
||||
});
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-plugin", {
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
sourceCommit: commit,
|
||||
sourceRef: "v1.0.0",
|
||||
});
|
||||
const explicitPayload = getPublishPayload();
|
||||
const explicitFiles = getUploadedFileNames();
|
||||
|
||||
httpMocks.apiRequestForm.mockClear();
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-plugin", {});
|
||||
const inferredPayload = getPublishPayload();
|
||||
const inferredFiles = getUploadedFileNames();
|
||||
|
||||
expect(inferredPayload).toEqual(explicitPayload);
|
||||
expect(inferredFiles).toEqual(explicitFiles);
|
||||
dateSpy.mockRestore();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("lets explicit source flags override inferred git metadata", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(222_222_222);
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "demo.plugin" }), "utf8");
|
||||
|
||||
runGit(folder, ["init", "-b", "main"]);
|
||||
runGit(folder, ["remote", "add", "origin", "git@github.com:openclaw/demo-plugin.git"]);
|
||||
runGit(folder, ["add", "."]);
|
||||
runGit(folder, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]);
|
||||
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
packageId: "pkg_1",
|
||||
releaseId: "rel_1",
|
||||
});
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-plugin", {
|
||||
sourceRepo: "openclaw/override-plugin",
|
||||
sourceCommit: "feedface",
|
||||
sourceRef: "refs/heads/release",
|
||||
sourcePath: "custom/path",
|
||||
});
|
||||
|
||||
expect(getPublishPayload()).toEqual({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.0.0",
|
||||
changelog: "",
|
||||
tags: ["latest"],
|
||||
source: {
|
||||
kind: "github",
|
||||
url: "https://github.com/openclaw/override-plugin",
|
||||
repo: "openclaw/override-plugin",
|
||||
ref: "refs/heads/release",
|
||||
commit: "feedface",
|
||||
path: "custom/path",
|
||||
importedAt: 222_222_222,
|
||||
},
|
||||
});
|
||||
dateSpy.mockRestore();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("supports dry-run without auth or publish and prints a summary", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(444_444_444);
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(join(folder, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "demo.plugin" }), "utf8");
|
||||
await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8");
|
||||
|
||||
runGit(folder, ["init", "-b", "main"]);
|
||||
runGit(folder, ["remote", "add", "origin", "git@github.com:openclaw/demo-plugin.git"]);
|
||||
runGit(folder, ["add", "."]);
|
||||
runGit(folder, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]);
|
||||
const commit = runGit(folder, ["rev-parse", "HEAD"]);
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-plugin", { dryRun: true });
|
||||
|
||||
expect(authTokenMocks.requireAuthToken).not.toHaveBeenCalled();
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
expect(mockLog.mock.calls.map((call) => call[0])).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Dry run - nothing will be published.",
|
||||
expect.stringMatching(/Source:\s+github:openclaw\/demo-plugin@main/),
|
||||
expect.stringMatching(/Name:\s+demo-plugin/),
|
||||
expect.stringMatching(new RegExp(`Commit:\\s+${commit}`)),
|
||||
"Files:",
|
||||
]),
|
||||
);
|
||||
expect(mockWrite).not.toHaveBeenCalled();
|
||||
dateSpy.mockRestore();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("supports dry-run json output without auth or publish", async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
makeCodePluginPackageJson({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), JSON.stringify({ id: "demo.plugin" }), "utf8");
|
||||
|
||||
runGit(folder, ["init", "-b", "main"]);
|
||||
runGit(folder, ["remote", "add", "origin", "git@github.com:openclaw/demo-plugin.git"]);
|
||||
runGit(folder, ["add", "."]);
|
||||
runGit(folder, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]);
|
||||
|
||||
await cmdPublishPackage(makeOpts(workdir), "demo-plugin", { dryRun: true, json: true });
|
||||
|
||||
expect(authTokenMocks.requireAuthToken).not.toHaveBeenCalled();
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
expect(mockWrite).toHaveBeenCalledTimes(1);
|
||||
const output = String(mockWrite.mock.calls[0]?.[0] ?? "").trim();
|
||||
expect(JSON.parse(output)).toEqual({
|
||||
source: "github:openclaw/demo-plugin@main",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.0.0",
|
||||
commit: expect.any(String),
|
||||
files: 2,
|
||||
totalBytes: expect.any(Number),
|
||||
});
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import ignore from "ignore";
|
||||
import mime from "mime";
|
||||
import semver from "semver";
|
||||
import { apiRequest, apiRequestForm, fetchText, registryUrl } from "../../http.js";
|
||||
import {
|
||||
fetchGitHubSource,
|
||||
normalizeGitHubRepo,
|
||||
resolveLocalGitInfo,
|
||||
resolveSourceInput,
|
||||
} from "./github.js";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1PackageListResponseSchema,
|
||||
@@ -12,10 +18,12 @@ import {
|
||||
ApiV1PackageSearchResponseSchema,
|
||||
ApiV1PackageVersionListResponseSchema,
|
||||
ApiV1PackageVersionResponseSchema,
|
||||
normalizeOpenClawExternalPluginCompatibility,
|
||||
type PackageCapabilitySummary,
|
||||
type PackageCompatibility,
|
||||
type PackageFamily,
|
||||
type PackageVerificationSummary,
|
||||
validateOpenClawExternalCodePluginPackageJson,
|
||||
} from "../../schema/index.js";
|
||||
import { getOptionalAuthToken, requireAuthToken } from "../authToken.js";
|
||||
import { getRegistry } from "../registry.js";
|
||||
@@ -60,6 +68,8 @@ type PackagePublishOptions = {
|
||||
sourceCommit?: string;
|
||||
sourceRef?: string;
|
||||
sourcePath?: string;
|
||||
dryRun?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type PackageFile = {
|
||||
@@ -68,6 +78,50 @@ type PackageFile = {
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
type InferredPublishSource = {
|
||||
repo?: string;
|
||||
commit?: string;
|
||||
ref?: string;
|
||||
path?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type PackagePublishSource = ReturnType<typeof buildSource>;
|
||||
|
||||
type PackagePublishPayload = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
ownerHandle?: string;
|
||||
family: "code-plugin" | "bundle-plugin";
|
||||
version: string;
|
||||
changelog: string;
|
||||
tags: string[];
|
||||
source?: NonNullable<PackagePublishSource>;
|
||||
bundle?: {
|
||||
format?: string;
|
||||
hostTargets: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type PackagePublishPlan = {
|
||||
folder: string;
|
||||
cleanup?: () => Promise<void>;
|
||||
filesOnDisk: PackageFile[];
|
||||
payload: PackagePublishPayload;
|
||||
compatibility?: PackageCompatibility;
|
||||
sourceLabel: string;
|
||||
output: {
|
||||
source: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
family: "code-plugin" | "bundle-plugin";
|
||||
version: string;
|
||||
commit?: string;
|
||||
files: number;
|
||||
totalBytes: number;
|
||||
};
|
||||
};
|
||||
|
||||
type PrintableFile = {
|
||||
path: string;
|
||||
size: number | null;
|
||||
@@ -280,100 +334,77 @@ export async function cmdInspectPackage(
|
||||
|
||||
export async function cmdPublishPackage(
|
||||
opts: GlobalOpts,
|
||||
folderArg: string,
|
||||
sourceArg: string,
|
||||
options: PackagePublishOptions = {},
|
||||
) {
|
||||
const folder = folderArg ? resolve(opts.workdir, folderArg) : null;
|
||||
if (!folder) fail("Path required");
|
||||
const folderStat = await stat(folder).catch(() => null);
|
||||
if (!folderStat || !folderStat.isDirectory()) fail("Path must be a folder");
|
||||
if (!sourceArg?.trim()) fail("Path required");
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const filesOnDisk = await listPackageFiles(folder);
|
||||
if (filesOnDisk.length === 0) fail("No files found");
|
||||
|
||||
const fileSet = new Set(filesOnDisk.map((file) => file.relPath.toLowerCase()));
|
||||
const packageJson = await readJsonFile(join(folder, "package.json"));
|
||||
const family = detectPackageFamily(fileSet, options.family);
|
||||
const name =
|
||||
options.name?.trim() ||
|
||||
packageJsonString(packageJson, "name") ||
|
||||
basename(folder).trim().toLowerCase();
|
||||
const displayName =
|
||||
options.displayName?.trim() ||
|
||||
packageJsonString(packageJson, "displayName") ||
|
||||
titleCase(basename(folder));
|
||||
const ownerHandle = options.owner?.trim().replace(/^@+/, "");
|
||||
const version = options.version?.trim() || packageJsonString(packageJson, "version");
|
||||
const changelog = options.changelog ?? "";
|
||||
const tags = parseTags(options.tags ?? "latest");
|
||||
const source = buildSource(options);
|
||||
|
||||
if (!name) fail("--name required");
|
||||
if (!displayName) fail("--display-name required");
|
||||
if (!version) fail("--version required");
|
||||
if (family === "code-plugin" && !semver.valid(version)) {
|
||||
fail("--version must be valid semver for code plugins");
|
||||
}
|
||||
if (family === "code-plugin") {
|
||||
if (!fileSet.has("package.json")) fail("package.json required");
|
||||
if (!fileSet.has("openclaw.plugin.json")) fail("openclaw.plugin.json required");
|
||||
if (!source) fail("--source-repo and --source-commit required for code plugins");
|
||||
}
|
||||
if (family === "bundle-plugin") {
|
||||
const hostTargets = parseCsv(options.hostTargets);
|
||||
if (!fileSet.has("openclaw.bundle.json") && hostTargets.length === 0) {
|
||||
fail("Bundle plugins need openclaw.bundle.json or --host-targets");
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = createSpinner(`Preparing ${name}@${version}`);
|
||||
let plan: PackagePublishPlan | undefined;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
"payload",
|
||||
JSON.stringify({
|
||||
name,
|
||||
displayName,
|
||||
...(ownerHandle ? { ownerHandle } : {}),
|
||||
family,
|
||||
version,
|
||||
changelog,
|
||||
tags,
|
||||
...(source ? { source } : {}),
|
||||
...(family === "bundle-plugin"
|
||||
? {
|
||||
bundle: {
|
||||
format: options.bundleFormat?.trim() || undefined,
|
||||
hostTargets: parseCsv(options.hostTargets),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
plan = await preparePackagePublishPlan(opts, sourceArg, options);
|
||||
|
||||
let index = 0;
|
||||
for (const file of filesOnDisk) {
|
||||
index += 1;
|
||||
spinner.text = `Uploading ${file.relPath} (${index}/${filesOnDisk.length})`;
|
||||
const blob = new Blob([Buffer.from(file.bytes)], {
|
||||
type: file.contentType ?? "application/octet-stream",
|
||||
});
|
||||
form.append("files", blob, file.relPath);
|
||||
if (options.dryRun) {
|
||||
if (options.json) {
|
||||
process.stdout.write(`${JSON.stringify(plan.output, null, 2)}\n`);
|
||||
} else {
|
||||
printPackageDryRun({
|
||||
source: plan.sourceLabel,
|
||||
family: plan.payload.family,
|
||||
name: plan.payload.name,
|
||||
displayName: plan.payload.displayName,
|
||||
version: plan.payload.version,
|
||||
commit: plan.payload.source?.commit,
|
||||
compatibility: plan.compatibility,
|
||||
tags: plan.payload.tags,
|
||||
files: plan.filesOnDisk,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
spinner.text = `Publishing ${name}@${version}`;
|
||||
const result = await apiRequestForm(
|
||||
registry,
|
||||
{ method: "POST", path: ApiRoutes.packages, token, form },
|
||||
ApiV1PackagePublishResponseSchema,
|
||||
);
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const spinner = options.json
|
||||
? null
|
||||
: createSpinner(`Preparing ${plan.payload.name}@${plan.payload.version}`);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("payload", JSON.stringify(plan.payload));
|
||||
|
||||
spinner.succeed(`OK. Published ${name}@${version} (${result.releaseId})`);
|
||||
} catch (error) {
|
||||
spinner.fail(formatError(error));
|
||||
throw error;
|
||||
let index = 0;
|
||||
for (const file of plan.filesOnDisk) {
|
||||
index += 1;
|
||||
if (spinner) {
|
||||
spinner.text = `Uploading ${file.relPath} (${index}/${plan.filesOnDisk.length})`;
|
||||
}
|
||||
const blob = new Blob([Buffer.from(file.bytes)], {
|
||||
type: file.contentType ?? "application/octet-stream",
|
||||
});
|
||||
form.append("files", blob, file.relPath);
|
||||
}
|
||||
|
||||
if (spinner) spinner.text = `Publishing ${plan.payload.name}@${plan.payload.version}`;
|
||||
const result = await apiRequestForm(
|
||||
registry,
|
||||
{ method: "POST", path: ApiRoutes.packages, token, form },
|
||||
ApiV1PackagePublishResponseSchema,
|
||||
);
|
||||
|
||||
if (options.json) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ ...plan.output, releaseId: result.releaseId }, null, 2)}\n`,
|
||||
);
|
||||
} else {
|
||||
spinner?.succeed(
|
||||
`OK. Published ${plan.payload.name}@${plan.payload.version} (${result.releaseId})`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await plan?.cleanup?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,7 +507,12 @@ function printVersionSummary(version: NonNullable<PackageVersionResponse["versio
|
||||
|
||||
function printCompatibility(compatibility: PackageCompatibility | null | undefined) {
|
||||
if (!compatibility) return;
|
||||
const entries = [
|
||||
const entries = formatCompatibilityEntries(compatibility);
|
||||
if (entries.length > 0) console.log(`Compatibility: ${entries.join(", ")}`);
|
||||
}
|
||||
|
||||
function formatCompatibilityEntries(compatibility: PackageCompatibility) {
|
||||
return [
|
||||
compatibility.pluginApiRange ? `pluginApi=${compatibility.pluginApiRange}` : null,
|
||||
compatibility.builtWithOpenClawVersion
|
||||
? `builtWith=${compatibility.builtWithOpenClawVersion}`
|
||||
@@ -484,7 +520,6 @@ function printCompatibility(compatibility: PackageCompatibility | null | undefin
|
||||
compatibility.pluginSdkVersion ? `sdk=${compatibility.pluginSdkVersion}` : null,
|
||||
compatibility.minGatewayVersion ? `minGateway=${compatibility.minGatewayVersion}` : null,
|
||||
].filter(Boolean);
|
||||
if (entries.length > 0) console.log(`Compatibility: ${entries.join(", ")}`);
|
||||
}
|
||||
|
||||
function printCapabilities(capabilities: PackageCapabilitySummary | null | undefined) {
|
||||
@@ -619,20 +654,158 @@ function parseCsv(value: string | undefined) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildSource(options: PackagePublishOptions) {
|
||||
const rawRepo = options.sourceRepo?.trim();
|
||||
const rawCommit = options.sourceCommit?.trim();
|
||||
const rawRef = options.sourceRef?.trim();
|
||||
const rawPath = options.sourcePath?.trim();
|
||||
async function preparePackagePublishPlan(
|
||||
opts: GlobalOpts,
|
||||
sourceArg: string,
|
||||
options: PackagePublishOptions,
|
||||
): Promise<PackagePublishPlan> {
|
||||
const resolvedSource = await resolveSourceInput(sourceArg, { workdir: opts.workdir });
|
||||
let folder = resolvedSource.kind === "local" ? resolvedSource.path : "";
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
let inferredSource: InferredPublishSource | undefined;
|
||||
|
||||
if (resolvedSource.kind === "github") {
|
||||
const fetchSpinner = options.json
|
||||
? null
|
||||
: createSpinner(`Fetching ${resolvedSource.owner}/${resolvedSource.repo}`);
|
||||
try {
|
||||
const fetched = await fetchGitHubSource(resolvedSource);
|
||||
folder = fetched.dir;
|
||||
cleanup = fetched.cleanup;
|
||||
inferredSource = fetched.source;
|
||||
fetchSpinner?.stop();
|
||||
} catch (error) {
|
||||
fetchSpinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const folderStat = await stat(folder).catch(() => null);
|
||||
if (!folderStat || !folderStat.isDirectory()) fail("Path must be a folder");
|
||||
|
||||
const localGitInfo = resolveLocalGitInfo(folder);
|
||||
if (localGitInfo) {
|
||||
inferredSource = {
|
||||
repo: localGitInfo.repo,
|
||||
commit: localGitInfo.commit,
|
||||
ref: localGitInfo.ref,
|
||||
path: localGitInfo.path,
|
||||
...(localGitInfo.repo ? { url: `https://github.com/${localGitInfo.repo}` } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const filesOnDisk = await listPackageFiles(folder);
|
||||
if (filesOnDisk.length === 0) fail("No files found");
|
||||
|
||||
const fileSet = new Set(filesOnDisk.map((file) => file.relPath.toLowerCase()));
|
||||
const packageJson = await readJsonFile(join(folder, "package.json"));
|
||||
const pluginManifest = await readJsonFile(join(folder, "openclaw.plugin.json"));
|
||||
const bundleManifest = await readJsonFile(join(folder, "openclaw.bundle.json"));
|
||||
const family = detectPackageFamily(fileSet, options.family);
|
||||
const name =
|
||||
options.name?.trim() ||
|
||||
packageJsonString(packageJson, "name") ||
|
||||
packageJsonString(pluginManifest, "id") ||
|
||||
packageJsonString(bundleManifest, "id") ||
|
||||
basename(folder).trim().toLowerCase();
|
||||
const displayName =
|
||||
options.displayName?.trim() ||
|
||||
packageJsonString(packageJson, "displayName") ||
|
||||
packageJsonString(pluginManifest, "name") ||
|
||||
packageJsonString(bundleManifest, "name") ||
|
||||
titleCase(basename(folder));
|
||||
const ownerHandle = options.owner?.trim().replace(/^@+/, "");
|
||||
const version = options.version?.trim() || packageJsonString(packageJson, "version");
|
||||
const changelog = options.changelog ?? "";
|
||||
const tags = parseTags(options.tags ?? "latest");
|
||||
const source = buildSource(options, inferredSource);
|
||||
|
||||
if (!name) fail("--name required");
|
||||
if (!displayName) fail("--display-name required");
|
||||
if (!version) fail("--version required");
|
||||
if (family === "code-plugin" && !semver.valid(version)) {
|
||||
fail("--version must be valid semver for code plugins");
|
||||
}
|
||||
if (family === "code-plugin") {
|
||||
if (!fileSet.has("package.json")) fail("package.json required");
|
||||
if (!fileSet.has("openclaw.plugin.json")) fail("openclaw.plugin.json required");
|
||||
if (!source) fail("--source-repo and --source-commit required for code plugins");
|
||||
const validation = validateOpenClawExternalCodePluginPackageJson(packageJson);
|
||||
if (validation.issues.length > 0) {
|
||||
fail(validation.issues.map((issue) => issue.message).join(" "));
|
||||
}
|
||||
}
|
||||
if (family === "bundle-plugin") {
|
||||
const hostTargets = parseCsv(options.hostTargets);
|
||||
if (!fileSet.has("openclaw.bundle.json") && hostTargets.length === 0) {
|
||||
fail("Bundle plugins need openclaw.bundle.json or --host-targets");
|
||||
}
|
||||
}
|
||||
|
||||
const payload: PackagePublishPayload = {
|
||||
name,
|
||||
displayName,
|
||||
...(ownerHandle ? { ownerHandle } : {}),
|
||||
family,
|
||||
version,
|
||||
changelog,
|
||||
tags,
|
||||
...(source ? { source } : {}),
|
||||
...(family === "bundle-plugin"
|
||||
? {
|
||||
bundle: {
|
||||
format: options.bundleFormat?.trim() || undefined,
|
||||
hostTargets: parseCsv(options.hostTargets),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const sourceLabel = describePublishSource(resolvedSource, source, folder);
|
||||
|
||||
return {
|
||||
folder,
|
||||
cleanup,
|
||||
filesOnDisk,
|
||||
payload,
|
||||
compatibility:
|
||||
family === "code-plugin"
|
||||
? normalizeOpenClawExternalPluginCompatibility(packageJson)
|
||||
: undefined,
|
||||
sourceLabel,
|
||||
output: {
|
||||
source: sourceLabel,
|
||||
name,
|
||||
displayName,
|
||||
family,
|
||||
version,
|
||||
...(source?.commit ? { commit: source.commit } : {}),
|
||||
files: filesOnDisk.length,
|
||||
totalBytes: filesOnDisk.reduce((sum, file) => sum + file.bytes.byteLength, 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildSource(
|
||||
options: PackagePublishOptions,
|
||||
inferred?: InferredPublishSource,
|
||||
) {
|
||||
const rawRepo = options.sourceRepo?.trim() || inferred?.repo?.trim();
|
||||
const rawCommit = options.sourceCommit?.trim() || inferred?.commit?.trim();
|
||||
const rawRef = options.sourceRef?.trim() || inferred?.ref?.trim();
|
||||
const rawPath = options.sourcePath?.trim() || inferred?.path?.trim();
|
||||
if (!rawRepo && !rawCommit && !rawRef && !rawPath) return undefined;
|
||||
if (!rawRepo || !rawCommit) fail("--source-repo and --source-commit must be set together");
|
||||
const repo = rawRepo
|
||||
.replace(/^https?:\/\/github\.com\//, "")
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/^\/+|\/+$/g, "");
|
||||
const repo = normalizeGitHubRepo(rawRepo);
|
||||
if (!repo) fail("--source-repo must be a GitHub repo or URL");
|
||||
const explicitRepo = options.sourceRepo?.trim();
|
||||
const url = explicitRepo
|
||||
? explicitRepo.startsWith("http")
|
||||
? explicitRepo
|
||||
: `https://github.com/${repo}`
|
||||
: inferred?.url || `https://github.com/${repo}`;
|
||||
return {
|
||||
kind: "github" as const,
|
||||
url: rawRepo.startsWith("http") ? rawRepo : `https://github.com/${repo}`,
|
||||
url,
|
||||
repo,
|
||||
ref: rawRef || rawCommit,
|
||||
commit: rawCommit,
|
||||
@@ -641,6 +814,64 @@ function buildSource(options: PackagePublishOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
function describePublishSource(
|
||||
sourceInput: Awaited<ReturnType<typeof resolveSourceInput>>,
|
||||
source: ReturnType<typeof buildSource>,
|
||||
folder: string,
|
||||
) {
|
||||
if (source) {
|
||||
return `github:${source.repo}@${source.ref}${source.path !== "." ? `:${source.path}` : ""}`;
|
||||
}
|
||||
if (sourceInput.kind === "github") {
|
||||
const repo = `${sourceInput.owner}/${sourceInput.repo}`;
|
||||
return `github:${repo}@${sourceInput.ref ?? "HEAD"}${
|
||||
sourceInput.path !== "." ? `:${sourceInput.path}` : ""
|
||||
}`;
|
||||
}
|
||||
return `local:${folder}`;
|
||||
}
|
||||
|
||||
function printPackageDryRun(params: {
|
||||
source: string;
|
||||
family: PackageFamily;
|
||||
name: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
commit?: string;
|
||||
compatibility?: PackageCompatibility;
|
||||
tags: string[];
|
||||
files: PackageFile[];
|
||||
}) {
|
||||
console.log("Dry run - nothing will be published.");
|
||||
console.log("");
|
||||
console.log(`Source: ${params.source}`);
|
||||
console.log(`Family: ${params.family}`);
|
||||
console.log(`Name: ${params.name}`);
|
||||
console.log(`Display: ${params.displayName}`);
|
||||
console.log(`Version: ${params.version}`);
|
||||
if (params.commit) console.log(`Commit: ${params.commit}`);
|
||||
if (params.compatibility) {
|
||||
console.log(`Compat: ${formatCompatibilityEntries(params.compatibility).join(", ")}`);
|
||||
}
|
||||
console.log(
|
||||
`Files: ${params.files.length} files (${formatByteCount(
|
||||
params.files.reduce((sum, file) => sum + file.bytes.byteLength, 0),
|
||||
)})`,
|
||||
);
|
||||
console.log(`Tags: ${params.tags.join(", ")}`);
|
||||
console.log("");
|
||||
console.log("Files:");
|
||||
for (const file of params.files) {
|
||||
console.log(` ${file.relPath.padEnd(28)} ${formatByteCount(file.bytes.byteLength)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatByteCount(value: number) {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
async function listPackageFiles(root: string) {
|
||||
const files: PackageFile[] = [];
|
||||
const absRoot = resolve(root);
|
||||
|
||||
@@ -4,32 +4,23 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
const mockGetRegistry = vi.fn(async (_opts: unknown, _params?: unknown) => "https://clawhub.ai");
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: (opts: unknown, params?: unknown) => mockGetRegistry(opts, params),
|
||||
}));
|
||||
|
||||
const mockApiRequestForm = vi.fn();
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequestForm: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
mockApiRequestForm(registry, args, schema),
|
||||
}));
|
||||
|
||||
const mockFail = vi.fn((message: string) => {
|
||||
throw new Error(message);
|
||||
});
|
||||
const mockSpinner = { text: "", succeed: vi.fn(), fail: vi.fn() };
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => mockFail(message),
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
}));
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const { cmdPublish } = await import("./publish");
|
||||
|
||||
@@ -38,18 +29,12 @@ async function makeTmpWorkdir() {
|
||||
return root;
|
||||
}
|
||||
|
||||
function makeOpts(workdir: string): GlobalOpts {
|
||||
return {
|
||||
workdir,
|
||||
dir: join(workdir, "skills"),
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
function makeOpts(workdir: string) {
|
||||
return makeGlobalOpts(workdir);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -64,7 +49,7 @@ describe("cmdPublish", () => {
|
||||
await writeFile(join(folder, "SKILL.md"), skillContent, "utf8");
|
||||
await writeFile(join(folder, "notes.md"), notesContent, "utf8");
|
||||
|
||||
mockApiRequestForm.mockResolvedValueOnce({
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
skillId: "skill_1",
|
||||
versionId: "ver_1",
|
||||
@@ -78,7 +63,7 @@ describe("cmdPublish", () => {
|
||||
tags: "latest",
|
||||
});
|
||||
|
||||
const publishCall = mockApiRequestForm.mock.calls.find((call) => {
|
||||
const publishCall = httpMocks.apiRequestForm.mock.calls.find((call) => {
|
||||
const req = call[1] as { path?: string } | undefined;
|
||||
return req?.path === "/api/v1/skills";
|
||||
});
|
||||
@@ -107,7 +92,7 @@ describe("cmdPublish", () => {
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(join(folder, "SKILL.md"), "# Skill\n", "utf8");
|
||||
|
||||
mockApiRequestForm.mockResolvedValueOnce({
|
||||
httpMocks.apiRequestForm.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
skillId: "skill_1",
|
||||
versionId: "ver_2",
|
||||
@@ -119,7 +104,7 @@ describe("cmdPublish", () => {
|
||||
tags: "latest",
|
||||
});
|
||||
|
||||
expect(mockApiRequestForm).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequestForm).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ path: "/api/v1/skills", method: "POST" }),
|
||||
expect.anything(),
|
||||
@@ -128,4 +113,31 @@ describe("cmdPublish", () => {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects plugin folders with guidance to use "clawhub package publish"', async () => {
|
||||
const workdir = await makeTmpWorkdir();
|
||||
try {
|
||||
const folder = join(workdir, "demo-plugin");
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(
|
||||
join(folder, "package.json"),
|
||||
JSON.stringify({ name: "demo-plugin", openclaw: { extensions: ["./index.ts"] } }),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(folder, "openclaw.plugin.json"), '{"id":"demo-plugin"}', "utf8");
|
||||
|
||||
await expect(
|
||||
cmdPublish(makeOpts(workdir), "demo-plugin", {
|
||||
slug: "demo-plugin",
|
||||
name: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
tags: "latest",
|
||||
}),
|
||||
).rejects.toThrow('This looks like a plugin. Use "clawhub package publish <source>" instead.');
|
||||
expect(authTokenMocks.requireAuthToken).not.toHaveBeenCalled();
|
||||
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import semver from "semver";
|
||||
import { apiRequestForm } from "../../http.js";
|
||||
import { ApiRoutes, ApiV1PublishResponseSchema } from "../../schema/index.js";
|
||||
@@ -26,6 +26,9 @@ export async function cmdPublish(
|
||||
if (!folder) fail("Path required");
|
||||
const folderStat = await stat(folder).catch(() => null);
|
||||
if (!folderStat || !folderStat.isDirectory()) fail("Path must be a folder");
|
||||
if (await looksLikePluginFolder(folder)) {
|
||||
fail('This looks like a plugin. Use "clawhub package publish <source>" instead.');
|
||||
}
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
@@ -96,6 +99,27 @@ export async function cmdPublish(
|
||||
}
|
||||
}
|
||||
|
||||
async function looksLikePluginFolder(folder: string) {
|
||||
const checks = [
|
||||
join(folder, "openclaw.plugin.json"),
|
||||
join(folder, "openclaw.bundle.json"),
|
||||
join(folder, "package.json"),
|
||||
];
|
||||
const stats = await Promise.all(checks.map((candidate) => stat(candidate).catch(() => null)));
|
||||
if (stats[0]?.isFile() || stats[1]?.isFile()) {
|
||||
return true;
|
||||
}
|
||||
if (!stats[2]?.isFile()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(checks[2], "utf8")) as { openclaw?: unknown };
|
||||
return Boolean(raw && typeof raw === "object" && raw.openclaw && typeof raw.openclaw === "object");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseForkOf(value: string) {
|
||||
const trimmed = value.trim();
|
||||
const [slugRaw, versionRaw] = trimmed.split("@");
|
||||
|
||||
@@ -1,67 +1,69 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as fsPromises from "node:fs/promises";
|
||||
import * as skillStore from "../../skills.js";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
import { ApiRoutes } from "../../schema/index.js";
|
||||
import type { GlobalOpts } from "../types";
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
const mockDownloadZip = vi.fn();
|
||||
const mockRegistryUrl = vi.fn((path: string, registry: string) => {
|
||||
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
||||
const relative = path.startsWith("/") ? path.slice(1) : path;
|
||||
return new URL(relative, base);
|
||||
const fsMocks = vi.hoisted(() => ({
|
||||
mkdir: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
return {
|
||||
...actual,
|
||||
mkdir: fsMocks.mkdir,
|
||||
rm: fsMocks.rm,
|
||||
stat: fsMocks.stat,
|
||||
};
|
||||
});
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
|
||||
downloadZip: (...args: unknown[]) => mockDownloadZip(...args),
|
||||
registryUrl: (...args: [string, string]) => mockRegistryUrl(...args),
|
||||
}));
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => "https://clawhub.ai");
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}));
|
||||
const mocked = <T,>(value: T) => value as T & Record<string, unknown>;
|
||||
Object.assign(vi as object, { mocked });
|
||||
|
||||
const mockGetOptionalAuthToken = vi.fn(async () => undefined as string | undefined);
|
||||
vi.mock("../authToken.js", () => ({
|
||||
getOptionalAuthToken: () => mockGetOptionalAuthToken(),
|
||||
}));
|
||||
|
||||
const mockSpinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
start: vi.fn(),
|
||||
succeed: vi.fn(),
|
||||
isSpinning: false,
|
||||
text: "",
|
||||
};
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
const mockApiRequest = httpMocks.apiRequest;
|
||||
const mockDownloadZip = httpMocks.downloadZip;
|
||||
const mockGetOptionalAuthToken = authTokenMocks.getOptionalAuthToken;
|
||||
const mockSpinner = uiMocks.spinner;
|
||||
const mockIsInteractive = vi.fn(() => false);
|
||||
const mockPromptConfirm = vi.fn(async () => false);
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
fail: (message: string) => uiMocks.fail(message),
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: mockIsInteractive,
|
||||
promptConfirm: mockPromptConfirm,
|
||||
}));
|
||||
|
||||
vi.mock("../../skills.js", () => ({
|
||||
extractZipToDir: vi.fn(),
|
||||
hashSkillFiles: vi.fn(),
|
||||
listTextFiles: vi.fn(),
|
||||
readLockfile: vi.fn(),
|
||||
readSkillOrigin: vi.fn(),
|
||||
writeLockfile: vi.fn(),
|
||||
writeSkillOrigin: vi.fn(),
|
||||
}));
|
||||
const extractZipToDirMock = vi.spyOn(skillStore, "extractZipToDir");
|
||||
const hashSkillFilesMock = vi.spyOn(skillStore, "hashSkillFiles");
|
||||
const listTextFilesMock = vi.spyOn(skillStore, "listTextFiles");
|
||||
const readLockfileMock = vi.spyOn(skillStore, "readLockfile");
|
||||
const readSkillOriginMock = vi.spyOn(skillStore, "readSkillOrigin");
|
||||
const writeLockfileMock = vi.spyOn(skillStore, "writeLockfile");
|
||||
const writeSkillOriginMock = vi.spyOn(skillStore, "writeSkillOrigin");
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
mkdir: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
const mkdirMock = fsMocks.mkdir;
|
||||
const rmMock = fsMocks.rm;
|
||||
const statMock = fsMocks.stat;
|
||||
const commandSkillsModuleSpecifier = "./skills.js?command-skills-test" as string;
|
||||
|
||||
const {
|
||||
clampLimit,
|
||||
@@ -71,7 +73,7 @@ const {
|
||||
cmdUninstall,
|
||||
cmdUpdate,
|
||||
formatExploreLine,
|
||||
} = await import("./skills");
|
||||
} = (await import(commandSkillsModuleSpecifier)) as typeof import("./skills");
|
||||
const {
|
||||
extractZipToDir,
|
||||
hashSkillFiles,
|
||||
@@ -80,25 +82,42 @@ const {
|
||||
readSkillOrigin,
|
||||
writeLockfile,
|
||||
writeSkillOrigin,
|
||||
} = await import("../../skills.js");
|
||||
const { rm, stat } = await import("node:fs/promises");
|
||||
} = skillStore;
|
||||
const { rm, stat } = fsPromises;
|
||||
|
||||
const mockLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
function makeOpts() {
|
||||
return makeGlobalOpts();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirMock.mockResolvedValue(undefined);
|
||||
rmMock.mockResolvedValue(undefined);
|
||||
statMock.mockRejectedValue(new Error("missing"));
|
||||
extractZipToDirMock.mockResolvedValue(undefined);
|
||||
hashSkillFilesMock.mockReturnValue({ fingerprint: "hash", files: [] });
|
||||
listTextFilesMock.mockResolvedValue([]);
|
||||
readLockfileMock.mockResolvedValue({ version: 1, skills: {} });
|
||||
readSkillOriginMock.mockResolvedValue(null);
|
||||
writeLockfileMock.mockResolvedValue(undefined);
|
||||
writeSkillOriginMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
extractZipToDirMock.mockRestore();
|
||||
hashSkillFilesMock.mockRestore();
|
||||
listTextFilesMock.mockRestore();
|
||||
readLockfileMock.mockRestore();
|
||||
readSkillOriginMock.mockRestore();
|
||||
writeLockfileMock.mockRestore();
|
||||
writeSkillOriginMock.mockRestore();
|
||||
});
|
||||
|
||||
describe("explore helpers", () => {
|
||||
it("clamps explore limits and handles non-finite values", () => {
|
||||
expect(clampLimit(-5)).toBe(1);
|
||||
@@ -512,10 +531,10 @@ describe("cmdUninstall", () => {
|
||||
|
||||
await cmdUninstall(makeOpts(), "demo", { yes: true }, false);
|
||||
|
||||
const rmMock = vi.mocked(rm);
|
||||
const writeLockfileMock = vi.mocked(writeLockfile);
|
||||
expect(rmMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
writeLockfileMock.mock.invocationCallOrder[0],
|
||||
const rmCallMock = vi.mocked(rm);
|
||||
const writeLockfileCallMock = vi.mocked(writeLockfile);
|
||||
expect(rmCallMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
writeLockfileCallMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
const mockIntro = vi.fn();
|
||||
const mockOutro = vi.fn();
|
||||
const mockLog = vi.fn();
|
||||
const mockMultiselect = vi.fn(async (_args?: unknown) => [] as string[]);
|
||||
let interactive = false;
|
||||
const mocked = <T,>(value: T) => value as T & { mockImplementation: (...args: unknown[]) => unknown };
|
||||
|
||||
const defaultFindSkillFolders = async (root: string) => {
|
||||
if (!root.endsWith("/scan")) return [];
|
||||
@@ -26,30 +33,25 @@ vi.mock("@clack/prompts", () => ({
|
||||
isCancel: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
|
||||
const mockGetRegistry = vi.fn(async () => "https://clawhub.ai");
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: () => mockGetRegistry(),
|
||||
}));
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
mockApiRequest(registry, args, schema),
|
||||
}));
|
||||
|
||||
const mockFail = vi.fn((message: string) => {
|
||||
throw new Error(message);
|
||||
});
|
||||
const mockSpinner = { succeed: vi.fn(), fail: vi.fn(), stop: vi.fn() };
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
httpMocks.downloadZip.mockImplementation(
|
||||
async (_registry?: unknown, _args?: unknown) => new Uint8Array([1, 2, 3]),
|
||||
);
|
||||
const mockApiRequest = httpMocks.apiRequest;
|
||||
const mockFail = uiMocks.fail;
|
||||
const mockSpinner = uiMocks.spinner;
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => mockSpinner),
|
||||
fail: (message: string) => mockFail(message),
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => interactive,
|
||||
promptConfirm: uiMocks.promptConfirm,
|
||||
}));
|
||||
|
||||
vi.mock("../scanSkills.js", () => ({
|
||||
@@ -68,37 +70,40 @@ vi.mock("../clawdbotConfig.js", () => ({
|
||||
resolveClawdbotSkillRoots: () => mockResolveClawdbotSkillRoots(),
|
||||
}));
|
||||
|
||||
vi.mock("../../skills.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../skills.js")>("../../skills.js");
|
||||
return {
|
||||
...actual,
|
||||
listTextFiles: vi.fn(async (folder: string) => [
|
||||
{ relPath: "SKILL.md", bytes: new TextEncoder().encode(folder) },
|
||||
]),
|
||||
};
|
||||
});
|
||||
const mockListTextFiles = vi.fn(async (folder: string) => [
|
||||
{ relPath: "SKILL.md", bytes: new TextEncoder().encode(folder) },
|
||||
]);
|
||||
const mockHashSkillFiles = vi.fn((files: Array<{ relPath: string; bytes: Uint8Array }>) => ({
|
||||
fingerprint: files.map((file) => `${file.relPath}:${Buffer.from(file.bytes).toString("hex")}`).join("|"),
|
||||
files: [],
|
||||
}));
|
||||
const mockHashSkillZip = vi.fn((_zip?: Uint8Array) => ({
|
||||
fingerprint: "remote-fingerprint",
|
||||
files: [],
|
||||
}));
|
||||
const mockReadSkillOrigin = vi.fn(async (_folder?: string) => null);
|
||||
vi.mock("../../skills.js", () => ({
|
||||
listTextFiles: (folder: string) => mockListTextFiles(folder),
|
||||
hashSkillFiles: (files: Array<{ relPath: string; bytes: Uint8Array }>) => mockHashSkillFiles(files),
|
||||
hashSkillZip: (zip: Uint8Array) => mockHashSkillZip(zip),
|
||||
readSkillOrigin: (folder: string) => mockReadSkillOrigin(folder),
|
||||
}));
|
||||
|
||||
const mockCmdPublish = vi.fn();
|
||||
vi.mock("./publish.js", () => ({
|
||||
cmdPublish: (...args: unknown[]) => mockCmdPublish(...args),
|
||||
cmdPublish: (opts: unknown, folder: unknown, options?: unknown) => mockCmdPublish(opts, folder, options),
|
||||
}));
|
||||
|
||||
const { cmdSync } = await import("./sync");
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
function makeOpts() {
|
||||
return makeGlobalOpts();
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { findSkillFolders } = await import("../scanSkills.js");
|
||||
vi.mocked(findSkillFolders).mockImplementation(defaultFindSkillFolders);
|
||||
mocked(findSkillFolders).mockImplementation(defaultFindSkillFolders);
|
||||
});
|
||||
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => {
|
||||
@@ -205,7 +210,7 @@ describe("cmdSync", () => {
|
||||
it("dedupes duplicate slugs before publishing", async () => {
|
||||
interactive = false;
|
||||
const { findSkillFolders } = await import("../scanSkills.js");
|
||||
vi.mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
if (!root.endsWith("/scan")) return [];
|
||||
return [
|
||||
{ folder: "/scan/dup-skill", slug: "dup-skill", displayName: "Dup Skill" },
|
||||
@@ -237,7 +242,7 @@ describe("cmdSync", () => {
|
||||
labels: { "/auto": "Agent: Work" },
|
||||
});
|
||||
const { findSkillFolders } = await import("../scanSkills.js");
|
||||
vi.mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
mocked(findSkillFolders).mockImplementation(async (root: string) => {
|
||||
if (root === "/auto") {
|
||||
return [{ folder: "/auto/alpha", slug: "alpha", displayName: "Alpha" }];
|
||||
}
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GlobalOpts } from "../types";
|
||||
import {
|
||||
createAuthTokenModuleMocks,
|
||||
createHttpModuleMocks,
|
||||
createRegistryModuleMocks,
|
||||
createUiModuleMocks,
|
||||
makeGlobalOpts,
|
||||
} from "../../../test/cliCommandTestKit.js";
|
||||
|
||||
vi.mock("../authToken.js", () => ({
|
||||
requireAuthToken: vi.fn(async () => "tkn"),
|
||||
}));
|
||||
const authTokenMocks = createAuthTokenModuleMocks();
|
||||
const registryMocks = createRegistryModuleMocks();
|
||||
const httpMocks = createHttpModuleMocks();
|
||||
const uiMocks = createUiModuleMocks();
|
||||
|
||||
vi.mock("../registry.js", () => ({
|
||||
getRegistry: vi.fn(async () => "https://clawhub.ai"),
|
||||
}));
|
||||
|
||||
const mockApiRequest = vi.fn();
|
||||
vi.mock("../../http.js", () => ({
|
||||
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
mockApiRequest(registry, args, schema),
|
||||
}));
|
||||
|
||||
vi.mock("../ui.js", () => ({
|
||||
createSpinner: vi.fn(() => ({ succeed: vi.fn(), fail: vi.fn(), stop: vi.fn() })),
|
||||
fail: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => false,
|
||||
promptConfirm: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("../authToken.js", () => authTokenMocks.moduleFactory());
|
||||
vi.mock("../registry.js", () => registryMocks.moduleFactory());
|
||||
vi.mock("../../http.js", () => httpMocks.moduleFactory());
|
||||
vi.mock("../ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const {
|
||||
cmdTransferAccept,
|
||||
@@ -35,16 +27,6 @@ const {
|
||||
cmdTransferRequest,
|
||||
} = await import("./transfer");
|
||||
|
||||
function makeOpts(): GlobalOpts {
|
||||
return {
|
||||
workdir: "/work",
|
||||
dir: "/work/skills",
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -53,13 +35,15 @@ afterEach(() => {
|
||||
|
||||
describe("transfer commands", () => {
|
||||
it("request requires --yes when input is disabled", async () => {
|
||||
await expect(cmdTransferRequest(makeOpts(), "demo", "@alice", {}, false)).rejects.toThrow(
|
||||
await expect(
|
||||
cmdTransferRequest(makeGlobalOpts(), "demo", "@alice", {}, false),
|
||||
).rejects.toThrow(
|
||||
/--yes/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("request calls transfer endpoint", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
transferId: "skillOwnershipTransfers:1",
|
||||
toUserHandle: "alice",
|
||||
@@ -67,14 +51,14 @@ describe("transfer commands", () => {
|
||||
});
|
||||
|
||||
await cmdTransferRequest(
|
||||
makeOpts(),
|
||||
makeGlobalOpts(),
|
||||
"Demo",
|
||||
"@Alice",
|
||||
{ yes: true, message: "Please take over" },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
@@ -82,16 +66,16 @@ describe("transfer commands", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
const requestArgs = mockApiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
const requestArgs = httpMocks.apiRequest.mock.calls[0]?.[1] as { body?: string };
|
||||
expect(requestArgs.body).toContain('"toUserHandle":"alice"');
|
||||
});
|
||||
|
||||
it("list calls incoming transfers endpoint", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
transfers: [],
|
||||
});
|
||||
await cmdTransferList(makeOpts(), {});
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
await cmdTransferList(makeGlobalOpts(), {});
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
@@ -103,11 +87,11 @@ describe("transfer commands", () => {
|
||||
});
|
||||
|
||||
it("list supports outgoing endpoint", async () => {
|
||||
mockApiRequest.mockResolvedValueOnce({
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
transfers: [],
|
||||
});
|
||||
await cmdTransferList(makeOpts(), { outgoing: true });
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
await cmdTransferList(makeGlobalOpts(), { outgoing: true });
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
@@ -119,26 +103,26 @@ describe("transfer commands", () => {
|
||||
});
|
||||
|
||||
it("accept/reject/cancel call action endpoints", async () => {
|
||||
mockApiRequest.mockResolvedValue({
|
||||
httpMocks.apiRequest.mockResolvedValue({
|
||||
ok: true,
|
||||
skillSlug: "demo",
|
||||
});
|
||||
|
||||
await cmdTransferAccept(makeOpts(), "demo", { yes: true }, false);
|
||||
await cmdTransferReject(makeOpts(), "demo", { yes: true }, false);
|
||||
await cmdTransferCancel(makeOpts(), "demo", { yes: true }, false);
|
||||
await cmdTransferAccept(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
await cmdTransferReject(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
await cmdTransferCancel(makeGlobalOpts(), "demo", { yes: true }, false);
|
||||
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/transfer/accept" }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/transfer/reject" }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST", path: "/api/v1/skills/demo/transfer/cancel" }),
|
||||
expect.anything(),
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEnvStubRegistry } from "../test/runtimeStubs.js";
|
||||
|
||||
const chmodMock = vi.fn();
|
||||
const mkdirMock = vi.fn();
|
||||
const readFileMock = vi.fn();
|
||||
const writeFileMock = vi.fn();
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
chmod: (...args: unknown[]) => chmodMock(...args),
|
||||
mkdir: (...args: unknown[]) => mkdirMock(...args),
|
||||
readFile: (...args: unknown[]) => readFileMock(...args),
|
||||
writeFile: (...args: unknown[]) => writeFileMock(...args),
|
||||
const fsMocks = vi.hoisted(() => ({
|
||||
chmod: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const { writeGlobalConfig } = await import("./config");
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
return {
|
||||
...actual,
|
||||
chmod: fsMocks.chmod,
|
||||
mkdir: fsMocks.mkdir,
|
||||
readFile: fsMocks.readFile,
|
||||
writeFile: fsMocks.writeFile,
|
||||
};
|
||||
});
|
||||
|
||||
const configModuleSpecifier = "./config.js?config-test" as string;
|
||||
|
||||
const { writeGlobalConfig } = (await import(configModuleSpecifier)) as typeof import("./config");
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
const testConfigPath = "/tmp/clawhub-config-test/config.json";
|
||||
const envStubs = createEnvStubRegistry();
|
||||
|
||||
function makeErr(code: string): NodeJS.ErrnoException {
|
||||
const error = new Error(code) as NodeJS.ErrnoException;
|
||||
@@ -26,29 +36,33 @@ function makeErr(code: string): NodeJS.ErrnoException {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("CLAWHUB_CONFIG_PATH", testConfigPath);
|
||||
envStubs.stub("CLAWHUB_CONFIG_PATH", testConfigPath);
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
chmodMock.mockResolvedValue(undefined);
|
||||
mkdirMock.mockResolvedValue(undefined);
|
||||
readFileMock.mockResolvedValue("");
|
||||
writeFileMock.mockResolvedValue(undefined);
|
||||
fsMocks.chmod.mockResolvedValue(undefined);
|
||||
fsMocks.mkdir.mockResolvedValue(undefined);
|
||||
fsMocks.readFile.mockResolvedValue("");
|
||||
fsMocks.writeFile.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
||||
vi.unstubAllEnvs();
|
||||
envStubs.restoreAll();
|
||||
vi.clearAllMocks();
|
||||
fsMocks.chmod.mockReset();
|
||||
fsMocks.mkdir.mockReset();
|
||||
fsMocks.readFile.mockReset();
|
||||
fsMocks.writeFile.mockReset();
|
||||
});
|
||||
|
||||
describe("writeGlobalConfig", () => {
|
||||
it("writes config with restricted modes", async () => {
|
||||
await writeGlobalConfig({ registry: "https://example.com", token: "clh_test" });
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith("/tmp/clawhub-config-test", {
|
||||
expect(fsMocks.mkdir).toHaveBeenCalledWith("/tmp/clawhub-config-test", {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
expect(writeFileMock).toHaveBeenCalledWith(
|
||||
expect(fsMocks.writeFile).toHaveBeenCalledWith(
|
||||
testConfigPath,
|
||||
expect.stringContaining('"token": "clh_test"'),
|
||||
{
|
||||
@@ -56,17 +70,17 @@ describe("writeGlobalConfig", () => {
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
expect(chmodMock).toHaveBeenCalledWith(testConfigPath, 0o600);
|
||||
expect(fsMocks.chmod).toHaveBeenCalledWith(testConfigPath, 0o600);
|
||||
});
|
||||
|
||||
it("ignores non-fatal chmod errors", async () => {
|
||||
chmodMock.mockRejectedValueOnce(makeErr("ENOTSUP"));
|
||||
fsMocks.chmod.mockRejectedValueOnce(makeErr("ENOTSUP"));
|
||||
|
||||
await expect(writeGlobalConfig({ registry: "https://example.com" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rethrows unexpected chmod errors", async () => {
|
||||
chmodMock.mockRejectedValueOnce(new Error("boom"));
|
||||
fsMocks.chmod.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
await expect(writeGlobalConfig({ registry: "https://example.com" })).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createGlobalStubRegistry } from "../test/runtimeStubs.js";
|
||||
import { discoverRegistryFromSite } from "./discovery";
|
||||
|
||||
const globalStubs = createGlobalStubRegistry();
|
||||
|
||||
describe("discovery", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
globalStubs.restoreAll();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns null on non-ok response", async () => {
|
||||
vi.stubGlobal(
|
||||
globalStubs.stub(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("nope", { status: 404 })) as unknown as typeof fetch,
|
||||
);
|
||||
@@ -17,7 +22,7 @@ describe("discovery", () => {
|
||||
});
|
||||
|
||||
it("parses registry config", async () => {
|
||||
vi.stubGlobal(
|
||||
globalStubs.stub(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
@@ -35,7 +40,7 @@ describe("discovery", () => {
|
||||
});
|
||||
|
||||
it("parses apiBase config", async () => {
|
||||
vi.stubGlobal(
|
||||
globalStubs.stub(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
@@ -60,7 +65,7 @@ describe("discovery", () => {
|
||||
});
|
||||
|
||||
it("returns null when apiBase is empty", async () => {
|
||||
vi.stubGlobal(
|
||||
globalStubs.stub(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
|
||||
@@ -1,283 +1,162 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const bunRuntimeMocks = vi.hoisted(() => {
|
||||
const originalBunVersion = (process.versions as Record<string, string | undefined>).bun;
|
||||
Object.defineProperty(process.versions, "bun", {
|
||||
value: "1.2.3",
|
||||
configurable: true,
|
||||
});
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createHttpClient } from "./http.js";
|
||||
|
||||
return {
|
||||
originalBunVersion,
|
||||
spawnSync: vi.fn(),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
mkdtemp: vi.fn(async () => "/tmp/clawhub-test"),
|
||||
rm: vi.fn(async () => undefined),
|
||||
writeFile: vi.fn(async () => undefined),
|
||||
readFile: vi.fn(async () => Buffer.from([1, 2, 3]) as Buffer<ArrayBuffer>),
|
||||
};
|
||||
});
|
||||
type SpawnResult = {
|
||||
status: number | null;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawnSync: bunRuntimeMocks.spawnSync,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
mkdir: bunRuntimeMocks.mkdir,
|
||||
mkdtemp: bunRuntimeMocks.mkdtemp,
|
||||
rm: bunRuntimeMocks.rm,
|
||||
writeFile: bunRuntimeMocks.writeFile,
|
||||
readFile: bunRuntimeMocks.readFile,
|
||||
}));
|
||||
|
||||
import * as http from "./http";
|
||||
|
||||
function restoreBunRuntime() {
|
||||
if (bunRuntimeMocks.originalBunVersion === undefined) {
|
||||
Reflect.deleteProperty(process.versions, "bun");
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(process.versions, "bun", {
|
||||
value: bunRuntimeMocks.originalBunVersion,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function mockImmediateTimeouts() {
|
||||
const setTimeoutMock = vi.fn((callback: () => void) => {
|
||||
callback();
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutMock = vi.fn();
|
||||
vi.stubGlobal("setTimeout", setTimeoutMock as unknown as typeof setTimeout);
|
||||
vi.stubGlobal("clearTimeout", clearTimeoutMock as typeof clearTimeout);
|
||||
return { setTimeoutMock, clearTimeoutMock };
|
||||
}
|
||||
|
||||
type SpawnImpl = (...args: unknown[]) => unknown;
|
||||
|
||||
async function loadHttpModuleWithBunMocks(opts?: {
|
||||
spawnImpl?: SpawnImpl;
|
||||
function createBunClient(options?: {
|
||||
spawnImpl?: (...args: unknown[]) => SpawnResult;
|
||||
mkdtempValue?: string;
|
||||
readFileValue?: Buffer | null;
|
||||
}) {
|
||||
const spawnSync: SpawnImpl = opts?.spawnImpl ?? vi.fn();
|
||||
bunRuntimeMocks.spawnSync.mockImplementation((...args: unknown[]) => spawnSync(...args));
|
||||
bunRuntimeMocks.mkdir.mockImplementation(async () => undefined);
|
||||
bunRuntimeMocks.mkdtemp.mockImplementation(async () => opts?.mkdtempValue ?? "/tmp/clawhub-test");
|
||||
bunRuntimeMocks.rm.mockImplementation(async () => undefined);
|
||||
bunRuntimeMocks.writeFile.mockImplementation(async () => undefined);
|
||||
bunRuntimeMocks.readFile.mockImplementation(
|
||||
async () => (opts?.readFileValue ?? Buffer.from([1, 2, 3])) as Buffer<ArrayBuffer>,
|
||||
const spawnImpl = vi.fn(options?.spawnImpl ?? (() => ({ status: 0, stdout: "", stderr: "" })));
|
||||
const mkdirImpl = vi.fn(async () => undefined);
|
||||
const mkdtempImpl = vi.fn(async () => options?.mkdtempValue ?? "/tmp/clawhub-test");
|
||||
const rmImpl = vi.fn(async () => undefined);
|
||||
const writeFileImpl = vi.fn(async () => undefined);
|
||||
const readFileImpl = vi.fn(
|
||||
async () => (options?.readFileValue ?? Buffer.from([1, 2, 3])) as Buffer<ArrayBuffer>,
|
||||
);
|
||||
const setTimeoutImpl = vi.fn((callback: () => void, _ms?: number) => {
|
||||
callback();
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutImpl = vi.fn();
|
||||
|
||||
return {
|
||||
http,
|
||||
spawnSync: bunRuntimeMocks.spawnSync,
|
||||
mkdir: bunRuntimeMocks.mkdir,
|
||||
mkdtemp: bunRuntimeMocks.mkdtemp,
|
||||
rm: bunRuntimeMocks.rm,
|
||||
writeFile: bunRuntimeMocks.writeFile,
|
||||
readFile: bunRuntimeMocks.readFile,
|
||||
client: createHttpClient({
|
||||
runtime: "bun",
|
||||
configureDispatcher: false,
|
||||
spawnSyncImpl: spawnImpl as unknown as typeof import("node:child_process").spawnSync,
|
||||
mkdirImpl: mkdirImpl as unknown as typeof import("node:fs/promises").mkdir,
|
||||
mkdtempImpl: mkdtempImpl as unknown as typeof import("node:fs/promises").mkdtemp,
|
||||
rmImpl: rmImpl as unknown as typeof import("node:fs/promises").rm,
|
||||
writeFileImpl: writeFileImpl as unknown as typeof import("node:fs/promises").writeFile,
|
||||
readFileImpl: readFileImpl as unknown as typeof import("node:fs/promises").readFile,
|
||||
setTimeoutImpl: setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl,
|
||||
tmpdirPath: "/tmp",
|
||||
random: () => 0,
|
||||
}),
|
||||
spawnImpl,
|
||||
mkdirImpl,
|
||||
mkdtempImpl,
|
||||
rmImpl,
|
||||
writeFileImpl,
|
||||
readFileImpl,
|
||||
setTimeoutImpl,
|
||||
clearTimeoutImpl,
|
||||
};
|
||||
}
|
||||
|
||||
describe("http bun runtime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(process.versions, "bun", {
|
||||
value: "1.2.3",
|
||||
configurable: true,
|
||||
describe("bun http client", () => {
|
||||
it("uses curl for apiRequest GET and POST", async () => {
|
||||
const { client, spawnImpl } = createBunClient({
|
||||
spawnImpl: () => ({ status: 0, stdout: '{"ok":true}\n200', stderr: "" }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreBunRuntime();
|
||||
});
|
||||
|
||||
it("uses curl for apiRequest GET and parses JSON", async () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: '{"ok":true}\n200',
|
||||
stderr: "",
|
||||
});
|
||||
const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync });
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await httpClient.apiRequest<{ ok: boolean }>("https://registry.example", {
|
||||
const getResult = await client.apiRequest<{ ok: boolean }>("https://registry.example", {
|
||||
method: "GET",
|
||||
path: "/v1/ping",
|
||||
token: "clh_token",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(spawnSync).toHaveBeenCalledTimes(1);
|
||||
const [, args] = spawnSync.mock.calls[0] as [string, string[]];
|
||||
expect(args).toContain("GET");
|
||||
expect(args).toContain("https://registry.example/v1/ping");
|
||||
expect(args).toContain("Accept: application/json");
|
||||
expect(args).toContain("Authorization: Bearer clh_token");
|
||||
}, 10_000);
|
||||
|
||||
it("uses curl for apiRequest POST with json body", async () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: '{"ok":true}\n200',
|
||||
stderr: "",
|
||||
});
|
||||
const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync });
|
||||
|
||||
await httpClient.apiRequest("https://registry.example", {
|
||||
await client.apiRequest("https://registry.example", {
|
||||
method: "POST",
|
||||
path: "/v1/ping",
|
||||
body: { a: 1 },
|
||||
});
|
||||
|
||||
const [, args] = spawnSync.mock.calls[0] as [string, string[]];
|
||||
expect(args).toContain("Content-Type: application/json");
|
||||
expect(args).toContain("--data-binary");
|
||||
expect(args).toContain('{"a":1}');
|
||||
expect(getResult).toEqual({ ok: true });
|
||||
const [, getArgs] = spawnImpl.mock.calls[0] as [string, string[]];
|
||||
expect(getArgs).toContain("GET");
|
||||
expect(getArgs).toContain("https://registry.example/v1/ping");
|
||||
expect(getArgs).toContain("Authorization: Bearer clh_token");
|
||||
|
||||
const [, postArgs] = spawnImpl.mock.calls[1] as [string, string[]];
|
||||
expect(postArgs).toContain("Content-Type: application/json");
|
||||
expect(postArgs).toContain("--data-binary");
|
||||
expect(postArgs).toContain('{"a":1}');
|
||||
});
|
||||
|
||||
it("retries bun apiRequest on 429 errors", async () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: "rate limited\n429",
|
||||
stderr: "",
|
||||
it("retries 429 responses and keeps 404 non-retryable", async () => {
|
||||
const rateLimited = createBunClient({
|
||||
spawnImpl: () => ({ status: 0, stdout: "rate limited\n429", stderr: "" }),
|
||||
});
|
||||
const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync });
|
||||
|
||||
await expect(
|
||||
httpClient.apiRequest("https://registry.example", {
|
||||
rateLimited.client.apiRequest("https://registry.example", {
|
||||
method: "GET",
|
||||
path: "/v1/ping",
|
||||
}),
|
||||
).rejects.toThrow("rate limited");
|
||||
expect(rateLimited.spawnImpl).toHaveBeenCalledTimes(3);
|
||||
|
||||
expect(spawnSync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("includes rate-limit guidance from curl metadata on 429", async () => {
|
||||
mockImmediateTimeouts();
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: "rate limited\n__CLAWHUB_CURL_META__\n429\n20\n0\n1771404540\n20\n0\n34\n34\n",
|
||||
stderr: "",
|
||||
const missing = createBunClient({
|
||||
spawnImpl: () => ({ status: 0, stdout: "missing\n404", stderr: "" }),
|
||||
});
|
||||
const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync });
|
||||
|
||||
await expect(
|
||||
httpClient.apiRequest("https://registry.example", {
|
||||
method: "GET",
|
||||
path: "/v1/ping",
|
||||
}),
|
||||
).rejects.toThrow(/retry in 34s.*remaining: 0\/20.*reset in 34s/i);
|
||||
|
||||
expect(spawnSync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not retry bun apiRequest on 404 errors", async () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: "missing\n404",
|
||||
stderr: "",
|
||||
});
|
||||
const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync });
|
||||
|
||||
await expect(
|
||||
httpClient.apiRequest("https://registry.example", {
|
||||
missing.client.apiRequest("https://registry.example", {
|
||||
method: "GET",
|
||||
path: "/v1/ping",
|
||||
}),
|
||||
).rejects.toThrow("missing");
|
||||
|
||||
expect(spawnSync).toHaveBeenCalledTimes(1);
|
||||
expect(missing.spawnImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("supports fetchText bun path and propagates status fallback", async () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
it("includes curl rate-limit metadata in 429 errors", async () => {
|
||||
const { client, spawnImpl } = createBunClient({
|
||||
spawnImpl: () => ({
|
||||
status: 0,
|
||||
stdout: "hello world\n200",
|
||||
stdout: "rate limited\n__CLAWHUB_CURL_META__\n429\n20\n0\n1771404540\n20\n0\n34\n34\n",
|
||||
stderr: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "\n400",
|
||||
stderr: "",
|
||||
});
|
||||
const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync });
|
||||
|
||||
const text = await httpClient.fetchText("https://registry.example", { path: "/v1/readme" });
|
||||
expect(text).toBe("hello world");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
httpClient.fetchText("https://registry.example", { path: "/v1/readme" }),
|
||||
).rejects.toThrow("HTTP 400");
|
||||
client.apiRequest("https://registry.example", {
|
||||
method: "GET",
|
||||
path: "/v1/ping",
|
||||
}),
|
||||
).rejects.toThrow(/retry in 34s.*remaining: 0\/20.*reset in 34s/i);
|
||||
expect(spawnImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("handles downloadZip bun path and cleans up temp dir", async () => {
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "200",
|
||||
stderr: "",
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: "404",
|
||||
stderr: "",
|
||||
});
|
||||
const {
|
||||
http: httpClient,
|
||||
rm,
|
||||
readFile,
|
||||
} = await loadHttpModuleWithBunMocks({
|
||||
spawnImpl: spawnSync,
|
||||
it("supports fetchText and downloadZip via curl", async () => {
|
||||
const { client, spawnImpl, readFileImpl, rmImpl } = createBunClient({
|
||||
spawnImpl: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 0, stdout: "hello world\n200", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "200", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "404", stderr: "" }),
|
||||
mkdtempValue: "/tmp/clawhub-download-abc",
|
||||
readFileValue: Buffer.from("not found"),
|
||||
});
|
||||
|
||||
const bytes = await httpClient.downloadZip("https://registry.example", {
|
||||
slug: "demo",
|
||||
token: "t",
|
||||
});
|
||||
await expect(client.fetchText("https://registry.example", { path: "/v1/readme" })).resolves.toBe(
|
||||
"hello world",
|
||||
);
|
||||
const bytes = await client.downloadZip("https://registry.example", { slug: "demo", token: "t" });
|
||||
expect(Array.from(bytes)).toEqual(Array.from(Buffer.from("not found")));
|
||||
|
||||
await expect(
|
||||
httpClient.downloadZip("https://registry.example", { slug: "demo", token: "t" }),
|
||||
client.downloadZip("https://registry.example", { slug: "demo", token: "t" }),
|
||||
).rejects.toThrow("not found");
|
||||
|
||||
expect(readFile).toHaveBeenCalled();
|
||||
expect(rm).toHaveBeenCalledWith("/tmp/clawhub-download-abc", {
|
||||
expect(readFileImpl).toHaveBeenCalled();
|
||||
expect(rmImpl).toHaveBeenCalledWith("/tmp/clawhub-download-abc", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
expect(spawnImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("posts multipart form via curl in bun path", async () => {
|
||||
const spawnSync = vi.fn().mockReturnValue({
|
||||
status: 0,
|
||||
stdout: '{"ok":true}\n200',
|
||||
stderr: "",
|
||||
});
|
||||
const {
|
||||
http: httpClient,
|
||||
mkdir,
|
||||
writeFile,
|
||||
rm,
|
||||
} = await loadHttpModuleWithBunMocks({
|
||||
spawnImpl: spawnSync,
|
||||
it("posts multipart form data via curl and cleans up temp files", async () => {
|
||||
const { client, spawnImpl, mkdirImpl, writeFileImpl, rmImpl } = createBunClient({
|
||||
spawnImpl: () => ({ status: 0, stdout: '{"ok":true}\n200', stderr: "" }),
|
||||
mkdtempValue: "/tmp/clawhub-upload-abc",
|
||||
});
|
||||
|
||||
@@ -285,19 +164,24 @@ describe("http bun runtime", () => {
|
||||
form.append("name", "demo");
|
||||
form.append("file", new Blob(["abc"], { type: "text/plain" }), "dist/demo.txt");
|
||||
|
||||
const result = await httpClient.apiRequestForm<{ ok: boolean }>("https://registry.example", {
|
||||
const result = await client.apiRequestForm<{ ok: boolean }>("https://registry.example", {
|
||||
method: "POST",
|
||||
path: "/upload",
|
||||
form,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mkdir).toHaveBeenCalledWith("/tmp/clawhub-upload-abc/dist", { recursive: true });
|
||||
expect(writeFile).toHaveBeenCalled();
|
||||
expect(rm).toHaveBeenCalledWith("/tmp/clawhub-upload-abc", { recursive: true, force: true });
|
||||
const [, args] = spawnSync.mock.calls[0] as [string, string[]];
|
||||
expect(mkdirImpl).toHaveBeenCalledWith("/tmp/clawhub-upload-abc/dist", { recursive: true });
|
||||
expect(writeFileImpl).toHaveBeenCalled();
|
||||
expect(rmImpl).toHaveBeenCalledWith("/tmp/clawhub-upload-abc", {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
const [, args] = spawnImpl.mock.calls[0] as [string, string[]];
|
||||
expect(args).toContain("-F");
|
||||
expect(args.some((arg) => arg.includes("name=demo"))).toBe(true);
|
||||
expect(args.some((arg) => arg.includes("file=@/tmp/clawhub-upload-abc/dist/demo.txt"))).toBe(true);
|
||||
expect(args.some((arg) => arg.includes("file=@/tmp/clawhub-upload-abc/dist/demo.txt"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+145
-220
@@ -1,31 +1,39 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
apiRequest,
|
||||
apiRequestForm,
|
||||
downloadZip,
|
||||
fetchText,
|
||||
registryUrl,
|
||||
shouldUseProxyFromEnv,
|
||||
} from "./http";
|
||||
import { createHttpClient, detectHttpRuntime, registryUrl, shouldUseProxyFromEnv } from "./http.js";
|
||||
import { ApiV1WhoamiResponseSchema } from "./schema/index.js";
|
||||
|
||||
function mockImmediateTimeouts() {
|
||||
const setTimeoutMock = vi.fn((callback: () => void, _ms?: number) => {
|
||||
function createNodeClient(options?: {
|
||||
fetchImpl?: typeof fetch;
|
||||
setTimeoutImpl?: typeof setTimeout;
|
||||
clearTimeoutImpl?: typeof clearTimeout;
|
||||
now?: () => number;
|
||||
}) {
|
||||
return createHttpClient({
|
||||
runtime: "node",
|
||||
configureDispatcher: false,
|
||||
fetchImpl: options?.fetchImpl,
|
||||
setTimeoutImpl: options?.setTimeoutImpl,
|
||||
clearTimeoutImpl: options?.clearTimeoutImpl,
|
||||
now: options?.now,
|
||||
random: () => 0,
|
||||
});
|
||||
}
|
||||
|
||||
function createImmediateTimeouts() {
|
||||
const setTimeoutImpl = vi.fn((callback: () => void, _ms?: number) => {
|
||||
callback();
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutMock = vi.fn();
|
||||
vi.stubGlobal("setTimeout", setTimeoutMock as unknown as typeof setTimeout);
|
||||
vi.stubGlobal("clearTimeout", clearTimeoutMock as typeof clearTimeout);
|
||||
return { setTimeoutMock, clearTimeoutMock };
|
||||
const clearTimeoutImpl = vi.fn();
|
||||
return { setTimeoutImpl, clearTimeoutImpl };
|
||||
}
|
||||
|
||||
function createAbortingFetchMock() {
|
||||
return vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const signal = init?.signal;
|
||||
if (!signal || !(signal instanceof AbortSignal)) {
|
||||
if (!(signal instanceof AbortSignal)) {
|
||||
throw new Error("Missing abort signal");
|
||||
}
|
||||
if (signal.aborted) {
|
||||
@@ -43,6 +51,13 @@ function createAbortingFetchMock() {
|
||||
});
|
||||
}
|
||||
|
||||
describe("detectHttpRuntime", () => {
|
||||
it("detects bun and node runtimes explicitly", () => {
|
||||
expect(detectHttpRuntime({ bun: "1.2.3" } as unknown as NodeJS.ProcessVersions)).toBe("bun");
|
||||
expect(detectHttpRuntime({ node: "22.0.0" } as unknown as NodeJS.ProcessVersions)).toBe("node");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldUseProxyFromEnv", () => {
|
||||
it("detects standard proxy variables", () => {
|
||||
expect(
|
||||
@@ -73,94 +88,60 @@ describe("shouldUseProxyFromEnv", () => {
|
||||
});
|
||||
|
||||
describe("registryUrl", () => {
|
||||
it("works with a plain-origin registry (no base path)", () => {
|
||||
it("preserves registry base paths and normalizes slashes", () => {
|
||||
expect(registryUrl("/api/v1/skills", "https://clawhub.ai").toString()).toBe(
|
||||
"https://clawhub.ai/api/v1/skills",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the registry base path", () => {
|
||||
const base = "http://localhost:8081/custom/registry/path";
|
||||
expect(registryUrl("/api/v1/skills", base).toString()).toBe(
|
||||
"http://localhost:8081/custom/registry/path/api/v1/skills",
|
||||
expect(registryUrl("/api/v1/skills", "http://localhost:8081/custom/path").toString()).toBe(
|
||||
"http://localhost:8081/custom/path/api/v1/skills",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a trailing slash on the registry", () => {
|
||||
const base = "http://localhost:8081/custom/registry/path/";
|
||||
expect(registryUrl("/api/v1/skills", base).toString()).toBe(
|
||||
"http://localhost:8081/custom/registry/path/api/v1/skills",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles paths without a leading slash", () => {
|
||||
expect(registryUrl("api/v1/skills", "https://clawhub.ai").toString()).toBe(
|
||||
"https://clawhub.ai/api/v1/skills",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles compound paths with encoded segments", () => {
|
||||
const base = "http://localhost:8081/base";
|
||||
const path = `/api/v1/skills/${encodeURIComponent("my-skill")}/versions`;
|
||||
expect(registryUrl(path, base).toString()).toBe(
|
||||
"http://localhost:8081/base/api/v1/skills/my-skill/versions",
|
||||
expect(registryUrl("api/v1/skills", "http://localhost:8081/custom/path/").toString()).toBe(
|
||||
"http://localhost:8081/custom/path/api/v1/skills",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiRequest", () => {
|
||||
describe("node http client", () => {
|
||||
it("adds bearer token and parses json", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ user: { handle: null } }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const result = await apiRequest(
|
||||
const client = createNodeClient({ fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
const result = await client.apiRequest(
|
||||
"https://example.com",
|
||||
{ method: "GET", path: "/x", token: "clh_token" },
|
||||
ApiV1WhoamiResponseSchema,
|
||||
);
|
||||
|
||||
expect(result.user.handle).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const [, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer clh_token");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("posts json body", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await apiRequest("https://example.com", {
|
||||
const client = createNodeClient({ fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
await client.apiRequest("https://example.com", {
|
||||
method: "POST",
|
||||
path: "/x",
|
||||
body: { a: 1 },
|
||||
});
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
|
||||
const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://example.com/x");
|
||||
expect(init.body).toBe(JSON.stringify({ a: 1 }));
|
||||
expect((init.headers as Record<string, string>)["Content-Type"]).toBe("application/json");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("throws text body on non-200", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => "bad",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
"bad",
|
||||
);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("includes rate-limit guidance from headers on 429", async () => {
|
||||
mockImmediateTimeouts();
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
it("includes rate-limit guidance from response headers on 429", async () => {
|
||||
const { setTimeoutImpl, clearTimeoutImpl } = createImmediateTimeouts();
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: new Headers({
|
||||
@@ -171,19 +152,22 @@ describe("apiRequest", () => {
|
||||
}),
|
||||
text: async () => "Rate limit exceeded",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = createNodeClient({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
setTimeoutImpl: setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl,
|
||||
});
|
||||
|
||||
await expect(apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
await expect(client.apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
/retry in 34s.*remaining: 0\/20.*reset in 34s/i,
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
vi.unstubAllGlobals();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
expect(clearTimeoutImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("interprets legacy epoch Retry-After values as reset delays", async () => {
|
||||
mockImmediateTimeouts();
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_771_404_500_000);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
const { setTimeoutImpl, clearTimeoutImpl } = createImmediateTimeouts();
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: new Headers({
|
||||
@@ -193,205 +177,146 @@ describe("apiRequest", () => {
|
||||
}),
|
||||
text: async () => "Rate limit exceeded",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = createNodeClient({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
setTimeoutImpl: setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl,
|
||||
now: () => 1_771_404_500_000,
|
||||
});
|
||||
|
||||
await expect(apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
await expect(client.apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
/retry in 40s.*remaining: 0\/20/i,
|
||||
);
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("falls back to HTTP status when body is empty", async () => {
|
||||
mockImmediateTimeouts();
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
it("falls back to HTTP status when response bodies are empty", async () => {
|
||||
const { setTimeoutImpl, clearTimeoutImpl } = createImmediateTimeouts();
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => "",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = createNodeClient({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
setTimeoutImpl: setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl,
|
||||
});
|
||||
|
||||
await expect(
|
||||
apiRequest("https://example.com", { method: "GET", url: "https://example.com/x" }),
|
||||
client.apiRequest("https://example.com", { method: "GET", url: "https://example.com/x" }),
|
||||
).rejects.toThrow("HTTP 500");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
vi.unstubAllGlobals();
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("downloads zip bytes", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const bytes = await downloadZip("https://example.com", {
|
||||
it("downloads zip bytes and does not retry non-retryable errors", async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => "nope",
|
||||
});
|
||||
const client = createNodeClient({ fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
const bytes = await client.downloadZip("https://example.com", {
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
token: "clh_token",
|
||||
});
|
||||
expect(Array.from(bytes)).toEqual([1, 2, 3]);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("slug=demo");
|
||||
expect(url).toContain("version=1.0.0");
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer clh_token");
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
await expect(client.downloadZip("https://example.com", { slug: "demo" })).rejects.toThrow(
|
||||
"nope",
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry on non-retryable errors", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => "nope",
|
||||
it("retries request and text timeouts using injected timeout helpers", async () => {
|
||||
const { setTimeoutImpl, clearTimeoutImpl } = createImmediateTimeouts();
|
||||
const fetchImpl = createAbortingFetchMock();
|
||||
const client = createNodeClient({
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
setTimeoutImpl: setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl,
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(downloadZip("https://example.com", { slug: "demo" })).rejects.toThrow("nope");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
await expect(client.apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
/timed out/i,
|
||||
);
|
||||
await expect(client.fetchText("https://example.com", { path: "/x" })).rejects.toThrow(
|
||||
/timed out/i,
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(6);
|
||||
expect(clearTimeoutImpl).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("aborts with Error timeouts and retries", async () => {
|
||||
const { clearTimeoutMock } = mockImmediateTimeouts();
|
||||
const fetchMock = createAbortingFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
it("normalizes non-Error throws from fetch", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw { message: "The operation was aborted", name: "AbortError" };
|
||||
});
|
||||
const client = createNodeClient({ fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await apiRequest("https://example.com", { method: "GET", path: "/x" });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toMatch(/timed out/);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(clearTimeoutMock.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
vi.unstubAllGlobals();
|
||||
await expect(client.apiRequest("https://example.com", { method: "GET", path: "/x" })).rejects.toThrow(
|
||||
"The operation was aborted",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiRequestForm", () => {
|
||||
it("posts form data and returns json", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
it("posts form data, retries 429, and uses the upload timeout", async () => {
|
||||
const successFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const successClient = createNodeClient({ fetchImpl: successFetch as unknown as typeof fetch });
|
||||
const form = new FormData();
|
||||
form.append("x", "1");
|
||||
const result = await apiRequestForm("https://example.com", {
|
||||
const result = await successClient.apiRequestForm("https://example.com", {
|
||||
method: "POST",
|
||||
path: "/upload",
|
||||
token: "clh_token",
|
||||
form,
|
||||
});
|
||||
expect(result).toEqual({ ok: true });
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const [, init] = successFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe(form);
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer clh_token");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("retries on 429", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
const rateLimitedFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: async () => "rate limited",
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const retryClient = createNodeClient({
|
||||
fetchImpl: rateLimitedFetch as unknown as typeof fetch,
|
||||
setTimeoutImpl: createImmediateTimeouts().setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl: vi.fn(),
|
||||
});
|
||||
await expect(
|
||||
apiRequestForm("https://example.com", {
|
||||
retryClient.apiRequestForm("https://example.com", {
|
||||
method: "POST",
|
||||
path: "/upload",
|
||||
form: new FormData(),
|
||||
}),
|
||||
).rejects.toThrow("rate limited");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
expect(rateLimitedFetch).toHaveBeenCalledTimes(3);
|
||||
|
||||
it("falls back to HTTP status when body cannot be read", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
const { setTimeoutImpl, clearTimeoutImpl } = createImmediateTimeouts();
|
||||
const abortingFetch = createAbortingFetchMock();
|
||||
const timeoutClient = createNodeClient({
|
||||
fetchImpl: abortingFetch as unknown as typeof fetch,
|
||||
setTimeoutImpl: setTimeoutImpl as unknown as typeof setTimeout,
|
||||
clearTimeoutImpl,
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(
|
||||
apiRequestForm("https://example.com", {
|
||||
timeoutClient.apiRequestForm("https://example.com", {
|
||||
method: "POST",
|
||||
path: "/upload",
|
||||
form: new FormData(),
|
||||
}),
|
||||
).rejects.toThrow("HTTP 400");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses the longer upload timeout for multipart requests", async () => {
|
||||
const { setTimeoutMock, clearTimeoutMock } = mockImmediateTimeouts();
|
||||
const fetchMock = createAbortingFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await apiRequestForm("https://example.com", {
|
||||
method: "POST",
|
||||
path: "/upload",
|
||||
form: new FormData(),
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toMatch(/timed out after 120s/i);
|
||||
expect(setTimeoutMock).toHaveBeenCalled();
|
||||
expect(setTimeoutMock.mock.calls[0]?.[1]).toBe(120_000);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(clearTimeoutMock.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchText", () => {
|
||||
it("aborts with Error timeouts and retries", async () => {
|
||||
const { clearTimeoutMock } = mockImmediateTimeouts();
|
||||
const fetchMock = createAbortingFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await fetchText("https://example.com", { path: "/x" });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toMatch(/timed out/);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(clearTimeoutMock.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchWithTimeout — non-Error normalization", () => {
|
||||
it("wraps DOMException-like non-Error throws into proper Error instances", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
// Simulate a runtime that throws a non-Error object on abort
|
||||
throw { message: "The operation was aborted", name: "AbortError" };
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await apiRequest("https://example.com", { method: "GET", path: "/x" });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
expect((caught as Error).message).toContain("The operation was aborted");
|
||||
vi.unstubAllGlobals();
|
||||
).rejects.toThrow(/timed out after 120s/i);
|
||||
expect(setTimeoutImpl.mock.calls[0]?.[1]).toBe(120_000);
|
||||
});
|
||||
});
|
||||
|
||||
+315
-192
@@ -28,38 +28,19 @@ const CURL_WRITE_OUT_FORMAT = [
|
||||
"%{header:ratelimit-reset}",
|
||||
"%{header:retry-after}",
|
||||
].join("\n");
|
||||
const isBun = typeof process !== "undefined" && Boolean(process.versions?.bun);
|
||||
|
||||
export function shouldUseProxyFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return Boolean(env.HTTPS_PROXY || env.HTTP_PROXY || env.https_proxy || env.http_proxy);
|
||||
}
|
||||
|
||||
if (typeof process !== "undefined" && process.versions?.node) {
|
||||
try {
|
||||
setGlobalDispatcher(
|
||||
shouldUseProxyFromEnv(process.env)
|
||||
? new EnvHttpProxyAgent({
|
||||
connect: { timeout: REQUEST_TIMEOUT_MS },
|
||||
})
|
||||
: new Agent({
|
||||
connect: { timeout: REQUEST_TIMEOUT_MS },
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// ignore dispatcher setup failures in non-node runtimes
|
||||
}
|
||||
}
|
||||
|
||||
export function registryUrl(path: string, registry: string): URL {
|
||||
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
||||
const relative = path.startsWith("/") ? path.slice(1) : path;
|
||||
return new URL(relative, base);
|
||||
}
|
||||
export type HttpRuntime = "node" | "bun";
|
||||
|
||||
type RequestArgs =
|
||||
| { method: "GET" | "POST" | "DELETE"; path: string; token?: string; body?: unknown }
|
||||
| { method: "GET" | "POST" | "DELETE"; url: string; token?: string; body?: unknown };
|
||||
|
||||
type FormRequestArgs =
|
||||
| { method: "POST"; path: string; token?: string; form: FormData }
|
||||
| { method: "POST"; url: string; token?: string; form: FormData };
|
||||
|
||||
type TextRequestArgs = { path: string; token?: string } | { url: string; token?: string };
|
||||
|
||||
type HeaderSource = Headers | Record<string, string> | null | undefined;
|
||||
|
||||
type RateLimitInfo = {
|
||||
@@ -69,6 +50,40 @@ type RateLimitInfo = {
|
||||
retryAfterSeconds?: number;
|
||||
};
|
||||
|
||||
type HttpClientDeps = {
|
||||
runtime: HttpRuntime;
|
||||
fetchImpl: typeof fetch;
|
||||
setTimeoutImpl: typeof setTimeout;
|
||||
clearTimeoutImpl: typeof clearTimeout;
|
||||
spawnSyncImpl: typeof spawnSync;
|
||||
mkdirImpl: typeof mkdir;
|
||||
mkdtempImpl: typeof mkdtemp;
|
||||
readFileImpl: typeof readFile;
|
||||
rmImpl: typeof rm;
|
||||
writeFileImpl: typeof writeFile;
|
||||
tmpdirPath: string;
|
||||
now: () => number;
|
||||
random: () => number;
|
||||
env: NodeJS.ProcessEnv;
|
||||
configureDispatcher: boolean;
|
||||
};
|
||||
|
||||
export type HttpClientOptions = Partial<Omit<HttpClientDeps, "runtime">> & {
|
||||
runtime?: HttpRuntime;
|
||||
};
|
||||
|
||||
type HttpClient = {
|
||||
apiRequest<T>(registry: string, args: RequestArgs): Promise<T>;
|
||||
apiRequest<T>(registry: string, args: RequestArgs, schema: ArkValidator<T>): Promise<T>;
|
||||
apiRequestForm<T>(registry: string, args: FormRequestArgs): Promise<T>;
|
||||
apiRequestForm<T>(registry: string, args: FormRequestArgs, schema: ArkValidator<T>): Promise<T>;
|
||||
fetchText(registry: string, args: TextRequestArgs): Promise<string>;
|
||||
downloadZip(
|
||||
registry: string,
|
||||
args: { slug: string; version?: string; token?: string },
|
||||
): Promise<Uint8Array>;
|
||||
};
|
||||
|
||||
class HttpStatusError extends Error {
|
||||
readonly status: number;
|
||||
readonly rateLimit: RateLimitInfo;
|
||||
@@ -81,6 +96,178 @@ class HttpStatusError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function detectHttpRuntime(
|
||||
processVersions: NodeJS.ProcessVersions | undefined = process.versions,
|
||||
): HttpRuntime {
|
||||
return processVersions?.bun ? "bun" : "node";
|
||||
}
|
||||
|
||||
export function shouldUseProxyFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return Boolean(env.HTTPS_PROXY || env.HTTP_PROXY || env.https_proxy || env.http_proxy);
|
||||
}
|
||||
|
||||
export function registryUrl(path: string, registry: string): URL {
|
||||
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
||||
const relative = path.startsWith("/") ? path.slice(1) : path;
|
||||
return new URL(relative, base);
|
||||
}
|
||||
|
||||
export function createHttpClient(options: HttpClientOptions = {}): HttpClient {
|
||||
const deps: HttpClientDeps = {
|
||||
runtime: options.runtime ?? detectHttpRuntime(),
|
||||
fetchImpl: options.fetchImpl ?? globalThis.fetch.bind(globalThis),
|
||||
setTimeoutImpl: options.setTimeoutImpl ?? globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeoutImpl: options.clearTimeoutImpl ?? globalThis.clearTimeout.bind(globalThis),
|
||||
spawnSyncImpl: options.spawnSyncImpl ?? spawnSync,
|
||||
mkdirImpl: options.mkdirImpl ?? mkdir,
|
||||
mkdtempImpl: options.mkdtempImpl ?? mkdtemp,
|
||||
readFileImpl: options.readFileImpl ?? readFile,
|
||||
rmImpl: options.rmImpl ?? rm,
|
||||
writeFileImpl: options.writeFileImpl ?? writeFile,
|
||||
tmpdirPath: options.tmpdirPath ?? tmpdir(),
|
||||
now: options.now ?? Date.now,
|
||||
random: options.random ?? Math.random,
|
||||
env: options.env ?? process.env,
|
||||
configureDispatcher: options.configureDispatcher ?? true,
|
||||
};
|
||||
|
||||
if (deps.runtime === "node" && deps.configureDispatcher) {
|
||||
configureNodeDispatcher(deps.env);
|
||||
}
|
||||
|
||||
const runWithRetries = createRetryRunner(deps);
|
||||
|
||||
async function apiRequest<T>(
|
||||
registry: string,
|
||||
args: RequestArgs,
|
||||
schema?: ArkValidator<T>,
|
||||
): Promise<T> {
|
||||
const url = "url" in args ? args.url : registryUrl(args.path, registry).toString();
|
||||
const json = await runWithRetries(async () => {
|
||||
if (deps.runtime === "bun") {
|
||||
return await fetchJsonViaCurl(deps, url, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
let body: string | undefined;
|
||||
if (args.method === "POST") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(args.body ?? {});
|
||||
}
|
||||
const response = await fetchWithTimeout(deps, url, {
|
||||
method: args.method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response), response.headers, deps.now);
|
||||
}
|
||||
return (await response.json()) as unknown;
|
||||
});
|
||||
if (schema) return parseArk(schema, json, "API response");
|
||||
return json as T;
|
||||
}
|
||||
|
||||
async function apiRequestForm<T>(
|
||||
registry: string,
|
||||
args: FormRequestArgs,
|
||||
schema?: ArkValidator<T>,
|
||||
): Promise<T> {
|
||||
const url = "url" in args ? args.url : registryUrl(args.path, registry).toString();
|
||||
const json = await runWithRetries(async () => {
|
||||
if (deps.runtime === "bun") {
|
||||
return await fetchJsonFormViaCurl(deps, url, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
const response = await fetchWithTimeout(
|
||||
deps,
|
||||
url,
|
||||
{
|
||||
method: args.method,
|
||||
headers,
|
||||
body: args.form,
|
||||
},
|
||||
UPLOAD_TIMEOUT_MS,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response), response.headers, deps.now);
|
||||
}
|
||||
return (await response.json()) as unknown;
|
||||
});
|
||||
if (schema) return parseArk(schema, json, "API response");
|
||||
return json as T;
|
||||
}
|
||||
|
||||
async function fetchTextRequest(registry: string, args: TextRequestArgs): Promise<string> {
|
||||
const url = "url" in args ? args.url : registryUrl(args.path, registry).toString();
|
||||
return await runWithRetries(async () => {
|
||||
if (deps.runtime === "bun") {
|
||||
return await fetchTextViaCurl(deps, url, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: "text/plain" };
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
const response = await fetchWithTimeout(deps, url, { method: "GET", headers });
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, text, response.headers, deps.now);
|
||||
}
|
||||
return text;
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadZipRequest(
|
||||
registry: string,
|
||||
args: { slug: string; version?: string; token?: string },
|
||||
) {
|
||||
const url = registryUrl(ApiRoutes.download, registry);
|
||||
url.searchParams.set("slug", args.slug);
|
||||
if (args.version) url.searchParams.set("version", args.version);
|
||||
return await runWithRetries(async () => {
|
||||
if (deps.runtime === "bun") {
|
||||
return await fetchBinaryViaCurl(deps, url.toString(), args.token);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
const response = await fetchWithTimeout(deps, url.toString(), { method: "GET", headers });
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response), response.headers, deps.now);
|
||||
}
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
apiRequest,
|
||||
apiRequestForm,
|
||||
fetchText: fetchTextRequest,
|
||||
downloadZip: downloadZipRequest,
|
||||
};
|
||||
}
|
||||
|
||||
function configureNodeDispatcher(env: NodeJS.ProcessEnv) {
|
||||
if (!process.versions?.node) return;
|
||||
try {
|
||||
setGlobalDispatcher(
|
||||
shouldUseProxyFromEnv(env)
|
||||
? new EnvHttpProxyAgent({
|
||||
connect: { timeout: REQUEST_TIMEOUT_MS },
|
||||
})
|
||||
: new Agent({
|
||||
connect: { timeout: REQUEST_TIMEOUT_MS },
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// Ignore dispatcher setup failures in environments that partially emulate Node APIs.
|
||||
}
|
||||
}
|
||||
|
||||
const defaultHttpClient = createHttpClient();
|
||||
|
||||
export async function apiRequest<T>(registry: string, args: RequestArgs): Promise<T>;
|
||||
export async function apiRequest<T>(
|
||||
registry: string,
|
||||
@@ -92,37 +279,12 @@ export async function apiRequest<T>(
|
||||
args: RequestArgs,
|
||||
schema?: ArkValidator<T>,
|
||||
): Promise<T> {
|
||||
const url = "url" in args ? args.url : registryUrl(args.path, registry).toString();
|
||||
const json = await runWithRetries(async () => {
|
||||
if (isBun) {
|
||||
return await fetchJsonViaCurl(url, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
let body: string | undefined;
|
||||
if (args.method === "POST") {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(args.body ?? {});
|
||||
}
|
||||
const response = await fetchWithTimeout(url, {
|
||||
method: args.method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response), response.headers);
|
||||
}
|
||||
return (await response.json()) as unknown;
|
||||
});
|
||||
if (schema) return parseArk(schema, json, "API response");
|
||||
return json as T;
|
||||
if (schema) {
|
||||
return await defaultHttpClient.apiRequest(registry, args, schema);
|
||||
}
|
||||
return await defaultHttpClient.apiRequest(registry, args);
|
||||
}
|
||||
|
||||
type FormRequestArgs =
|
||||
| { method: "POST"; path: string; token?: string; form: FormData }
|
||||
| { method: "POST"; url: string; token?: string; form: FormData };
|
||||
|
||||
export async function apiRequestForm<T>(registry: string, args: FormRequestArgs): Promise<T>;
|
||||
export async function apiRequestForm<T>(
|
||||
registry: string,
|
||||
@@ -134,98 +296,63 @@ export async function apiRequestForm<T>(
|
||||
args: FormRequestArgs,
|
||||
schema?: ArkValidator<T>,
|
||||
): Promise<T> {
|
||||
const url = "url" in args ? args.url : registryUrl(args.path, registry).toString();
|
||||
const json = await runWithRetries(async () => {
|
||||
if (isBun) {
|
||||
return await fetchJsonFormViaCurl(url, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
const response = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: args.method,
|
||||
headers,
|
||||
body: args.form,
|
||||
},
|
||||
UPLOAD_TIMEOUT_MS,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response), response.headers);
|
||||
}
|
||||
return (await response.json()) as unknown;
|
||||
});
|
||||
if (schema) return parseArk(schema, json, "API response");
|
||||
return json as T;
|
||||
if (schema) {
|
||||
return await defaultHttpClient.apiRequestForm(registry, args, schema);
|
||||
}
|
||||
return await defaultHttpClient.apiRequestForm(registry, args);
|
||||
}
|
||||
|
||||
type TextRequestArgs = { path: string; token?: string } | { url: string; token?: string };
|
||||
|
||||
export async function fetchText(registry: string, args: TextRequestArgs): Promise<string> {
|
||||
const url = "url" in args ? args.url : registryUrl(args.path, registry).toString();
|
||||
return runWithRetries(async () => {
|
||||
if (isBun) {
|
||||
return await fetchTextViaCurl(url, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Accept: "text/plain" };
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
const response = await fetchWithTimeout(url, { method: "GET", headers });
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, text, response.headers);
|
||||
}
|
||||
return text;
|
||||
});
|
||||
return await defaultHttpClient.fetchText(registry, args);
|
||||
}
|
||||
|
||||
export async function downloadZip(
|
||||
registry: string,
|
||||
args: { slug: string; version?: string; token?: string },
|
||||
) {
|
||||
const url = registryUrl(ApiRoutes.download, registry);
|
||||
url.searchParams.set("slug", args.slug);
|
||||
if (args.version) url.searchParams.set("version", args.version);
|
||||
return runWithRetries(async () => {
|
||||
if (isBun) {
|
||||
return await fetchBinaryViaCurl(url.toString(), args.token);
|
||||
}
|
||||
return await defaultHttpClient.downloadZip(registry, args);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (args.token) headers.Authorization = `Bearer ${args.token}`;
|
||||
|
||||
const response = await fetchWithTimeout(url.toString(), { method: "GET", headers });
|
||||
if (!response.ok) {
|
||||
throwHttpStatusError(response.status, await readResponseTextSafe(response), response.headers);
|
||||
}
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
});
|
||||
function createRetryRunner(deps: Pick<HttpClientDeps, "setTimeoutImpl" | "random" | "now">) {
|
||||
return async function runWithRetries<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return await pRetry(fn, {
|
||||
retries: RETRY_COUNT,
|
||||
minTimeout: 0,
|
||||
maxTimeout: 0,
|
||||
factor: 1,
|
||||
randomize: false,
|
||||
onFailedAttempt: async (attemptError) => {
|
||||
const delayMs = getRetryDelayMs(attemptError, deps.random);
|
||||
if (delayMs <= 0) return;
|
||||
await sleep(delayMs, deps.setTimeoutImpl);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
deps: Pick<HttpClientDeps, "fetchImpl" | "setTimeoutImpl" | "clearTimeoutImpl">,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs = REQUEST_TIMEOUT_MS,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutSeconds = Math.ceil(timeoutMs / 1000);
|
||||
const timeout = setTimeout(
|
||||
const timeout = deps.setTimeoutImpl(
|
||||
() => controller.abort(new Error(`Request timed out after ${timeoutSeconds}s`)),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
return await deps.fetchImpl(url, { ...init, signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) throw error;
|
||||
// Normalize non-Error throws (e.g. DOMException from AbortController) into proper Errors
|
||||
const message =
|
||||
typeof error === "object" && error !== null && "message" in error
|
||||
? String((error as { message: unknown }).message)
|
||||
: String(error);
|
||||
throw new Error(message, { cause: error });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
deps.clearTimeoutImpl(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,22 +360,7 @@ async function readResponseTextSafe(response: Response): Promise<string> {
|
||||
return await response.text().catch(() => "");
|
||||
}
|
||||
|
||||
async function runWithRetries<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return await pRetry(fn, {
|
||||
retries: RETRY_COUNT,
|
||||
minTimeout: 0,
|
||||
maxTimeout: 0,
|
||||
factor: 1,
|
||||
randomize: false,
|
||||
onFailedAttempt: async (attemptError) => {
|
||||
const delayMs = getRetryDelayMs(attemptError);
|
||||
if (delayMs <= 0) return;
|
||||
await sleep(delayMs);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getRetryDelayMs(attemptError: unknown): number {
|
||||
function getRetryDelayMs(attemptError: unknown, random: () => number): number {
|
||||
const failed = attemptError as {
|
||||
attemptNumber?: number;
|
||||
cause?: unknown;
|
||||
@@ -257,25 +369,30 @@ function getRetryDelayMs(attemptError: unknown): number {
|
||||
const attemptNumber = Math.max(1, Number(failed.attemptNumber ?? 1));
|
||||
const rootError = failed.cause ?? failed.error ?? attemptError;
|
||||
if (rootError instanceof HttpStatusError && rootError.rateLimit.retryAfterSeconds !== undefined) {
|
||||
return rootError.rateLimit.retryAfterSeconds * 1000 + jitterMs(RETRY_AFTER_JITTER_MS);
|
||||
return rootError.rateLimit.retryAfterSeconds * 1000 + jitterMs(RETRY_AFTER_JITTER_MS, random);
|
||||
}
|
||||
const baseMs = Math.min(RETRY_BACKOFF_MAX_MS, RETRY_BACKOFF_BASE_MS * 2 ** (attemptNumber - 1));
|
||||
return baseMs + jitterMs(RETRY_BACKOFF_BASE_MS);
|
||||
return baseMs + jitterMs(RETRY_BACKOFF_BASE_MS, random);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
function sleep(ms: number, setTimeoutImpl: typeof setTimeout): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
setTimeoutImpl(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function jitterMs(maxMs: number): number {
|
||||
function jitterMs(maxMs: number, random: () => number): number {
|
||||
if (maxMs <= 0) return 0;
|
||||
return Math.floor(Math.random() * maxMs);
|
||||
return Math.floor(random() * maxMs);
|
||||
}
|
||||
|
||||
function throwHttpStatusError(status: number, text: string, headers?: HeaderSource): never {
|
||||
const rateLimit = parseRateLimitInfo(headers);
|
||||
function throwHttpStatusError(
|
||||
status: number,
|
||||
text: string,
|
||||
headers: HeaderSource,
|
||||
now: () => number,
|
||||
): never {
|
||||
const rateLimit = parseRateLimitInfo(headers, now);
|
||||
const message = buildHttpErrorMessage(status, text, rateLimit);
|
||||
if (status === 429 || status >= 500) {
|
||||
throw new HttpStatusError(status, message, rateLimit);
|
||||
@@ -295,13 +412,10 @@ function buildHttpErrorMessage(status: number, text: string, rateLimit: RateLimi
|
||||
if (rateLimit.resetDelaySeconds !== undefined) {
|
||||
details.push(`reset in ${rateLimit.resetDelaySeconds}s`);
|
||||
}
|
||||
if (details.length === 0) {
|
||||
return base;
|
||||
}
|
||||
return `${base} (${details.join(", ")})`;
|
||||
return details.length === 0 ? base : `${base} (${details.join(", ")})`;
|
||||
}
|
||||
|
||||
function parseRateLimitInfo(headers?: HeaderSource): RateLimitInfo {
|
||||
function parseRateLimitInfo(headers: HeaderSource, now: () => number): RateLimitInfo {
|
||||
if (!headers) return {};
|
||||
const limit = parseIntHeader(
|
||||
getHeader(headers, "x-ratelimit-limit") ?? getHeader(headers, "ratelimit-limit"),
|
||||
@@ -309,16 +423,10 @@ function parseRateLimitInfo(headers?: HeaderSource): RateLimitInfo {
|
||||
const remaining = parseIntHeader(
|
||||
getHeader(headers, "x-ratelimit-remaining") ?? getHeader(headers, "ratelimit-remaining"),
|
||||
);
|
||||
const nowMs = Date.now();
|
||||
const nowMs = now();
|
||||
const retryAfterSeconds = parseRetryAfterSeconds(getHeader(headers, "retry-after"), nowMs);
|
||||
const resetDelaySeconds = parseResetDelaySeconds(headers, nowMs, retryAfterSeconds);
|
||||
|
||||
return {
|
||||
limit,
|
||||
remaining,
|
||||
resetDelaySeconds,
|
||||
retryAfterSeconds,
|
||||
};
|
||||
return { limit, remaining, resetDelaySeconds, retryAfterSeconds };
|
||||
}
|
||||
|
||||
function parseResetDelaySeconds(
|
||||
@@ -327,7 +435,6 @@ function parseResetDelaySeconds(
|
||||
retryAfterSeconds: number | undefined,
|
||||
): number | undefined {
|
||||
if (retryAfterSeconds !== undefined) return retryAfterSeconds;
|
||||
|
||||
const standardized = parseIntHeader(getHeader(headers, "ratelimit-reset"));
|
||||
if (standardized !== undefined) {
|
||||
return Math.max(1, standardized);
|
||||
@@ -345,7 +452,6 @@ function parseRetryAfterSeconds(value: string | undefined, nowMs: number): numbe
|
||||
|
||||
const asNumber = Number(trimmed);
|
||||
if (Number.isFinite(asNumber) && asNumber >= 0) {
|
||||
// Compatibility guard for older servers that accidentally sent Unix epoch seconds.
|
||||
if (asNumber > 31_536_000) {
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
return Math.max(1, Math.ceil(asNumber - nowSeconds));
|
||||
@@ -361,8 +467,7 @@ function parseRetryAfterSeconds(value: string | undefined, nowMs: number): numbe
|
||||
function parseIntHeader(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
return parsed;
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function getHeader(headers: HeaderSource, key: string): string | undefined {
|
||||
@@ -383,11 +488,13 @@ function getHeader(headers: HeaderSource, key: string): string | undefined {
|
||||
return typeof match?.[1] === "string" ? match[1].trim() : undefined;
|
||||
}
|
||||
|
||||
async function fetchJsonViaCurl(url: string, args: RequestArgs) {
|
||||
async function fetchJsonViaCurl(
|
||||
deps: Pick<HttpClientDeps, "spawnSyncImpl" | "now">,
|
||||
url: string,
|
||||
args: RequestArgs,
|
||||
) {
|
||||
const headers = ["-H", "Accept: application/json"];
|
||||
if (args.token) {
|
||||
headers.push("-H", `Authorization: Bearer ${args.token}`);
|
||||
}
|
||||
if (args.token) headers.push("-H", `Authorization: Bearer ${args.token}`);
|
||||
const curlArgs = [
|
||||
"--silent",
|
||||
"--show-error",
|
||||
@@ -406,24 +513,29 @@ async function fetchJsonViaCurl(url: string, args: RequestArgs) {
|
||||
curlArgs.push("--data-binary", JSON.stringify(args.body ?? {}));
|
||||
}
|
||||
|
||||
const result = spawnSync("curl", curlArgs, { encoding: "utf8" });
|
||||
const result = deps.spawnSyncImpl("curl", curlArgs, { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || "curl failed");
|
||||
}
|
||||
const { body, status, headers: responseHeaders } = parseCurlBodyAndMeta(result.stdout ?? "");
|
||||
if (status < 200 || status >= 300) {
|
||||
throwHttpStatusError(status, body, responseHeaders);
|
||||
throwHttpStatusError(status, body, responseHeaders, deps.now);
|
||||
}
|
||||
return JSON.parse(body || "null") as unknown;
|
||||
}
|
||||
|
||||
async function fetchJsonFormViaCurl(url: string, args: FormRequestArgs) {
|
||||
async function fetchJsonFormViaCurl(
|
||||
deps: Pick<
|
||||
HttpClientDeps,
|
||||
"spawnSyncImpl" | "mkdtempImpl" | "mkdirImpl" | "writeFileImpl" | "rmImpl" | "tmpdirPath" | "now"
|
||||
>,
|
||||
url: string,
|
||||
args: FormRequestArgs,
|
||||
) {
|
||||
const headers = ["-H", "Accept: application/json"];
|
||||
if (args.token) {
|
||||
headers.push("-H", `Authorization: Bearer ${args.token}`);
|
||||
}
|
||||
if (args.token) headers.push("-H", `Authorization: Bearer ${args.token}`);
|
||||
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "clawhub-upload-"));
|
||||
const tempDir = await deps.mkdtempImpl(join(deps.tmpdirPath, "clawhub-upload-"));
|
||||
try {
|
||||
const formArgs: string[] = [];
|
||||
for (const [key, value] of args.form.entries()) {
|
||||
@@ -431,8 +543,8 @@ async function fetchJsonFormViaCurl(url: string, args: FormRequestArgs) {
|
||||
const filename = typeof (value as File).name === "string" ? (value as File).name : "file";
|
||||
const filePath = join(tempDir, filename);
|
||||
const bytes = new Uint8Array(await value.arrayBuffer());
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, bytes);
|
||||
await deps.mkdirImpl(dirname(filePath), { recursive: true });
|
||||
await deps.writeFileImpl(filePath, bytes);
|
||||
formArgs.push("-F", `${key}=@${filePath};filename=${filename}`);
|
||||
} else {
|
||||
formArgs.push("-F", `${key}=${String(value)}`);
|
||||
@@ -454,25 +566,27 @@ async function fetchJsonFormViaCurl(url: string, args: FormRequestArgs) {
|
||||
url,
|
||||
];
|
||||
|
||||
const result = spawnSync("curl", curlArgs, { encoding: "utf8" });
|
||||
const result = deps.spawnSyncImpl("curl", curlArgs, { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || "curl failed");
|
||||
}
|
||||
const { body, status, headers: responseHeaders } = parseCurlBodyAndMeta(result.stdout ?? "");
|
||||
if (status < 200 || status >= 300) {
|
||||
throwHttpStatusError(status, body, responseHeaders);
|
||||
throwHttpStatusError(status, body, responseHeaders, deps.now);
|
||||
}
|
||||
return JSON.parse(body || "null") as unknown;
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await deps.rmImpl(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTextViaCurl(url: string, args: { token?: string }) {
|
||||
async function fetchTextViaCurl(
|
||||
deps: Pick<HttpClientDeps, "spawnSyncImpl" | "now">,
|
||||
url: string,
|
||||
args: { token?: string },
|
||||
) {
|
||||
const headers = ["-H", "Accept: text/plain"];
|
||||
if (args.token) {
|
||||
headers.push("-H", `Authorization: Bearer ${args.token}`);
|
||||
}
|
||||
if (args.token) headers.push("-H", `Authorization: Bearer ${args.token}`);
|
||||
const curlArgs = [
|
||||
"--silent",
|
||||
"--show-error",
|
||||
@@ -486,25 +600,30 @@ async function fetchTextViaCurl(url: string, args: { token?: string }) {
|
||||
...headers,
|
||||
url,
|
||||
];
|
||||
const result = spawnSync("curl", curlArgs, { encoding: "utf8" });
|
||||
const result = deps.spawnSyncImpl("curl", curlArgs, { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || "curl failed");
|
||||
}
|
||||
const { body, status, headers: responseHeaders } = parseCurlBodyAndMeta(result.stdout ?? "");
|
||||
if (status < 200 || status >= 300) {
|
||||
throwHttpStatusError(status, body, responseHeaders);
|
||||
throwHttpStatusError(status, body, responseHeaders, deps.now);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function fetchBinaryViaCurl(url: string, token?: string) {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "clawhub-download-"));
|
||||
async function fetchBinaryViaCurl(
|
||||
deps: Pick<
|
||||
HttpClientDeps,
|
||||
"spawnSyncImpl" | "mkdtempImpl" | "readFileImpl" | "rmImpl" | "tmpdirPath" | "now"
|
||||
>,
|
||||
url: string,
|
||||
token?: string,
|
||||
) {
|
||||
const tempDir = await deps.mkdtempImpl(join(deps.tmpdirPath, "clawhub-download-"));
|
||||
const filePath = join(tempDir, "payload.bin");
|
||||
try {
|
||||
const headers: string[] = [];
|
||||
if (token) {
|
||||
headers.push("-H", `Authorization: Bearer ${token}`);
|
||||
}
|
||||
if (token) headers.push("-H", `Authorization: Bearer ${token}`);
|
||||
|
||||
const curlArgs = [
|
||||
"--silent",
|
||||
@@ -519,19 +638,24 @@ async function fetchBinaryViaCurl(url: string, token?: string) {
|
||||
CURL_WRITE_OUT_FORMAT,
|
||||
url,
|
||||
];
|
||||
const result = spawnSync("curl", curlArgs, { encoding: "utf8" });
|
||||
const result = deps.spawnSyncImpl("curl", curlArgs, { encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || "curl failed");
|
||||
}
|
||||
const { status, headers: responseHeaders } = parseCurlBodyAndMeta(result.stdout ?? "");
|
||||
if (status < 200 || status >= 300) {
|
||||
const body = await readFileSafe(filePath);
|
||||
throwHttpStatusError(status, body ? new TextDecoder().decode(body) : "", responseHeaders);
|
||||
const body = await readFileSafe(deps.readFileImpl, filePath);
|
||||
throwHttpStatusError(
|
||||
status,
|
||||
body ? new TextDecoder().decode(body) : "",
|
||||
responseHeaders,
|
||||
deps.now,
|
||||
);
|
||||
}
|
||||
const bytes = await readFileSafe(filePath);
|
||||
const bytes = await readFileSafe(deps.readFileImpl, filePath);
|
||||
return bytes ? new Uint8Array(bytes) : new Uint8Array();
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await deps.rmImpl(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,7 +667,6 @@ function parseCurlBodyAndMeta(output: string): {
|
||||
const marker = `\n${CURL_META_MARKER}\n`;
|
||||
const markerIndex = output.lastIndexOf(marker);
|
||||
if (markerIndex === -1) {
|
||||
// Backward compatibility for older tests that only provide "<body>\n<status>".
|
||||
const splitAt = output.lastIndexOf("\n");
|
||||
if (splitAt === -1) {
|
||||
const statusOnly = Number(output.trim());
|
||||
@@ -595,9 +718,9 @@ function setHeaderIfPresent(
|
||||
headers[key] = trimmed;
|
||||
}
|
||||
|
||||
async function readFileSafe(path: string) {
|
||||
async function readFileSafe(readFileImpl: typeof readFile, path: string) {
|
||||
try {
|
||||
return await readFile(path);
|
||||
return await readFileImpl(path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
PLATFORM_SKILL_LICENSE_SUMMARY,
|
||||
PLATFORM_SKILL_LICENSE_URL,
|
||||
} from "./license.js";
|
||||
export * from "./openclawContract.js";
|
||||
export * from "./packages.js";
|
||||
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
|
||||
export * from "./schemas.js";
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { PackageCompatibility } from "./packages.js";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
export type OpenClawExternalPluginValidationIssue = {
|
||||
fieldPath: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type OpenClawExternalCodePluginValidation = {
|
||||
compatibility?: PackageCompatibility;
|
||||
issues: OpenClawExternalPluginValidationIssue[];
|
||||
};
|
||||
|
||||
export const OPENCLAW_EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS = [
|
||||
"openclaw.compat.pluginApi",
|
||||
"openclaw.build.openclawVersion",
|
||||
] as const;
|
||||
|
||||
function isRecord(value: unknown): value is JsonObject {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getTrimmedString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readOpenClawBlock(packageJson: unknown) {
|
||||
const root = isRecord(packageJson) ? packageJson : undefined;
|
||||
const openclaw = isRecord(root?.openclaw) ? root.openclaw : undefined;
|
||||
const compat = isRecord(openclaw?.compat) ? openclaw.compat : undefined;
|
||||
const build = isRecord(openclaw?.build) ? openclaw.build : undefined;
|
||||
const install = isRecord(openclaw?.install) ? openclaw.install : undefined;
|
||||
return { root, compat, build, install };
|
||||
}
|
||||
|
||||
export function normalizeOpenClawExternalPluginCompatibility(
|
||||
packageJson: unknown,
|
||||
): PackageCompatibility | undefined {
|
||||
const { root, compat, build, install } = readOpenClawBlock(packageJson);
|
||||
const version = getTrimmedString(root?.version);
|
||||
const minHostVersion = getTrimmedString(install?.minHostVersion);
|
||||
const compatibility: PackageCompatibility = {};
|
||||
|
||||
const pluginApi = getTrimmedString(compat?.pluginApi);
|
||||
if (pluginApi) {
|
||||
compatibility.pluginApiRange = pluginApi;
|
||||
}
|
||||
|
||||
const minGatewayVersion = getTrimmedString(compat?.minGatewayVersion) ?? minHostVersion;
|
||||
if (minGatewayVersion) {
|
||||
compatibility.minGatewayVersion = minGatewayVersion;
|
||||
}
|
||||
|
||||
const builtWithOpenClawVersion = getTrimmedString(build?.openclawVersion) ?? version;
|
||||
if (builtWithOpenClawVersion) {
|
||||
compatibility.builtWithOpenClawVersion = builtWithOpenClawVersion;
|
||||
}
|
||||
|
||||
const pluginSdkVersion = getTrimmedString(build?.pluginSdkVersion);
|
||||
if (pluginSdkVersion) {
|
||||
compatibility.pluginSdkVersion = pluginSdkVersion;
|
||||
}
|
||||
|
||||
return Object.keys(compatibility).length > 0 ? compatibility : undefined;
|
||||
}
|
||||
|
||||
export function listMissingOpenClawExternalCodePluginFieldPaths(packageJson: unknown): string[] {
|
||||
const { compat, build } = readOpenClawBlock(packageJson);
|
||||
const missing: string[] = [];
|
||||
if (!getTrimmedString(compat?.pluginApi)) {
|
||||
missing.push("openclaw.compat.pluginApi");
|
||||
}
|
||||
if (!getTrimmedString(build?.openclawVersion)) {
|
||||
missing.push("openclaw.build.openclawVersion");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function validateOpenClawExternalCodePluginPackageJson(
|
||||
packageJson: unknown,
|
||||
): OpenClawExternalCodePluginValidation {
|
||||
const issues = listMissingOpenClawExternalCodePluginFieldPaths(packageJson).map((fieldPath) => ({
|
||||
fieldPath,
|
||||
message: `${fieldPath} is required for external code plugins published to ClawHub.`,
|
||||
}));
|
||||
return {
|
||||
compatibility: normalizeOpenClawExternalPluginCompatibility(packageJson),
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const packageRoot = resolve(import.meta.dirname, "..");
|
||||
const repoRoot = resolve(packageRoot, "..", "..");
|
||||
const binPath = join(packageRoot, "bin", "clawdhub.js");
|
||||
const distCliPath = join(packageRoot, "dist", "cli.js");
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTmpDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function runNode(args: string[]) {
|
||||
return spawnSync("node", args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
function runGit(cwd: string, args: string[]) {
|
||||
const result = spawnSync("git", ["-C", cwd, ...args], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (tempDirs.length > 0) {
|
||||
await rm(tempDirs.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("built CLI artifact", () => {
|
||||
it("runs help from the published bin entrypoint", async () => {
|
||||
const result = runNode([binPath, "--help"]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toContain("ClawHub CLI");
|
||||
});
|
||||
|
||||
it("publishes a local code plugin in dry-run json mode from built output", async () => {
|
||||
const root = await makeTmpDir("clawhub-artifact-");
|
||||
const pluginDir = join(root, "demo-plugin");
|
||||
await mkdir(join(pluginDir, "src"), { recursive: true });
|
||||
await writeFile(
|
||||
join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@openclaw/demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.0.0",
|
||||
openclaw: {
|
||||
compat: {
|
||||
pluginApi: ">=2026.3.24-beta.2",
|
||||
minGatewayVersion: "2026.3.24-beta.2",
|
||||
},
|
||||
build: {
|
||||
openclawVersion: "2026.3.24-beta.2",
|
||||
pluginSdkVersion: "2026.3.24-beta.2",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(pluginDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "demo.plugin",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(join(pluginDir, "src", "index.ts"), "export const demo = true;\n", "utf8");
|
||||
|
||||
runGit(root, ["init"]);
|
||||
runGit(root, ["remote", "add", "origin", "https://github.com/openclaw/demo-plugin.git"]);
|
||||
runGit(root, ["add", "."]);
|
||||
runGit(root, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]);
|
||||
|
||||
const result = runNode([
|
||||
binPath,
|
||||
"package",
|
||||
"publish",
|
||||
pluginDir,
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--registry",
|
||||
"https://clawhub.ai",
|
||||
"--site",
|
||||
"https://clawhub.ai",
|
||||
]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
const output = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
||||
expect(output.name).toBe("@openclaw/demo-plugin");
|
||||
expect(output.family).toBe("code-plugin");
|
||||
expect(output.version).toBe("1.0.0");
|
||||
expect(output.commit).toBeTypeOf("string");
|
||||
});
|
||||
|
||||
it("keeps the built dist free of compiled test files", async () => {
|
||||
expect(dirname(distCliPath)).toBe(join(packageRoot, "dist"));
|
||||
const result = runNode([
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
`import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
const queue = ['${join(packageRoot, "dist").replaceAll("\\", "\\\\")}'];
|
||||
const hits = [];
|
||||
while (queue.length > 0) {
|
||||
const dir = queue.pop();
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) queue.push(path);
|
||||
else if (entry.name.includes('.test.')) hits.push(path);
|
||||
}
|
||||
}
|
||||
if (hits.length > 0) {
|
||||
console.error(hits.join('\\n'));
|
||||
process.exit(1);
|
||||
}`,
|
||||
]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { join } from "node:path";
|
||||
import { vi } from "vitest";
|
||||
import type { GlobalOpts } from "../src/cli/types.js";
|
||||
|
||||
export function makeGlobalOpts(workdir = "/work"): GlobalOpts {
|
||||
return {
|
||||
workdir,
|
||||
dir: join(workdir, "skills"),
|
||||
site: "https://clawhub.ai",
|
||||
registry: "https://clawhub.ai",
|
||||
registrySource: "default",
|
||||
};
|
||||
}
|
||||
|
||||
function buildRegistryUrl(path: string, registry: string) {
|
||||
const base = registry.endsWith("/") ? registry : `${registry}/`;
|
||||
const relative = path.startsWith("/") ? path.slice(1) : path;
|
||||
return new URL(relative, base);
|
||||
}
|
||||
|
||||
export function createHttpModuleMocks() {
|
||||
const apiRequest = vi.fn();
|
||||
const apiRequestForm = vi.fn();
|
||||
const downloadZip = vi.fn();
|
||||
const fetchText = vi.fn();
|
||||
const registryUrl = vi.fn(buildRegistryUrl);
|
||||
|
||||
return {
|
||||
apiRequest,
|
||||
apiRequestForm,
|
||||
downloadZip,
|
||||
fetchText,
|
||||
registryUrl,
|
||||
moduleFactory: () => ({
|
||||
apiRequest: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
apiRequest(registry, args, schema),
|
||||
apiRequestForm: (registry: unknown, args: unknown, schema?: unknown) =>
|
||||
apiRequestForm(registry, args, schema),
|
||||
downloadZip: (registry: unknown, args: unknown) => downloadZip(registry, args),
|
||||
fetchText: (registry: unknown, args: unknown) => fetchText(registry, args),
|
||||
registryUrl: (...args: [string, string]) => registryUrl(...args),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRegistryModuleMocks() {
|
||||
const getRegistry = vi.fn(async (_opts?: unknown, _params?: unknown) => "https://clawhub.ai");
|
||||
|
||||
return {
|
||||
getRegistry,
|
||||
moduleFactory: () => ({
|
||||
getRegistry: (opts: unknown, params?: unknown) => getRegistry(opts, params),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAuthTokenModuleMocks() {
|
||||
const requireAuthToken = vi.fn(async () => "tkn");
|
||||
const getOptionalAuthToken = vi.fn(async () => undefined as string | undefined);
|
||||
|
||||
return {
|
||||
requireAuthToken,
|
||||
getOptionalAuthToken,
|
||||
moduleFactory: () => ({
|
||||
requireAuthToken: () => requireAuthToken(),
|
||||
getOptionalAuthToken: () => getOptionalAuthToken(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createUiModuleMocks(options?: { interactive?: boolean }) {
|
||||
const spinner = {
|
||||
stop: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
succeed: vi.fn(),
|
||||
start: vi.fn(),
|
||||
isSpinning: false,
|
||||
text: "",
|
||||
};
|
||||
const fail = vi.fn((message: string) => {
|
||||
throw new Error(message);
|
||||
});
|
||||
const promptConfirm = vi.fn(async () => true);
|
||||
const interactive = options?.interactive ?? false;
|
||||
|
||||
return {
|
||||
spinner,
|
||||
fail,
|
||||
promptConfirm,
|
||||
moduleFactory: () => ({
|
||||
createSpinner: vi.fn(() => spinner),
|
||||
fail: (message: string) => fail(message),
|
||||
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
isInteractive: () => interactive,
|
||||
promptConfirm,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export function createGlobalStubRegistry() {
|
||||
const restorers: Array<() => void> = [];
|
||||
|
||||
return {
|
||||
stub<K extends keyof typeof globalThis>(name: K, value: (typeof globalThis)[K]) {
|
||||
const original = globalThis[name];
|
||||
restorers.push(() => {
|
||||
if (original === undefined) {
|
||||
Reflect.deleteProperty(globalThis, name);
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: original,
|
||||
});
|
||||
});
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
},
|
||||
restoreAll() {
|
||||
while (restorers.length > 0) {
|
||||
restorers.pop()?.();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createEnvStubRegistry() {
|
||||
const restorers: Array<() => void> = [];
|
||||
|
||||
return {
|
||||
stub(name: string, value: string) {
|
||||
const original = process.env[name];
|
||||
const hadOriginal = Object.prototype.hasOwnProperty.call(process.env, name);
|
||||
restorers.push(() => {
|
||||
if (hadOriginal) {
|
||||
process.env[name] = original;
|
||||
return;
|
||||
}
|
||||
delete process.env[name];
|
||||
});
|
||||
process.env[name] = value;
|
||||
},
|
||||
restoreAll() {
|
||||
while (restorers.length > 0) {
|
||||
restorers.pop()?.();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,5 +10,6 @@
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: false,
|
||||
testTimeout: 30_000,
|
||||
hookTimeout: 30_000,
|
||||
include: ["test-artifact/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: false,
|
||||
testTimeout: 15_000,
|
||||
hookTimeout: 15_000,
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**", "test-artifact/**"],
|
||||
},
|
||||
});
|
||||
Vendored
+1
@@ -1,6 +1,7 @@
|
||||
export type { ArkValidator } from "./ark.js";
|
||||
export { formatArkErrors, parseArk } from "./ark.js";
|
||||
export * from "./license.js";
|
||||
export * from "./openclawContract.js";
|
||||
export * from "./packages.js";
|
||||
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
|
||||
export * from "./schemas.js";
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
export { formatArkErrors, parseArk } from "./ark.js";
|
||||
export * from "./license.js";
|
||||
export * from "./openclawContract.js";
|
||||
export * from "./packages.js";
|
||||
export { ApiRoutes, LegacyApiRoutes } from "./routes.js";
|
||||
export * from "./schemas.js";
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import type { PackageCompatibility } from "./packages.js";
|
||||
export type OpenClawExternalPluginValidationIssue = {
|
||||
fieldPath: string;
|
||||
message: string;
|
||||
};
|
||||
export type OpenClawExternalCodePluginValidation = {
|
||||
compatibility?: PackageCompatibility;
|
||||
issues: OpenClawExternalPluginValidationIssue[];
|
||||
};
|
||||
export declare const OPENCLAW_EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS: readonly ["openclaw.compat.pluginApi", "openclaw.build.openclawVersion"];
|
||||
export declare function normalizeOpenClawExternalPluginCompatibility(packageJson: unknown): PackageCompatibility | undefined;
|
||||
export declare function listMissingOpenClawExternalCodePluginFieldPaths(packageJson: unknown): string[];
|
||||
export declare function validateOpenClawExternalCodePluginPackageJson(packageJson: unknown): OpenClawExternalCodePluginValidation;
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
export const OPENCLAW_EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS = [
|
||||
"openclaw.compat.pluginApi",
|
||||
"openclaw.build.openclawVersion",
|
||||
];
|
||||
function isRecord(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function getTrimmedString(value) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
function readOpenClawBlock(packageJson) {
|
||||
const root = isRecord(packageJson) ? packageJson : undefined;
|
||||
const openclaw = isRecord(root?.openclaw) ? root.openclaw : undefined;
|
||||
const compat = isRecord(openclaw?.compat) ? openclaw.compat : undefined;
|
||||
const build = isRecord(openclaw?.build) ? openclaw.build : undefined;
|
||||
const install = isRecord(openclaw?.install) ? openclaw.install : undefined;
|
||||
return { root, compat, build, install };
|
||||
}
|
||||
export function normalizeOpenClawExternalPluginCompatibility(packageJson) {
|
||||
const { root, compat, build, install } = readOpenClawBlock(packageJson);
|
||||
const version = getTrimmedString(root?.version);
|
||||
const minHostVersion = getTrimmedString(install?.minHostVersion);
|
||||
const compatibility = {};
|
||||
const pluginApi = getTrimmedString(compat?.pluginApi);
|
||||
if (pluginApi) {
|
||||
compatibility.pluginApiRange = pluginApi;
|
||||
}
|
||||
const minGatewayVersion = getTrimmedString(compat?.minGatewayVersion) ?? minHostVersion;
|
||||
if (minGatewayVersion) {
|
||||
compatibility.minGatewayVersion = minGatewayVersion;
|
||||
}
|
||||
const builtWithOpenClawVersion = getTrimmedString(build?.openclawVersion) ?? version;
|
||||
if (builtWithOpenClawVersion) {
|
||||
compatibility.builtWithOpenClawVersion = builtWithOpenClawVersion;
|
||||
}
|
||||
const pluginSdkVersion = getTrimmedString(build?.pluginSdkVersion);
|
||||
if (pluginSdkVersion) {
|
||||
compatibility.pluginSdkVersion = pluginSdkVersion;
|
||||
}
|
||||
return Object.keys(compatibility).length > 0 ? compatibility : undefined;
|
||||
}
|
||||
export function listMissingOpenClawExternalCodePluginFieldPaths(packageJson) {
|
||||
const { compat, build } = readOpenClawBlock(packageJson);
|
||||
const missing = [];
|
||||
if (!getTrimmedString(compat?.pluginApi)) {
|
||||
missing.push("openclaw.compat.pluginApi");
|
||||
}
|
||||
if (!getTrimmedString(build?.openclawVersion)) {
|
||||
missing.push("openclaw.build.openclawVersion");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
export function validateOpenClawExternalCodePluginPackageJson(packageJson) {
|
||||
const issues = listMissingOpenClawExternalCodePluginFieldPaths(packageJson).map((fieldPath) => ({
|
||||
fieldPath,
|
||||
message: `${fieldPath} is required for external code plugins published to ClawHub.`,
|
||||
}));
|
||||
return {
|
||||
compatibility: normalizeOpenClawExternalPluginCompatibility(packageJson),
|
||||
issues,
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=openclawContract.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"openclawContract.js","sourceRoot":"","sources":["../src/openclawContract.ts"],"names":[],"mappings":"AAcA,MAAM,CAAC,MAAM,kDAAkD,GAAG;IAChE,2BAA2B;IAC3B,gCAAgC;CACxB,CAAC;AAEX,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,CAAC,KAAK,CAAC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;AAC9E,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAc;IACtC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;AAC9E,CAAC;AAED,SAAS,iBAAiB,CAAC,WAAoB;IAC7C,MAAM,IAAI,GAAG,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC;IAC7D,MAAM,QAAQ,GAAG,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC;IACtE,MAAM,MAAM,GAAG,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC;IACxE,MAAM,KAAK,GAAG,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;IACrE,MAAM,OAAO,GAAG,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;IAC3E,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC;AAC1C,CAAC;AAED,MAAM,UAAU,4CAA4C,CAC1D,WAAoB;IAEpB,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;IACxE,MAAM,OAAO,GAAG,gBAAgB,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IAChD,MAAM,cAAc,GAAG,gBAAgB,CAAC,OAAO,EAAE,cAAc,CAAC,CAAC;IACjE,MAAM,aAAa,GAAyB,EAAE,CAAC;IAE/C,MAAM,SAAS,GAAG,gBAAgB,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IACtD,IAAI,SAAS,EAAE,CAAC;QACd,aAAa,CAAC,cAAc,GAAG,SAAS,CAAC;IAC3C,CAAC;IAED,MAAM,iBAAiB,GAAG,gBAAgB,CAAC,MAAM,EAAE,iBAAiB,CAAC,IAAI,cAAc,CAAC;IACxF,IAAI,iBAAiB,EAAE,CAAC;QACtB,aAAa,CAAC,iBAAiB,GAAG,iBAAiB,CAAC;IACtD,CAAC;IAED,MAAM,wBAAwB,GAAG,gBAAgB,CAAC,KAAK,EAAE,eAAe,CAAC,IAAI,OAAO,CAAC;IACrF,IAAI,wBAAwB,EAAE,CAAC;QAC7B,aAAa,CAAC,wBAAwB,GAAG,wBAAwB,CAAC;IACpE,CAAC;IAED,MAAM,gBAAgB,GAAG,gBAAgB,CAAC,KAAK,EAAE,gBAAgB,CAAC,CAAC;IACnE,IAAI,gBAAgB,EAAE,CAAC;QACrB,aAAa,CAAC,gBAAgB,GAAG,gBAAgB,CAAC;IACpD,CAAC;IAED,OAAO,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,SAAS,CAAC;AAC3E,CAAC;AAED,MAAM,UAAU,+CAA+C,CAAC,WAAoB;IAClF,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;IACzD,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,IAAI,CAAC,gBAAgB,CAAC,MAAM,EAAE,SAAS,CAAC,EAAE,CAAC;QACzC,OAAO,CAAC,IAAI,CAAC,2BAA2B,CAAC,CAAC;IAC5C,CAAC;IACD,IAAI,CAAC,gBAAgB,CAAC,KAAK,EAAE,eAAe,CAAC,EAAE,CAAC;QAC9C,OAAO,CAAC,IAAI,CAAC,gCAAgC,CAAC,CAAC;IACjD,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,MAAM,UAAU,6CAA6C,CAC3D,WAAoB;IAEpB,MAAM,MAAM,GAAG,+CAA+C,CAAC,WAAW,CAAC,CAAC,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;QAC9F,SAAS;QACT,OAAO,EAAE,GAAG,SAAS,8DAA8D;KACpF,CAAC,CAAC,CAAC;IACJ,OAAO;QACL,aAAa,EAAE,4CAA4C,CAAC,WAAW,CAAC;QACxE,MAAM;KACP,CAAC;AACJ,CAAC"}
|
||||
Vendored
+6
@@ -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];
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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/**",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user