From a8a687eba25d8a681d1d34d919e2bb23c97ce8aa Mon Sep 17 00:00:00 2001 From: George Zhang Date: Sun, 29 Mar 2026 02:37:58 -0700 Subject: [PATCH] [codex] streamline plugin publish flow (#1373) * fix: streamline plugin publish flow * fix: stabilize github package publish e2e * test: fix clawdhub ci portability * test: make settings route assertion portable * test: fix ci typechecks --- .github/workflows/ci.yml | 3 + .github/workflows/package-publish.yml | 207 ++++++ CONTRIBUTING.md | 12 + README.md | 4 +- clawdhub | 5 +- clawhub | 5 +- convex/lib/packageRegistry.ts | 51 +- docs/cli.md | 58 +- docs/github-import.md | 18 + docs/manual-testing.md | 4 +- docs/quickstart.md | 2 +- docs/troubleshooting.md | 2 +- e2e/clawdhub.e2e.test.ts | 118 +++- e2e/prod-http-smoke.e2e.test.ts | 2 +- packages/clawdhub/README.md | 29 +- packages/clawdhub/package.json | 9 +- packages/clawdhub/scripts/build.mjs | 19 + packages/clawdhub/src/cli.ts | 27 +- .../clawdhub/src/cli/commands/auth.test.ts | 23 +- .../clawdhub/src/cli/commands/delete.test.ts | 80 +-- .../clawdhub/src/cli/commands/github.test.ts | 244 +++++++ packages/clawdhub/src/cli/commands/github.ts | 365 +++++++++++ .../clawdhub/src/cli/commands/inspect.test.ts | 86 +-- .../src/cli/commands/moderation.test.ts | 112 ++-- .../src/cli/commands/ownership.test.ts | 70 +- .../src/cli/commands/packages.test.ts | 599 +++++++++++++++--- .../clawdhub/src/cli/commands/packages.ts | 427 ++++++++++--- .../clawdhub/src/cli/commands/publish.test.ts | 86 +-- packages/clawdhub/src/cli/commands/publish.ts | 28 +- .../clawdhub/src/cli/commands/skills.test.ts | 143 +++-- .../clawdhub/src/cli/commands/sync.test.ts | 87 +-- .../src/cli/commands/transfer.test.ts | 86 +-- packages/clawdhub/src/config.test.ts | 58 +- packages/clawdhub/src/discovery.test.ts | 15 +- packages/clawdhub/src/http.bun.test.ts | 334 ++++------ packages/clawdhub/src/http.test.ts | 365 +++++------ packages/clawdhub/src/http.ts | 507 +++++++++------ packages/clawdhub/src/schema/index.ts | 1 + .../clawdhub/src/schema/openclawContract.ts | 91 +++ .../test-artifact/cli.artifact.test.ts | 145 +++++ packages/clawdhub/test/cliCommandTestKit.ts | 98 +++ packages/clawdhub/test/runtimeStubs.ts | 54 ++ packages/clawdhub/tsconfig.json | 3 +- packages/clawdhub/vitest.artifact.config.ts | 12 + packages/clawdhub/vitest.config.ts | 12 + packages/schema/dist/index.d.ts | 1 + packages/schema/dist/index.js | 1 + packages/schema/dist/index.js.map | 2 +- packages/schema/dist/openclawContract.d.ts | 13 + packages/schema/dist/openclawContract.js | 63 ++ packages/schema/dist/openclawContract.js.map | 1 + packages/schema/dist/packages.d.ts | 6 + packages/schema/dist/packages.js.map | 2 +- packages/schema/src/index.ts | 1 + packages/schema/src/openclawContract.ts | 91 +++ packages/schema/src/packages.ts | 7 + src/__tests__/packages-publish-route.test.tsx | 150 ++++- src/components/PackageSourceChooser.tsx | 159 +++++ src/lib/pluginPublishPrefill.ts | 153 +++++ src/routes/publish-plugin.tsx | 306 ++------- src/routes/settings.test.tsx | 2 +- vitest.config.ts | 7 +- 62 files changed, 4031 insertions(+), 1640 deletions(-) create mode 100644 .github/workflows/package-publish.yml create mode 100644 packages/clawdhub/scripts/build.mjs create mode 100644 packages/clawdhub/src/cli/commands/github.test.ts create mode 100644 packages/clawdhub/src/cli/commands/github.ts create mode 100644 packages/clawdhub/src/schema/openclawContract.ts create mode 100644 packages/clawdhub/test-artifact/cli.artifact.test.ts create mode 100644 packages/clawdhub/test/cliCommandTestKit.ts create mode 100644 packages/clawdhub/test/runtimeStubs.ts create mode 100644 packages/clawdhub/vitest.artifact.config.ts create mode 100644 packages/clawdhub/vitest.config.ts create mode 100644 packages/schema/dist/openclawContract.d.ts create mode 100644 packages/schema/dist/openclawContract.js create mode 100644 packages/schema/dist/openclawContract.js.map create mode 100644 packages/schema/src/openclawContract.ts create mode 100644 src/components/PackageSourceChooser.tsx create mode 100644 src/lib/pluginPublishPrefill.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecfb22f3..815063b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/package-publish.yml b/.github/workflows/package-publish.yml new file mode 100644 index 00000000..04126440 --- /dev/null +++ b/.github/workflows/package-publish.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fcfa2842..fdb7e4d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 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`). diff --git a/README.md b/README.md index ca6e0404..7ed32322 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ Common CLI flows: - Browse unified catalog (skills + plugins): `clawhub package explore`, `clawhub package inspect ` - Manage local installs: `clawhub install `, `clawhub uninstall `, `clawhub list`, `clawhub update --all` - Inspect without installing: `clawhub inspect ` -- Publish/sync: `clawhub publish `, `clawhub sync` -- Publish plugins: `clawhub package publish [--owner ] --source-repo --source-commit ` +- Publish/sync skills: `clawhub skill publish `, `clawhub sync` +- Publish plugins: `clawhub package publish ` - Canonicalize owned skills: `clawhub skill rename `, `clawhub skill merge ` Docs: [`docs/quickstart.md`](docs/quickstart.md), [`docs/cli.md`](docs/cli.md). diff --git a/clawdhub b/clawdhub index 9832a79b..bc8bbde3 100755 --- a/clawdhub +++ b/clawdhub @@ -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) diff --git a/clawhub b/clawhub index 9832a79b..bc8bbde3 100755 --- a/clawhub +++ b/clawhub @@ -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) diff --git a/convex/lib/packageRegistry.ts b/convex/lib/packageRegistry.ts index e9bdc8f0..a9ac7986 100644 --- a/convex/lib/packageRegistry.ts +++ b/convex/lib/packageRegistry.ts @@ -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()) || diff --git a/docs/cli.md b/docs/cli.md index ab8c963f..e6aaa2b2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -132,12 +132,13 @@ Stores your API token + cached registry URL. - refuses by default - overwrites with `--force` (or prompt, if interactive) -### `publish ` +### `skill publish ` - 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 `. ### `delete ` @@ -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 ` +### `package publish ` - Publishes a code plugin or bundle plugin via `POST /api/v1/packages`. +- `` 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 ` 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` diff --git a/docs/github-import.md b/docs/github-import.md index 1380f668..283224d6 100644 --- a/docs/github-import.md +++ b/docs/github-import.md @@ -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). diff --git a/docs/manual-testing.md b/docs/manual-testing.md index 2f02720e..065100bb 100644 --- a/docs/manual-testing.md +++ b/docs/manual-testing.md @@ -47,9 +47,9 @@ read_when: - `SKILL.md` - `notes.md` - Publish: - - `bun clawhub publish . --slug clawhub-manual- --name "Manual " --version 1.0.0 --tags latest` + - `bun clawhub skill publish . --slug clawhub-manual- --name "Manual " --version 1.0.0 --tags latest` - Publish update with empty changelog: - - `bun clawhub publish . --slug clawhub-manual- --name "Manual " --version 1.0.1 --tags latest` + - `bun clawhub skill publish . --slug clawhub-manual- --name "Manual " --version 1.0.1 --tags latest` ## Delete / undelete (owner/admin) diff --git a/docs/quickstart.md b/docs/quickstart.md index 6ecfc62f..06ef8e97 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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 \ diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8d96334b..4a741938 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -77,7 +77,7 @@ clawhub sync --root /path/to/skills - Options: - keep local edits; skip updating - overwrite: `clawhub update --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 diff --git a/e2e/clawdhub.e2e.test.ts b/e2e/clawdhub.e2e.test.ts index bf5a464d..58f92efa 100644 --- a/e2e/clawdhub.e2e.test.ts +++ b/e2e/clawdhub.e2e.test.ts @@ -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; + 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(//); + 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", }, ); diff --git a/e2e/prod-http-smoke.e2e.test.ts b/e2e/prod-http-smoke.e2e.test.ts index 4ab688d7..268242b7 100644 --- a/e2e/prod-http-smoke.e2e.test.ts +++ b/e2e/prod-http-smoke.e2e.test.ts @@ -69,7 +69,7 @@ describe("prod http smoke", () => { expect(html).toContain("ClawHub"); expect(html).toContain('href="/skills"'); - expect(html).toContain('href="/upload"'); + expect(html).toContain('href="/publish-skill"'); expect(html).not.toContain("Something went wrong!"); }); diff --git a/packages/clawdhub/README.md b/packages/clawdhub/README.md index 6f4153b6..ad8cb119 100644 --- a/packages/clawdhub/README.md +++ b/packages/clawdhub/README.md @@ -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 diff --git a/packages/clawdhub/package.json b/packages/clawdhub/package.json index c752a831..f13d9924 100644 --- a/packages/clawdhub/package.json +++ b/packages/clawdhub/package.json @@ -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", diff --git a/packages/clawdhub/scripts/build.mjs b/packages/clawdhub/scripts/build.mjs new file mode 100644 index 00000000..61b0e693 --- /dev/null +++ b/packages/clawdhub/scripts/build.mjs @@ -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); diff --git a/packages/clawdhub/src/cli.ts b/packages/clawdhub/src/cli.ts index f7e2e98c..5ecbf4b4 100644 --- a/packages/clawdhub/src/cli.ts +++ b/packages/clawdhub/src/cli.ts @@ -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 diff --git a/packages/clawdhub/src/cli/commands/auth.test.ts b/packages/clawdhub/src/cli/commands/auth.test.ts index fafd47b4..749fa530 100644 --- a/packages/clawdhub/src/cli/commands/auth.test.ts +++ b/packages/clawdhub/src/cli/commands/auth.test.ts @@ -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({ diff --git a/packages/clawdhub/src/cli/commands/delete.test.ts b/packages/clawdhub/src/cli/commands/delete.test.ts index 5e277b5a..209df74b 100644 --- a/packages/clawdhub/src/cli/commands/delete.test.ts +++ b/packages/clawdhub/src/cli/commands/delete.test.ts @@ -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(), diff --git a/packages/clawdhub/src/cli/commands/github.test.ts b/packages/clawdhub/src/cli/commands/github.test.ts new file mode 100644 index 00000000..e74b7e7e --- /dev/null +++ b/packages/clawdhub/src/cli/commands/github.test.ts @@ -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, + }); + } + }); +}); diff --git a/packages/clawdhub/src/cli/commands/github.ts b/packages/clawdhub/src/cli/commands/github.ts new file mode 100644 index 00000000..d7ec81ce --- /dev/null +++ b/packages/clawdhub/src/cli/commands/github.ts @@ -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; +} diff --git a/packages/clawdhub/src/cli/commands/inspect.test.ts b/packages/clawdhub/src/cli/commands/inspect.test.ts index 99d871e8..88d45fb1 100644 --- a/packages/clawdhub/src/cli/commands/inspect.test.ts +++ b/packages/clawdhub/src/cli/commands/inspect.test.ts @@ -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"); }); }); diff --git a/packages/clawdhub/src/cli/commands/moderation.test.ts b/packages/clawdhub/src/cli/commands/moderation.test.ts index 180de09f..fbc19d6d 100644 --- a/packages/clawdhub/src/cli/commands/moderation.test.ts +++ b/packages/clawdhub/src/cli/commands/moderation.test.ts @@ -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", diff --git a/packages/clawdhub/src/cli/commands/ownership.test.ts b/packages/clawdhub/src/cli/commands/ownership.test.ts index 62bf0028..c4642d6d 100644 --- a/packages/clawdhub/src/cli/commands/ownership.test.ts +++ b/packages/clawdhub/src/cli/commands/ownership.test.ts @@ -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"'); }); }); diff --git a/packages/clawdhub/src/cli/commands/packages.test.ts b/packages/clawdhub/src/cli/commands/packages.test.ts index a7511cd1..86556951 100644 --- a/packages/clawdhub/src/cli/commands/packages.test.ts +++ b/packages/clawdhub/src/cli/commands/packages.test.ts @@ -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 }); } diff --git a/packages/clawdhub/src/cli/commands/packages.ts b/packages/clawdhub/src/cli/commands/packages.ts index a72741b3..ca554b11 100644 --- a/packages/clawdhub/src/cli/commands/packages.ts +++ b/packages/clawdhub/src/cli/commands/packages.ts @@ -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); diff --git a/packages/clawdhub/src/cli/commands/publish.test.ts b/packages/clawdhub/src/cli/commands/publish.test.ts index d7e2a72f..e948aa7e 100644 --- a/packages/clawdhub/src/cli/commands/publish.test.ts +++ b/packages/clawdhub/src/cli/commands/publish.test.ts @@ -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 }); + } + }); }); diff --git a/packages/clawdhub/src/cli/commands/publish.ts b/packages/clawdhub/src/cli/commands/publish.ts index d71bdefd..f8d91390 100644 --- a/packages/clawdhub/src/cli/commands/publish.ts +++ b/packages/clawdhub/src/cli/commands/publish.ts @@ -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("@"); diff --git a/packages/clawdhub/src/cli/commands/skills.test.ts b/packages/clawdhub/src/cli/commands/skills.test.ts index c1217f30..4d27ad78 100644 --- a/packages/clawdhub/src/cli/commands/skills.test.ts +++ b/packages/clawdhub/src/cli/commands/skills.test.ts @@ -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], ); }); diff --git a/packages/clawdhub/src/cli/commands/sync.test.ts b/packages/clawdhub/src/cli/commands/sync.test.ts index 5e813e45..6788e048 100644 --- a/packages/clawdhub/src/cli/commands/sync.test.ts +++ b/packages/clawdhub/src/cli/commands/sync.test.ts @@ -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" }]; } diff --git a/packages/clawdhub/src/cli/commands/transfer.test.ts b/packages/clawdhub/src/cli/commands/transfer.test.ts index 33583c52..2addf78e 100644 --- a/packages/clawdhub/src/cli/commands/transfer.test.ts +++ b/packages/clawdhub/src/cli/commands/transfer.test.ts @@ -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(), diff --git a/packages/clawdhub/src/config.test.ts b/packages/clawdhub/src/config.test.ts index 11e40e9e..6eeb1992 100644 --- a/packages/clawdhub/src/config.test.ts +++ b/packages/clawdhub/src/config.test.ts @@ -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"); }); diff --git a/packages/clawdhub/src/discovery.test.ts b/packages/clawdhub/src/discovery.test.ts index 5266b3fe..4e26b4ac 100644 --- a/packages/clawdhub/src/discovery.test.ts +++ b/packages/clawdhub/src/discovery.test.ts @@ -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 () => diff --git a/packages/clawdhub/src/http.bun.test.ts b/packages/clawdhub/src/http.bun.test.ts index 1ce91d1b..ce2c1d08 100644 --- a/packages/clawdhub/src/http.bun.test.ts +++ b/packages/clawdhub/src/http.bun.test.ts @@ -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, + ); }); }); diff --git a/packages/clawdhub/src/http.test.ts b/packages/clawdhub/src/http.test.ts index 6fd0afa1..df823ff4 100644 --- a/packages/clawdhub/src/http.test.ts +++ b/packages/clawdhub/src/http.test.ts @@ -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); }); }); diff --git a/packages/clawdhub/src/http.ts b/packages/clawdhub/src/http.ts index 8c8d828e..8768749a 100644 --- a/packages/clawdhub/src/http.ts +++ b/packages/clawdhub/src/http.ts @@ -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; } diff --git a/packages/clawdhub/src/schema/index.ts b/packages/clawdhub/src/schema/index.ts index bcf4dc55..85c8d3e8 100644 --- a/packages/clawdhub/src/schema/index.ts +++ b/packages/clawdhub/src/schema/index.ts @@ -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"; diff --git a/packages/clawdhub/src/schema/openclawContract.ts b/packages/clawdhub/src/schema/openclawContract.ts new file mode 100644 index 00000000..581ebe55 --- /dev/null +++ b/packages/clawdhub/src/schema/openclawContract.ts @@ -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, + }; +} diff --git a/packages/clawdhub/test-artifact/cli.artifact.test.ts b/packages/clawdhub/test-artifact/cli.artifact.test.ts new file mode 100644 index 00000000..823f43ad --- /dev/null +++ b/packages/clawdhub/test-artifact/cli.artifact.test.ts @@ -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(""); + }); +}); diff --git a/packages/clawdhub/test/cliCommandTestKit.ts b/packages/clawdhub/test/cliCommandTestKit.ts new file mode 100644 index 00000000..d49773a0 --- /dev/null +++ b/packages/clawdhub/test/cliCommandTestKit.ts @@ -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, + }), + }; +} diff --git a/packages/clawdhub/test/runtimeStubs.ts b/packages/clawdhub/test/runtimeStubs.ts new file mode 100644 index 00000000..dc359a7a --- /dev/null +++ b/packages/clawdhub/test/runtimeStubs.ts @@ -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()?.(); + } + }, + }; +} diff --git a/packages/clawdhub/tsconfig.json b/packages/clawdhub/tsconfig.json index 3cad0d99..25581cbe 100644 --- a/packages/clawdhub/tsconfig.json +++ b/packages/clawdhub/tsconfig.json @@ -10,5 +10,6 @@ "sourceMap": true, "skipLibCheck": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] } diff --git a/packages/clawdhub/vitest.artifact.config.ts b/packages/clawdhub/vitest.artifact.config.ts new file mode 100644 index 00000000..bf44a678 --- /dev/null +++ b/packages/clawdhub/vitest.artifact.config.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/**"], + }, +}); diff --git a/packages/clawdhub/vitest.config.ts b/packages/clawdhub/vitest.config.ts new file mode 100644 index 00000000..04b54e23 --- /dev/null +++ b/packages/clawdhub/vitest.config.ts @@ -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/**"], + }, +}); diff --git a/packages/schema/dist/index.d.ts b/packages/schema/dist/index.d.ts index a644e96d..c40af08e 100644 --- a/packages/schema/dist/index.d.ts +++ b/packages/schema/dist/index.d.ts @@ -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"; diff --git a/packages/schema/dist/index.js b/packages/schema/dist/index.js index 3f34e2ad..e1758269 100644 --- a/packages/schema/dist/index.js +++ b/packages/schema/dist/index.js @@ -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"; diff --git a/packages/schema/dist/index.js.map b/packages/schema/dist/index.js.map index 786b0a59..a2517162 100644 --- a/packages/schema/dist/index.js.map +++ b/packages/schema/dist/index.js.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/packages/schema/dist/openclawContract.d.ts b/packages/schema/dist/openclawContract.d.ts new file mode 100644 index 00000000..c232f0cd --- /dev/null +++ b/packages/schema/dist/openclawContract.d.ts @@ -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; diff --git a/packages/schema/dist/openclawContract.js b/packages/schema/dist/openclawContract.js new file mode 100644 index 00000000..d68f8403 --- /dev/null +++ b/packages/schema/dist/openclawContract.js @@ -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 \ No newline at end of file diff --git a/packages/schema/dist/openclawContract.js.map b/packages/schema/dist/openclawContract.js.map new file mode 100644 index 00000000..cff42250 --- /dev/null +++ b/packages/schema/dist/openclawContract.js.map @@ -0,0 +1 @@ +{"version":3,"file":"openclawContract.js","sourceRoot":"","sources":["../src/openclawContract.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/schema/dist/packages.d.ts b/packages/schema/dist/packages.d.ts index 31cb4f3b..e82dfd53 100644 --- a/packages/schema/dist/packages.d.ts +++ b/packages/schema/dist/packages.d.ts @@ -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]; diff --git a/packages/schema/dist/packages.js.map b/packages/schema/dist/packages.js.map index fe896794..c4a4e4ac 100644 --- a/packages/schema/dist/packages.js.map +++ b/packages/schema/dist/packages.js.map @@ -1 +1 @@ -{"version":3,"file":"packages.js","sourceRoot":"","sources":["../src/packages.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"packages.js","sourceRoot":"","sources":["../src/packages.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index a644e96d..c40af08e 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -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"; diff --git a/packages/schema/src/openclawContract.ts b/packages/schema/src/openclawContract.ts new file mode 100644 index 00000000..581ebe55 --- /dev/null +++ b/packages/schema/src/openclawContract.ts @@ -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, + }; +} diff --git a/packages/schema/src/packages.ts b/packages/schema/src/packages.ts index 5600953e..24f6c3a4 100644 --- a/packages/schema/src/packages.ts +++ b/packages/schema/src/packages.ts @@ -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]; diff --git a/src/__tests__/packages-publish-route.test.tsx b/src/__tests__/packages-publish-route.test.tsx index 46e52b19..3adc53ca 100644 --- a/src/__tests__/packages-publish-route.test.tsx +++ b/src/__tests__/packages-publish-route.test.tsx @@ -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", diff --git a/src/components/PackageSourceChooser.tsx b/src/components/PackageSourceChooser.tsx new file mode 100644 index 00000000..e118b0bd --- /dev/null +++ b/src/components/PackageSourceChooser.tsx @@ -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> + ); +} diff --git a/src/lib/pluginPublishPrefill.ts b/src/lib/pluginPublishPrefill.ts new file mode 100644 index 00000000..64ff0b62 --- /dev/null +++ b/src/lib/pluginPublishPrefill.ts @@ -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(', '); +} diff --git a/src/routes/publish-plugin.tsx b/src/routes/publish-plugin.tsx index 2deb53e8..1b901592 100644 --- a/src/routes/publish-plugin.tsx +++ b/src/routes/publish-plugin.tsx @@ -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; -} diff --git a/src/routes/settings.test.tsx b/src/routes/settings.test.tsx index 77521453..f5118405 100644 --- a/src/routes/settings.test.tsx +++ b/src/routes/settings.test.tsx @@ -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); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index d0540a36..1b27e803 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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/**", ],