Plugins: add Claude marketplace registry installs (#48058)
* Changelog: note Claude marketplace plugin support * Plugins: add Claude marketplace installs * E2E: cover marketplace plugin installs in Docker
This commit is contained in:
parent
9ee0fb52e9
commit
ff2e864c98
@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode.
|
- Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode.
|
||||||
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873)
|
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873)
|
||||||
- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819)
|
- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819)
|
||||||
|
- Plugins/marketplaces: add Claude marketplace registry resolution, `plugin@marketplace` installs, marketplace listing, and update support, plus Docker E2E coverage for local and official marketplace flows. Thanks @vincentkoc.
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
|
|
||||||
|
|||||||
@ -1,38 +1,44 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
FROM node:24-bookworm@sha256:9f3b13503acdf9bc1e0213ccb25ebe86ac881cad17636733a1da1be1d44509df
|
FROM node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
WORKDIR /app
|
RUN useradd --create-home --shell /bin/bash appuser \
|
||||||
|
&& mkdir -p /app \
|
||||||
|
&& chown appuser:appuser /app
|
||||||
|
|
||||||
|
ENV HOME="/home/appuser"
|
||||||
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
|
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
USER appuser
|
||||||
COPY ui/package.json ./ui/package.json
|
WORKDIR /app
|
||||||
COPY extensions/memory-core/package.json ./extensions/memory-core/package.json
|
|
||||||
COPY patches ./patches
|
|
||||||
|
|
||||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY --chown=appuser:appuser ui/package.json ./ui/package.json
|
||||||
|
COPY --chown=appuser:appuser extensions/memory-core/package.json ./extensions/memory-core/package.json
|
||||||
|
COPY --chown=appuser:appuser patches ./patches
|
||||||
|
|
||||||
|
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./
|
COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./
|
||||||
COPY src ./src
|
COPY --chown=appuser:appuser src ./src
|
||||||
COPY test ./test
|
COPY --chown=appuser:appuser test ./test
|
||||||
COPY scripts ./scripts
|
COPY --chown=appuser:appuser scripts ./scripts
|
||||||
COPY docs ./docs
|
COPY --chown=appuser:appuser docs ./docs
|
||||||
COPY skills ./skills
|
COPY --chown=appuser:appuser skills ./skills
|
||||||
COPY ui ./ui
|
COPY --chown=appuser:appuser ui ./ui
|
||||||
COPY extensions/memory-core ./extensions/memory-core
|
COPY --chown=appuser:appuser extensions ./extensions
|
||||||
COPY vendor/a2ui/renderers/lit ./vendor/a2ui/renderers/lit
|
COPY --chown=appuser:appuser vendor/a2ui/renderers/lit ./vendor/a2ui/renderers/lit
|
||||||
COPY apps/shared/OpenClawKit/Sources/OpenClawKit/Resources ./apps/shared/OpenClawKit/Sources/OpenClawKit/Resources
|
COPY --chown=appuser:appuser apps/shared/OpenClawKit/Sources/OpenClawKit/Resources ./apps/shared/OpenClawKit/Sources/OpenClawKit/Resources
|
||||||
COPY apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI
|
COPY --chown=appuser:appuser apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
RUN pnpm ui:build
|
RUN pnpm ui:build
|
||||||
|
|
||||||
RUN useradd --create-home --shell /bin/bash appuser \
|
|
||||||
&& chown -R appuser:appuser /app
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
CMD ["bash"]
|
CMD ["bash"]
|
||||||
|
|||||||
@ -1,23 +1,26 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
FROM node:24-bookworm@sha256:9f3b13503acdf9bc1e0213ccb25ebe86ac881cad17636733a1da1be1d44509df
|
FROM node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b
|
||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
|
RUN useradd --create-home --shell /bin/bash appuser \
|
||||||
|
&& mkdir -p /app \
|
||||||
|
&& chown appuser:appuser /app
|
||||||
|
|
||||||
|
ENV HOME="/home/appuser"
|
||||||
|
|
||||||
|
USER appuser
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
COPY ui/package.json ./ui/package.json
|
COPY --chown=appuser:appuser ui/package.json ./ui/package.json
|
||||||
COPY patches ./patches
|
COPY --chown=appuser:appuser patches ./patches
|
||||||
|
|
||||||
# This image only exercises the root qrcode-terminal dependency path.
|
# This image only exercises the root qrcode-terminal dependency path.
|
||||||
# Keep the pre-install copy set limited to the manifests needed for root
|
# Keep the pre-install copy set limited to the manifests needed for root
|
||||||
# workspace resolution so unrelated extension edits do not bust the layer.
|
# workspace resolution so unrelated extension edits do not bust the layer.
|
||||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY --chown=appuser:appuser . .
|
||||||
|
|
||||||
RUN useradd --create-home --shell /bin/bash appuser \
|
|
||||||
&& chown -R appuser:appuser /app
|
|
||||||
USER appuser
|
|
||||||
|
|||||||
@ -8,24 +8,69 @@ echo "Building Docker image..."
|
|||||||
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||||
|
|
||||||
echo "Running plugins Docker E2E..."
|
echo "Running plugins Docker E2E..."
|
||||||
docker run --rm -t "$IMAGE_NAME" bash -lc '
|
docker run --rm -i "$IMAGE_NAME" bash -s <<'EOF'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [ -f dist/index.mjs ]; then
|
|
||||||
OPENCLAW_ENTRY="dist/index.mjs"
|
|
||||||
elif [ -f dist/index.js ]; then
|
|
||||||
OPENCLAW_ENTRY="dist/index.js"
|
|
||||||
else
|
|
||||||
echo "Missing dist/index.(m)js (build output):"
|
|
||||||
ls -la dist || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
export OPENCLAW_ENTRY
|
|
||||||
|
|
||||||
home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX")
|
if [ -f dist/index.mjs ]; then
|
||||||
export HOME="$home_dir"
|
OPENCLAW_ENTRY="dist/index.mjs"
|
||||||
mkdir -p "$HOME/.openclaw/extensions/demo-plugin"
|
elif [ -f dist/index.js ]; then
|
||||||
|
OPENCLAW_ENTRY="dist/index.js"
|
||||||
|
else
|
||||||
|
echo "Missing dist/index.(m)js (build output):"
|
||||||
|
ls -la dist || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export OPENCLAW_ENTRY
|
||||||
|
|
||||||
cat > "$HOME/.openclaw/extensions/demo-plugin/index.js" <<'"'"'JS'"'"'
|
home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX")
|
||||||
|
export HOME="$home_dir"
|
||||||
|
|
||||||
|
write_fixture_plugin() {
|
||||||
|
local dir="$1"
|
||||||
|
local id="$2"
|
||||||
|
local version="$3"
|
||||||
|
local method="$4"
|
||||||
|
local name="$5"
|
||||||
|
|
||||||
|
mkdir -p "$dir"
|
||||||
|
cat > "$dir/package.json" <<JSON
|
||||||
|
{
|
||||||
|
"name": "@openclaw/$id",
|
||||||
|
"version": "$version",
|
||||||
|
"openclaw": { "extensions": ["./index.js"] }
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
cat > "$dir/index.js" <<JS
|
||||||
|
module.exports = {
|
||||||
|
id: "$id",
|
||||||
|
name: "$name",
|
||||||
|
register(api) {
|
||||||
|
api.registerGatewayMethod("$method", async () => ({ ok: true }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
JS
|
||||||
|
cat > "$dir/openclaw.plugin.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"id": "placeholder",
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
node - <<'NODE' "$dir/openclaw.plugin.json" "$id"
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const file = process.argv[2];
|
||||||
|
const id = process.argv[3];
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
parsed.id = id;
|
||||||
|
fs.writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$HOME/.openclaw/extensions/demo-plugin"
|
||||||
|
|
||||||
|
cat > "$HOME/.openclaw/extensions/demo-plugin/index.js" <<'JS'
|
||||||
module.exports = {
|
module.exports = {
|
||||||
id: "demo-plugin",
|
id: "demo-plugin",
|
||||||
name: "Demo Plugin",
|
name: "Demo Plugin",
|
||||||
@ -38,7 +83,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
JS
|
JS
|
||||||
cat > "$HOME/.openclaw/extensions/demo-plugin/openclaw.plugin.json" <<'"'"'JSON'"'"'
|
cat > "$HOME/.openclaw/extensions/demo-plugin/openclaw.plugin.json" <<'JSON'
|
||||||
{
|
{
|
||||||
"id": "demo-plugin",
|
"id": "demo-plugin",
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
@ -48,9 +93,9 @@ JS
|
|||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
|
|
||||||
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins.json
|
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins.json
|
||||||
|
|
||||||
node - <<'"'"'NODE'"'"'
|
node - <<'NODE'
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
|
|
||||||
const data = JSON.parse(fs.readFileSync("/tmp/plugins.json", "utf8"));
|
const data = JSON.parse(fs.readFileSync("/tmp/plugins.json", "utf8"));
|
||||||
@ -79,17 +124,17 @@ if (diagErrors.length > 0) {
|
|||||||
console.log("ok");
|
console.log("ok");
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
echo "Testing tgz install flow..."
|
echo "Testing tgz install flow..."
|
||||||
pack_dir="$(mktemp -d "/tmp/openclaw-plugin-pack.XXXXXX")"
|
pack_dir="$(mktemp -d "/tmp/openclaw-plugin-pack.XXXXXX")"
|
||||||
mkdir -p "$pack_dir/package"
|
mkdir -p "$pack_dir/package"
|
||||||
cat > "$pack_dir/package/package.json" <<'"'"'JSON'"'"'
|
cat > "$pack_dir/package/package.json" <<'JSON'
|
||||||
{
|
{
|
||||||
"name": "@openclaw/demo-plugin-tgz",
|
"name": "@openclaw/demo-plugin-tgz",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"openclaw": { "extensions": ["./index.js"] }
|
"openclaw": { "extensions": ["./index.js"] }
|
||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
cat > "$pack_dir/package/index.js" <<'"'"'JS'"'"'
|
cat > "$pack_dir/package/index.js" <<'JS'
|
||||||
module.exports = {
|
module.exports = {
|
||||||
id: "demo-plugin-tgz",
|
id: "demo-plugin-tgz",
|
||||||
name: "Demo Plugin TGZ",
|
name: "Demo Plugin TGZ",
|
||||||
@ -98,7 +143,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
JS
|
JS
|
||||||
cat > "$pack_dir/package/openclaw.plugin.json" <<'"'"'JSON'"'"'
|
cat > "$pack_dir/package/openclaw.plugin.json" <<'JSON'
|
||||||
{
|
{
|
||||||
"id": "demo-plugin-tgz",
|
"id": "demo-plugin-tgz",
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
@ -107,12 +152,12 @@ JS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package
|
tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package
|
||||||
|
|
||||||
node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-tgz.tgz
|
node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-tgz.tgz
|
||||||
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins2.json
|
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins2.json
|
||||||
|
|
||||||
node - <<'"'"'NODE'"'"'
|
node - <<'NODE'
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
|
|
||||||
const data = JSON.parse(fs.readFileSync("/tmp/plugins2.json", "utf8"));
|
const data = JSON.parse(fs.readFileSync("/tmp/plugins2.json", "utf8"));
|
||||||
@ -127,16 +172,16 @@ if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("de
|
|||||||
console.log("ok");
|
console.log("ok");
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
echo "Testing install from local folder (plugins.load.paths)..."
|
echo "Testing install from local folder (plugins.load.paths)..."
|
||||||
dir_plugin="$(mktemp -d "/tmp/openclaw-plugin-dir.XXXXXX")"
|
dir_plugin="$(mktemp -d "/tmp/openclaw-plugin-dir.XXXXXX")"
|
||||||
cat > "$dir_plugin/package.json" <<'"'"'JSON'"'"'
|
cat > "$dir_plugin/package.json" <<'JSON'
|
||||||
{
|
{
|
||||||
"name": "@openclaw/demo-plugin-dir",
|
"name": "@openclaw/demo-plugin-dir",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"openclaw": { "extensions": ["./index.js"] }
|
"openclaw": { "extensions": ["./index.js"] }
|
||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
cat > "$dir_plugin/index.js" <<'"'"'JS'"'"'
|
cat > "$dir_plugin/index.js" <<'JS'
|
||||||
module.exports = {
|
module.exports = {
|
||||||
id: "demo-plugin-dir",
|
id: "demo-plugin-dir",
|
||||||
name: "Demo Plugin DIR",
|
name: "Demo Plugin DIR",
|
||||||
@ -145,7 +190,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
JS
|
JS
|
||||||
cat > "$dir_plugin/openclaw.plugin.json" <<'"'"'JSON'"'"'
|
cat > "$dir_plugin/openclaw.plugin.json" <<'JSON'
|
||||||
{
|
{
|
||||||
"id": "demo-plugin-dir",
|
"id": "demo-plugin-dir",
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
@ -155,10 +200,10 @@ JS
|
|||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
|
|
||||||
node "$OPENCLAW_ENTRY" plugins install "$dir_plugin"
|
node "$OPENCLAW_ENTRY" plugins install "$dir_plugin"
|
||||||
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins3.json
|
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins3.json
|
||||||
|
|
||||||
node - <<'"'"'NODE'"'"'
|
node - <<'NODE'
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
|
|
||||||
const data = JSON.parse(fs.readFileSync("/tmp/plugins3.json", "utf8"));
|
const data = JSON.parse(fs.readFileSync("/tmp/plugins3.json", "utf8"));
|
||||||
@ -173,17 +218,17 @@ if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("de
|
|||||||
console.log("ok");
|
console.log("ok");
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
echo "Testing install from npm spec (file:)..."
|
echo "Testing install from npm spec (file:)..."
|
||||||
file_pack_dir="$(mktemp -d "/tmp/openclaw-plugin-filepack.XXXXXX")"
|
file_pack_dir="$(mktemp -d "/tmp/openclaw-plugin-filepack.XXXXXX")"
|
||||||
mkdir -p "$file_pack_dir/package"
|
mkdir -p "$file_pack_dir/package"
|
||||||
cat > "$file_pack_dir/package/package.json" <<'"'"'JSON'"'"'
|
cat > "$file_pack_dir/package/package.json" <<'JSON'
|
||||||
{
|
{
|
||||||
"name": "@openclaw/demo-plugin-file",
|
"name": "@openclaw/demo-plugin-file",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"openclaw": { "extensions": ["./index.js"] }
|
"openclaw": { "extensions": ["./index.js"] }
|
||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
cat > "$file_pack_dir/package/index.js" <<'"'"'JS'"'"'
|
cat > "$file_pack_dir/package/index.js" <<'JS'
|
||||||
module.exports = {
|
module.exports = {
|
||||||
id: "demo-plugin-file",
|
id: "demo-plugin-file",
|
||||||
name: "Demo Plugin FILE",
|
name: "Demo Plugin FILE",
|
||||||
@ -192,7 +237,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
JS
|
JS
|
||||||
cat > "$file_pack_dir/package/openclaw.plugin.json" <<'"'"'JSON'"'"'
|
cat > "$file_pack_dir/package/openclaw.plugin.json" <<'JSON'
|
||||||
{
|
{
|
||||||
"id": "demo-plugin-file",
|
"id": "demo-plugin-file",
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
@ -202,10 +247,10 @@ JS
|
|||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
|
|
||||||
node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_dir/package"
|
node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_dir/package"
|
||||||
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins4.json
|
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins4.json
|
||||||
|
|
||||||
node - <<'"'"'NODE'"'"'
|
node - <<'NODE'
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
|
|
||||||
const data = JSON.parse(fs.readFileSync("/tmp/plugins4.json", "utf8"));
|
const data = JSON.parse(fs.readFileSync("/tmp/plugins4.json", "utf8"));
|
||||||
@ -220,8 +265,155 @@ if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("de
|
|||||||
console.log("ok");
|
console.log("ok");
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
echo "Running bundle MCP CLI-agent e2e..."
|
echo "Testing marketplace install and update flows..."
|
||||||
pnpm exec vitest run --config vitest.e2e.config.ts src/agents/cli-runner.bundle-mcp.e2e.test.ts
|
marketplace_root="$HOME/.claude/plugins/marketplaces/fixture-marketplace"
|
||||||
'
|
mkdir -p "$HOME/.claude/plugins" "$marketplace_root/.claude-plugin"
|
||||||
|
write_fixture_plugin \
|
||||||
|
"$marketplace_root/plugins/marketplace-shortcut" \
|
||||||
|
"marketplace-shortcut" \
|
||||||
|
"0.0.1" \
|
||||||
|
"demo.marketplace.shortcut.v1" \
|
||||||
|
"Marketplace Shortcut"
|
||||||
|
write_fixture_plugin \
|
||||||
|
"$marketplace_root/plugins/marketplace-direct" \
|
||||||
|
"marketplace-direct" \
|
||||||
|
"0.0.1" \
|
||||||
|
"demo.marketplace.direct.v1" \
|
||||||
|
"Marketplace Direct"
|
||||||
|
cat > "$marketplace_root/.claude-plugin/marketplace.json" <<'JSON'
|
||||||
|
{
|
||||||
|
"name": "Fixture Marketplace",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "marketplace-shortcut",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Shortcut install fixture",
|
||||||
|
"source": "./plugins/marketplace-shortcut"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "marketplace-direct",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Explicit marketplace fixture",
|
||||||
|
"source": {
|
||||||
|
"type": "path",
|
||||||
|
"path": "./plugins/marketplace-direct"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
cat > "$HOME/.claude/plugins/known_marketplaces.json" <<JSON
|
||||||
|
{
|
||||||
|
"claude-fixtures": {
|
||||||
|
"installLocation": "$marketplace_root",
|
||||||
|
"source": {
|
||||||
|
"type": "github",
|
||||||
|
"repo": "openclaw/fixture-marketplace"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
node "$OPENCLAW_ENTRY" plugins marketplace list claude-fixtures --json > /tmp/marketplace-list.json
|
||||||
|
|
||||||
|
node - <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
const data = JSON.parse(fs.readFileSync("/tmp/marketplace-list.json", "utf8"));
|
||||||
|
const names = (data.plugins || []).map((entry) => entry.name).sort();
|
||||||
|
if (data.name !== "Fixture Marketplace") {
|
||||||
|
throw new Error(`unexpected marketplace name: ${data.name}`);
|
||||||
|
}
|
||||||
|
if (!names.includes("marketplace-shortcut") || !names.includes("marketplace-direct")) {
|
||||||
|
throw new Error(`unexpected marketplace plugins: ${names.join(", ")}`);
|
||||||
|
}
|
||||||
|
console.log("ok");
|
||||||
|
NODE
|
||||||
|
|
||||||
|
node "$OPENCLAW_ENTRY" plugins install marketplace-shortcut@claude-fixtures
|
||||||
|
node "$OPENCLAW_ENTRY" plugins install marketplace-direct --marketplace claude-fixtures
|
||||||
|
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-marketplace.json
|
||||||
|
|
||||||
|
node - <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace.json", "utf8"));
|
||||||
|
const getPlugin = (id) => {
|
||||||
|
const plugin = (data.plugins || []).find((entry) => entry.id === id);
|
||||||
|
if (!plugin) throw new Error(`plugin not found: ${id}`);
|
||||||
|
if (plugin.status !== "loaded") {
|
||||||
|
throw new Error(`unexpected status for ${id}: ${plugin.status}`);
|
||||||
|
}
|
||||||
|
return plugin;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shortcut = getPlugin("marketplace-shortcut");
|
||||||
|
const direct = getPlugin("marketplace-direct");
|
||||||
|
if (shortcut.version !== "0.0.1") {
|
||||||
|
throw new Error(`unexpected shortcut version: ${shortcut.version}`);
|
||||||
|
}
|
||||||
|
if (direct.version !== "0.0.1") {
|
||||||
|
throw new Error(`unexpected direct version: ${direct.version}`);
|
||||||
|
}
|
||||||
|
if (!shortcut.gatewayMethods.includes("demo.marketplace.shortcut.v1")) {
|
||||||
|
throw new Error("expected marketplace shortcut gateway method");
|
||||||
|
}
|
||||||
|
if (!direct.gatewayMethods.includes("demo.marketplace.direct.v1")) {
|
||||||
|
throw new Error("expected marketplace direct gateway method");
|
||||||
|
}
|
||||||
|
console.log("ok");
|
||||||
|
NODE
|
||||||
|
|
||||||
|
node - <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
|
||||||
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||||
|
for (const id of ["marketplace-shortcut", "marketplace-direct"]) {
|
||||||
|
const record = config.plugins?.installs?.[id];
|
||||||
|
if (!record) throw new Error(`missing install record for ${id}`);
|
||||||
|
if (record.source !== "marketplace") {
|
||||||
|
throw new Error(`unexpected source for ${id}: ${record.source}`);
|
||||||
|
}
|
||||||
|
if (record.marketplaceSource !== "claude-fixtures") {
|
||||||
|
throw new Error(`unexpected marketplace source for ${id}: ${record.marketplaceSource}`);
|
||||||
|
}
|
||||||
|
if (record.marketplacePlugin !== id) {
|
||||||
|
throw new Error(`unexpected marketplace plugin for ${id}: ${record.marketplacePlugin}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("ok");
|
||||||
|
NODE
|
||||||
|
|
||||||
|
write_fixture_plugin \
|
||||||
|
"$marketplace_root/plugins/marketplace-shortcut" \
|
||||||
|
"marketplace-shortcut" \
|
||||||
|
"0.0.2" \
|
||||||
|
"demo.marketplace.shortcut.v2" \
|
||||||
|
"Marketplace Shortcut"
|
||||||
|
node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut --dry-run
|
||||||
|
node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut
|
||||||
|
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-marketplace-updated.json
|
||||||
|
|
||||||
|
node - <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated.json", "utf8"));
|
||||||
|
const plugin = (data.plugins || []).find((entry) => entry.id === "marketplace-shortcut");
|
||||||
|
if (!plugin) throw new Error("updated marketplace plugin not found");
|
||||||
|
if (plugin.version !== "0.0.2") {
|
||||||
|
throw new Error(`unexpected updated version: ${plugin.version}`);
|
||||||
|
}
|
||||||
|
if (!plugin.gatewayMethods.includes("demo.marketplace.shortcut.v2")) {
|
||||||
|
throw new Error(`expected updated gateway method, got ${plugin.gatewayMethods.join(", ")}`);
|
||||||
|
}
|
||||||
|
console.log("ok");
|
||||||
|
NODE
|
||||||
|
|
||||||
|
echo "Running bundle MCP CLI-agent e2e..."
|
||||||
|
pnpm exec vitest run --config vitest.e2e.config.ts src/agents/cli-runner.bundle-mcp.e2e.test.ts
|
||||||
|
EOF
|
||||||
|
|
||||||
echo "OK"
|
echo "OK"
|
||||||
|
|||||||
@ -11,6 +11,11 @@ import { enablePluginInConfig } from "../plugins/enable.js";
|
|||||||
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
|
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
|
||||||
import { recordPluginInstall } from "../plugins/installs.js";
|
import { recordPluginInstall } from "../plugins/installs.js";
|
||||||
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
|
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
|
||||||
|
import {
|
||||||
|
installPluginFromMarketplace,
|
||||||
|
listMarketplacePlugins,
|
||||||
|
resolveMarketplaceInstallShortcut,
|
||||||
|
} from "../plugins/marketplace.js";
|
||||||
import type { PluginRecord } from "../plugins/registry.js";
|
import type { PluginRecord } from "../plugins/registry.js";
|
||||||
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
|
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
|
||||||
import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js";
|
import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js";
|
||||||
@ -46,6 +51,10 @@ export type PluginUpdateOptions = {
|
|||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PluginMarketplaceListOptions = {
|
||||||
|
json?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type PluginUninstallOptions = {
|
export type PluginUninstallOptions = {
|
||||||
keepFiles?: boolean;
|
keepFiles?: boolean;
|
||||||
keepConfig?: boolean;
|
keepConfig?: boolean;
|
||||||
@ -203,9 +212,65 @@ async function installBundledPluginSource(params: {
|
|||||||
|
|
||||||
async function runPluginInstallCommand(params: {
|
async function runPluginInstallCommand(params: {
|
||||||
raw: string;
|
raw: string;
|
||||||
opts: { link?: boolean; pin?: boolean };
|
opts: { link?: boolean; pin?: boolean; marketplace?: string };
|
||||||
}) {
|
}) {
|
||||||
const { raw, opts } = params;
|
const shorthand = !params.opts.marketplace
|
||||||
|
? await resolveMarketplaceInstallShortcut(params.raw)
|
||||||
|
: null;
|
||||||
|
if (shorthand?.ok === false) {
|
||||||
|
defaultRuntime.error(shorthand.error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = shorthand?.ok ? shorthand.plugin : params.raw;
|
||||||
|
const opts = {
|
||||||
|
...params.opts,
|
||||||
|
marketplace:
|
||||||
|
params.opts.marketplace ?? (shorthand?.ok ? shorthand.marketplaceSource : undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts.marketplace) {
|
||||||
|
if (opts.link) {
|
||||||
|
defaultRuntime.error("`--link` is not supported with `--marketplace`.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (opts.pin) {
|
||||||
|
defaultRuntime.error("`--pin` is not supported with `--marketplace`.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const result = await installPluginFromMarketplace({
|
||||||
|
marketplace: opts.marketplace,
|
||||||
|
plugin: raw,
|
||||||
|
logger: createPluginInstallLogger(),
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
defaultRuntime.error(result.error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPluginManifestRegistryCache();
|
||||||
|
|
||||||
|
let next = enablePluginInConfig(cfg, result.pluginId).config;
|
||||||
|
next = recordPluginInstall(next, {
|
||||||
|
pluginId: result.pluginId,
|
||||||
|
source: "marketplace",
|
||||||
|
installPath: result.targetDir,
|
||||||
|
version: result.version,
|
||||||
|
marketplaceName: result.marketplaceName,
|
||||||
|
marketplaceSource: result.marketplaceSource,
|
||||||
|
marketplacePlugin: result.marketplacePlugin,
|
||||||
|
});
|
||||||
|
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
|
||||||
|
next = slotResult.config;
|
||||||
|
await writeConfigFile(next);
|
||||||
|
logSlotWarnings(slotResult.warnings);
|
||||||
|
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||||
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fileSpec = resolveFileNpmSpecToLocalPath(raw);
|
const fileSpec = resolveFileNpmSpecToLocalPath(raw);
|
||||||
if (fileSpec && !fileSpec.ok) {
|
if (fileSpec && !fileSpec.ok) {
|
||||||
defaultRuntime.error(fileSpec.error);
|
defaultRuntime.error(fileSpec.error);
|
||||||
@ -734,17 +799,24 @@ export function registerPluginsCli(program: Command) {
|
|||||||
|
|
||||||
plugins
|
plugins
|
||||||
.command("install")
|
.command("install")
|
||||||
.description("Install a plugin (path, archive, or npm spec)")
|
.description("Install a plugin (path, archive, npm spec, or marketplace entry)")
|
||||||
.argument("<path-or-spec>", "Path (.ts/.js/.zip/.tgz/.tar.gz) or an npm package spec")
|
.argument(
|
||||||
|
"<path-or-spec-or-plugin>",
|
||||||
|
"Path (.ts/.js/.zip/.tgz/.tar.gz), npm package spec, or marketplace plugin name",
|
||||||
|
)
|
||||||
.option("-l, --link", "Link a local path instead of copying", false)
|
.option("-l, --link", "Link a local path instead of copying", false)
|
||||||
.option("--pin", "Record npm installs as exact resolved <name>@<version>", false)
|
.option("--pin", "Record npm installs as exact resolved <name>@<version>", false)
|
||||||
.action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => {
|
.option(
|
||||||
|
"--marketplace <source>",
|
||||||
|
"Install a Claude marketplace plugin from a local repo/path or git/GitHub source",
|
||||||
|
)
|
||||||
|
.action(async (raw: string, opts: { link?: boolean; pin?: boolean; marketplace?: string }) => {
|
||||||
await runPluginInstallCommand({ raw, opts });
|
await runPluginInstallCommand({ raw, opts });
|
||||||
});
|
});
|
||||||
|
|
||||||
plugins
|
plugins
|
||||||
.command("update")
|
.command("update")
|
||||||
.description("Update installed plugins (npm installs only)")
|
.description("Update installed plugins (npm and marketplace installs)")
|
||||||
.argument("[id]", "Plugin id (omit with --all)")
|
.argument("[id]", "Plugin id (omit with --all)")
|
||||||
.option("--all", "Update all tracked plugins", false)
|
.option("--all", "Update all tracked plugins", false)
|
||||||
.option("--dry-run", "Show what would change without writing", false)
|
.option("--dry-run", "Show what would change without writing", false)
|
||||||
@ -755,7 +827,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
|
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
if (opts.all) {
|
if (opts.all) {
|
||||||
defaultRuntime.log("No npm-installed plugins to update.");
|
defaultRuntime.log("No tracked plugins to update.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
defaultRuntime.error("Provide a plugin id or use --all.");
|
defaultRuntime.error("Provide a plugin id or use --all.");
|
||||||
@ -839,4 +911,54 @@ export function registerPluginsCli(program: Command) {
|
|||||||
lines.push(`${theme.muted("Docs:")} ${docs}`);
|
lines.push(`${theme.muted("Docs:")} ${docs}`);
|
||||||
defaultRuntime.log(lines.join("\n"));
|
defaultRuntime.log(lines.join("\n"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const marketplace = plugins
|
||||||
|
.command("marketplace")
|
||||||
|
.description("Inspect Claude-compatible plugin marketplaces");
|
||||||
|
|
||||||
|
marketplace
|
||||||
|
.command("list")
|
||||||
|
.description("List plugins published by a marketplace source")
|
||||||
|
.argument("<source>", "Local marketplace path/repo or git/GitHub source")
|
||||||
|
.option("--json", "Print JSON")
|
||||||
|
.action(async (source: string, opts: PluginMarketplaceListOptions) => {
|
||||||
|
const result = await listMarketplacePlugins({
|
||||||
|
marketplace: source,
|
||||||
|
logger: createPluginInstallLogger(),
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
defaultRuntime.error(result.error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
defaultRuntime.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
source: result.sourceLabel,
|
||||||
|
name: result.manifest.name,
|
||||||
|
version: result.manifest.version,
|
||||||
|
plugins: result.manifest.plugins,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.manifest.plugins.length === 0) {
|
||||||
|
defaultRuntime.log(`No plugins found in marketplace ${result.sourceLabel}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRuntime.log(
|
||||||
|
`${theme.heading("Marketplace")} ${theme.muted(result.manifest.name ?? result.sourceLabel)}`,
|
||||||
|
);
|
||||||
|
for (const plugin of result.manifest.plugins) {
|
||||||
|
const suffix = plugin.version ? theme.muted(` v${plugin.version}`) : "";
|
||||||
|
const desc = plugin.description ? ` - ${theme.muted(plugin.description)}` : "";
|
||||||
|
defaultRuntime.log(`${theme.command(plugin.name)}${suffix}${desc}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1003,6 +1003,12 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"plugins.installs.*.resolvedAt":
|
"plugins.installs.*.resolvedAt":
|
||||||
"ISO timestamp when npm package metadata was last resolved for this install record.",
|
"ISO timestamp when npm package metadata was last resolved for this install record.",
|
||||||
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
|
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
|
||||||
|
"plugins.installs.*.marketplaceName":
|
||||||
|
"Marketplace display name recorded for marketplace-backed plugin installs (if available).",
|
||||||
|
"plugins.installs.*.marketplaceSource":
|
||||||
|
"Original marketplace source used to resolve the install (for example a repo path or Git URL).",
|
||||||
|
"plugins.installs.*.marketplacePlugin":
|
||||||
|
"Plugin entry name inside the source marketplace, used for later updates.",
|
||||||
"agents.list.*.identity.avatar":
|
"agents.list.*.identity.avatar":
|
||||||
"Agent avatar (workspace-relative path, http(s) URL, or data URI).",
|
"Agent avatar (workspace-relative path, http(s) URL, or data URI).",
|
||||||
"agents.defaults.model.primary": "Primary model (provider/model).",
|
"agents.defaults.model.primary": "Primary model (provider/model).",
|
||||||
|
|||||||
@ -871,4 +871,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||||||
"plugins.installs.*.shasum": "Plugin Resolved Shasum",
|
"plugins.installs.*.shasum": "Plugin Resolved Shasum",
|
||||||
"plugins.installs.*.resolvedAt": "Plugin Resolution Time",
|
"plugins.installs.*.resolvedAt": "Plugin Resolution Time",
|
||||||
"plugins.installs.*.installedAt": "Plugin Install Time",
|
"plugins.installs.*.installedAt": "Plugin Install Time",
|
||||||
|
"plugins.installs.*.marketplaceName": "Plugin Marketplace Name",
|
||||||
|
"plugins.installs.*.marketplaceSource": "Plugin Marketplace Source",
|
||||||
|
"plugins.installs.*.marketplacePlugin": "Plugin Marketplace Plugin",
|
||||||
};
|
};
|
||||||
|
|||||||
@ -19,7 +19,12 @@ export type PluginsLoadConfig = {
|
|||||||
paths?: string[];
|
paths?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginInstallRecord = InstallRecordBase;
|
export type PluginInstallRecord = Omit<InstallRecordBase, "source"> & {
|
||||||
|
source: InstallRecordBase["source"] | "marketplace";
|
||||||
|
marketplaceName?: string;
|
||||||
|
marketplaceSource?: string;
|
||||||
|
marketplacePlugin?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PluginsConfig = {
|
export type PluginsConfig = {
|
||||||
/** Enable or disable plugin loading. */
|
/** Enable or disable plugin loading. */
|
||||||
|
|||||||
@ -6,6 +6,8 @@ export const InstallSourceSchema = z.union([
|
|||||||
z.literal("path"),
|
z.literal("path"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const PluginInstallSourceSchema = z.union([InstallSourceSchema, z.literal("marketplace")]);
|
||||||
|
|
||||||
export const InstallRecordShape = {
|
export const InstallRecordShape = {
|
||||||
source: InstallSourceSchema,
|
source: InstallSourceSchema,
|
||||||
spec: z.string().optional(),
|
spec: z.string().optional(),
|
||||||
@ -20,3 +22,11 @@ export const InstallRecordShape = {
|
|||||||
resolvedAt: z.string().optional(),
|
resolvedAt: z.string().optional(),
|
||||||
installedAt: z.string().optional(),
|
installedAt: z.string().optional(),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const PluginInstallRecordShape = {
|
||||||
|
...InstallRecordShape,
|
||||||
|
source: PluginInstallSourceSchema,
|
||||||
|
marketplaceName: z.string().optional(),
|
||||||
|
marketplaceSource: z.string().optional(),
|
||||||
|
marketplacePlugin: z.string().optional(),
|
||||||
|
} as const;
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import {
|
|||||||
SecretsConfigSchema,
|
SecretsConfigSchema,
|
||||||
} from "./zod-schema.core.js";
|
} from "./zod-schema.core.js";
|
||||||
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
|
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
|
||||||
import { InstallRecordShape } from "./zod-schema.installs.js";
|
import { PluginInstallRecordShape } from "./zod-schema.installs.js";
|
||||||
import { ChannelsSchema } from "./zod-schema.providers.js";
|
import { ChannelsSchema } from "./zod-schema.providers.js";
|
||||||
import { sensitive } from "./zod-schema.sensitive.js";
|
import { sensitive } from "./zod-schema.sensitive.js";
|
||||||
import {
|
import {
|
||||||
@ -905,7 +905,7 @@ export const OpenClawSchema = z
|
|||||||
z.string(),
|
z.string(),
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
...InstallRecordShape,
|
...PluginInstallRecordShape,
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
)
|
)
|
||||||
|
|||||||
141
src/plugins/marketplace.test.ts
Normal file
141
src/plugins/marketplace.test.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { withEnvAsync } from "../test-utils/env.js";
|
||||||
|
|
||||||
|
const installPluginFromPathMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("./install.js", () => ({
|
||||||
|
installPluginFromPath: (...args: unknown[]) => installPluginFromPathMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-test-"));
|
||||||
|
try {
|
||||||
|
return await fn(dir);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("marketplace plugins", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
installPluginFromPathMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists plugins from a local marketplace root", async () => {
|
||||||
|
await withTempDir(async (rootDir) => {
|
||||||
|
await fs.mkdir(path.join(rootDir, ".claude-plugin"), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(rootDir, ".claude-plugin", "marketplace.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: "Example Marketplace",
|
||||||
|
version: "1.0.0",
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "frontend-design",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "Design system bundle",
|
||||||
|
source: "./plugins/frontend-design",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { listMarketplacePlugins } = await import("./marketplace.js");
|
||||||
|
const result = await listMarketplacePlugins({ marketplace: rootDir });
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
sourceLabel: expect.stringContaining(".claude-plugin/marketplace.json"),
|
||||||
|
manifest: {
|
||||||
|
name: "Example Marketplace",
|
||||||
|
version: "1.0.0",
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "frontend-design",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "Design system bundle",
|
||||||
|
source: { kind: "path", path: "./plugins/frontend-design" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves relative plugin paths against the marketplace root", async () => {
|
||||||
|
await withTempDir(async (rootDir) => {
|
||||||
|
const pluginDir = path.join(rootDir, "plugins", "frontend-design");
|
||||||
|
await fs.mkdir(path.join(rootDir, ".claude-plugin"), { recursive: true });
|
||||||
|
await fs.mkdir(pluginDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(rootDir, ".claude-plugin", "marketplace.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "frontend-design",
|
||||||
|
source: "./plugins/frontend-design",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
installPluginFromPathMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
pluginId: "frontend-design",
|
||||||
|
targetDir: "/tmp/frontend-design",
|
||||||
|
version: "0.1.0",
|
||||||
|
extensions: ["index.ts"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { installPluginFromMarketplace } = await import("./marketplace.js");
|
||||||
|
const result = await installPluginFromMarketplace({
|
||||||
|
marketplace: path.join(rootDir, ".claude-plugin", "marketplace.json"),
|
||||||
|
plugin: "frontend-design",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(installPluginFromPathMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
path: pluginDir,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
pluginId: "frontend-design",
|
||||||
|
marketplacePlugin: "frontend-design",
|
||||||
|
marketplaceSource: path.join(rootDir, ".claude-plugin", "marketplace.json"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves Claude-style plugin@marketplace shortcuts from known_marketplaces.json", async () => {
|
||||||
|
await withTempDir(async (homeDir) => {
|
||||||
|
await fs.mkdir(path.join(homeDir, ".claude", "plugins"), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(homeDir, ".claude", "plugins", "known_marketplaces.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
"claude-plugins-official": {
|
||||||
|
source: {
|
||||||
|
source: "github",
|
||||||
|
repo: "anthropics/claude-plugins-official",
|
||||||
|
},
|
||||||
|
installLocation: path.join(homeDir, ".claude", "plugins", "marketplaces", "official"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { resolveMarketplaceInstallShortcut } = await import("./marketplace.js");
|
||||||
|
const shortcut = await withEnvAsync(
|
||||||
|
{ HOME: homeDir },
|
||||||
|
async () => await resolveMarketplaceInstallShortcut("superpowers@claude-plugins-official"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shortcut).toEqual({
|
||||||
|
ok: true,
|
||||||
|
plugin: "superpowers",
|
||||||
|
marketplaceName: "claude-plugins-official",
|
||||||
|
marketplaceSource: "claude-plugins-official",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
832
src/plugins/marketplace.ts
Normal file
832
src/plugins/marketplace.ts
Normal file
@ -0,0 +1,832 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { resolveArchiveKind } from "../infra/archive.js";
|
||||||
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
|
import { resolveUserPath } from "../utils.js";
|
||||||
|
import { installPluginFromPath, type InstallPluginResult } from "./install.js";
|
||||||
|
|
||||||
|
const DEFAULT_GIT_TIMEOUT_MS = 120_000;
|
||||||
|
const MARKETPLACE_MANIFEST_CANDIDATES = [
|
||||||
|
path.join(".claude-plugin", "marketplace.json"),
|
||||||
|
"marketplace.json",
|
||||||
|
] as const;
|
||||||
|
const CLAUDE_KNOWN_MARKETPLACES_PATH = path.join(
|
||||||
|
"~",
|
||||||
|
".claude",
|
||||||
|
"plugins",
|
||||||
|
"known_marketplaces.json",
|
||||||
|
);
|
||||||
|
|
||||||
|
type MarketplaceLogger = {
|
||||||
|
info?: (message: string) => void;
|
||||||
|
warn?: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MarketplaceEntrySource =
|
||||||
|
| { kind: "path"; path: string }
|
||||||
|
| { kind: "github"; repo: string; path?: string; ref?: string }
|
||||||
|
| { kind: "git"; url: string; path?: string; ref?: string }
|
||||||
|
| { kind: "git-subdir"; url: string; path: string; ref?: string }
|
||||||
|
| { kind: "url"; url: string };
|
||||||
|
|
||||||
|
export type MarketplacePluginEntry = {
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
description?: string;
|
||||||
|
source: MarketplaceEntrySource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarketplaceManifest = {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
plugins: MarketplacePluginEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoadedMarketplace = {
|
||||||
|
manifest: MarketplaceManifest;
|
||||||
|
rootDir: string;
|
||||||
|
sourceLabel: string;
|
||||||
|
cleanup?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type KnownMarketplaceRecord = {
|
||||||
|
installLocation?: string;
|
||||||
|
source?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarketplacePluginListResult =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
manifest: MarketplaceManifest;
|
||||||
|
sourceLabel: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarketplaceInstallResult =
|
||||||
|
| ({
|
||||||
|
ok: true;
|
||||||
|
marketplaceName?: string;
|
||||||
|
marketplaceVersion?: string;
|
||||||
|
marketplacePlugin: string;
|
||||||
|
marketplaceSource: string;
|
||||||
|
marketplaceEntryVersion?: string;
|
||||||
|
} & Extract<InstallPluginResult, { ok: true }>)
|
||||||
|
| Extract<InstallPluginResult, { ok: false }>;
|
||||||
|
|
||||||
|
export type MarketplaceShortcutResolution =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
plugin: string;
|
||||||
|
marketplaceName: string;
|
||||||
|
marketplaceSource: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
|
||||||
|
function isHttpUrl(value: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitUrl(value: string): boolean {
|
||||||
|
return (
|
||||||
|
/^git@/i.test(value) || /^ssh:\/\//i.test(value) || /^https?:\/\/.+\.git(?:#.*)?$/i.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeGitHubRepoShorthand(value: string): boolean {
|
||||||
|
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:#.+)?$/.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitRef(value: string): { base: string; ref?: string } {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const hashIndex = trimmed.lastIndexOf("#");
|
||||||
|
if (hashIndex <= 0 || hashIndex >= trimmed.length - 1) {
|
||||||
|
return { base: trimmed };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
base: trimmed.slice(0, hashIndex),
|
||||||
|
ref: trimmed.slice(hashIndex + 1).trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOptionalString(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntrySource(
|
||||||
|
raw: unknown,
|
||||||
|
): { ok: true; source: MarketplaceEntrySource } | { ok: false; error: string } {
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { ok: false, error: "empty plugin source" };
|
||||||
|
}
|
||||||
|
if (isHttpUrl(trimmed)) {
|
||||||
|
return { ok: true, source: { kind: "url", url: trimmed } };
|
||||||
|
}
|
||||||
|
return { ok: true, source: { kind: "path", path: trimmed } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!raw || typeof raw !== "object") {
|
||||||
|
return { ok: false, error: "plugin source must be a string or object" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = raw as Record<string, unknown>;
|
||||||
|
const kind = toOptionalString(rec.type) ?? toOptionalString(rec.source);
|
||||||
|
if (!kind) {
|
||||||
|
return { ok: false, error: 'plugin source object missing "type" or "source"' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "path") {
|
||||||
|
const sourcePath = toOptionalString(rec.path);
|
||||||
|
if (!sourcePath) {
|
||||||
|
return { ok: false, error: 'path source missing "path"' };
|
||||||
|
}
|
||||||
|
return { ok: true, source: { kind: "path", path: sourcePath } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "github") {
|
||||||
|
const repo = toOptionalString(rec.repo) ?? toOptionalString(rec.url);
|
||||||
|
if (!repo) {
|
||||||
|
return { ok: false, error: 'github source missing "repo"' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
source: {
|
||||||
|
kind: "github",
|
||||||
|
repo,
|
||||||
|
path: toOptionalString(rec.path),
|
||||||
|
ref: toOptionalString(rec.ref) ?? toOptionalString(rec.branch) ?? toOptionalString(rec.tag),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "git") {
|
||||||
|
const url = toOptionalString(rec.url) ?? toOptionalString(rec.repo);
|
||||||
|
if (!url) {
|
||||||
|
return { ok: false, error: 'git source missing "url"' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
source: {
|
||||||
|
kind: "git",
|
||||||
|
url,
|
||||||
|
path: toOptionalString(rec.path),
|
||||||
|
ref: toOptionalString(rec.ref) ?? toOptionalString(rec.branch) ?? toOptionalString(rec.tag),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "git-subdir") {
|
||||||
|
const url = toOptionalString(rec.url) ?? toOptionalString(rec.repo);
|
||||||
|
const sourcePath = toOptionalString(rec.path) ?? toOptionalString(rec.subdir);
|
||||||
|
if (!url) {
|
||||||
|
return { ok: false, error: 'git-subdir source missing "url"' };
|
||||||
|
}
|
||||||
|
if (!sourcePath) {
|
||||||
|
return { ok: false, error: 'git-subdir source missing "path"' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
source: {
|
||||||
|
kind: "git-subdir",
|
||||||
|
url,
|
||||||
|
path: sourcePath,
|
||||||
|
ref: toOptionalString(rec.ref) ?? toOptionalString(rec.branch) ?? toOptionalString(rec.tag),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "url") {
|
||||||
|
const url = toOptionalString(rec.url);
|
||||||
|
if (!url) {
|
||||||
|
return { ok: false, error: 'url source missing "url"' };
|
||||||
|
}
|
||||||
|
return { ok: true, source: { kind: "url", url } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: `unsupported plugin source kind: ${kind}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function marketplaceEntrySourceToInput(source: MarketplaceEntrySource): string {
|
||||||
|
switch (source.kind) {
|
||||||
|
case "path":
|
||||||
|
return source.path;
|
||||||
|
case "github":
|
||||||
|
return `${source.repo}${source.ref ? `#${source.ref}` : ""}`;
|
||||||
|
case "git":
|
||||||
|
return `${source.url}${source.ref ? `#${source.ref}` : ""}`;
|
||||||
|
case "git-subdir":
|
||||||
|
return `${source.url}${source.ref ? `#${source.ref}` : ""}`;
|
||||||
|
case "url":
|
||||||
|
return source.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarketplaceManifest(
|
||||||
|
raw: string,
|
||||||
|
sourceLabel: string,
|
||||||
|
): { ok: true; manifest: MarketplaceManifest } | { ok: false; error: string } {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: `invalid marketplace JSON at ${sourceLabel}: ${String(err)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return { ok: false, error: `invalid marketplace JSON at ${sourceLabel}: expected object` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = parsed as Record<string, unknown>;
|
||||||
|
if (!Array.isArray(rec.plugins)) {
|
||||||
|
return { ok: false, error: `invalid marketplace JSON at ${sourceLabel}: missing plugins[]` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins: MarketplacePluginEntry[] = [];
|
||||||
|
for (const entry of rec.plugins) {
|
||||||
|
if (!entry || typeof entry !== "object") {
|
||||||
|
return { ok: false, error: `invalid marketplace entry in ${sourceLabel}: expected object` };
|
||||||
|
}
|
||||||
|
const plugin = entry as Record<string, unknown>;
|
||||||
|
const name = toOptionalString(plugin.name);
|
||||||
|
if (!name) {
|
||||||
|
return { ok: false, error: `invalid marketplace entry in ${sourceLabel}: missing name` };
|
||||||
|
}
|
||||||
|
const normalizedSource = normalizeEntrySource(plugin.source);
|
||||||
|
if (!normalizedSource.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `invalid marketplace entry "${name}" in ${sourceLabel}: ${normalizedSource.error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
plugins.push({
|
||||||
|
name,
|
||||||
|
version: toOptionalString(plugin.version),
|
||||||
|
description: toOptionalString(plugin.description),
|
||||||
|
source: normalizedSource.source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
manifest: {
|
||||||
|
name: toOptionalString(rec.name),
|
||||||
|
version: toOptionalString(rec.version),
|
||||||
|
plugins,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(target: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(target);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readClaudeKnownMarketplaces(): Promise<Record<string, KnownMarketplaceRecord>> {
|
||||||
|
const knownPath = resolveUserPath(CLAUDE_KNOWN_MARKETPLACES_PATH);
|
||||||
|
if (!(await pathExists(knownPath))) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(await fs.readFile(knownPath, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = parsed as Record<string, unknown>;
|
||||||
|
const result: Record<string, KnownMarketplaceRecord> = {};
|
||||||
|
for (const [name, value] of Object.entries(entries)) {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
result[name] = {
|
||||||
|
installLocation: toOptionalString(record.installLocation),
|
||||||
|
source: record.source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveMarketplaceRootFromManifestPath(manifestPath: string): string {
|
||||||
|
const manifestDir = path.dirname(manifestPath);
|
||||||
|
return path.basename(manifestDir) === ".claude-plugin" ? path.dirname(manifestDir) : manifestDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveLocalMarketplaceSource(
|
||||||
|
input: string,
|
||||||
|
): Promise<
|
||||||
|
{ ok: true; rootDir: string; manifestPath: string } | { ok: false; error: string } | null
|
||||||
|
> {
|
||||||
|
const resolved = resolveUserPath(input);
|
||||||
|
if (!(await pathExists(resolved))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await fs.stat(resolved);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
rootDir: deriveMarketplaceRootFromManifestPath(resolved),
|
||||||
|
manifestPath: resolved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return { ok: false, error: `unsupported marketplace source: ${resolved}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootDir = path.basename(resolved) === ".claude-plugin" ? path.dirname(resolved) : resolved;
|
||||||
|
for (const candidate of MARKETPLACE_MANIFEST_CANDIDATES) {
|
||||||
|
const manifestPath = path.join(rootDir, candidate);
|
||||||
|
if (await pathExists(manifestPath)) {
|
||||||
|
return { ok: true, rootDir, manifestPath };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: `marketplace manifest not found under ${resolved}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGitCloneSource(
|
||||||
|
source: string,
|
||||||
|
): { url: string; ref?: string; label: string } | null {
|
||||||
|
const split = splitRef(source);
|
||||||
|
if (looksLikeGitHubRepoShorthand(split.base)) {
|
||||||
|
return {
|
||||||
|
url: `https://github.com/${split.base}.git`,
|
||||||
|
ref: split.ref,
|
||||||
|
label: split.base,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGitUrl(source)) {
|
||||||
|
return {
|
||||||
|
url: split.base,
|
||||||
|
ref: split.ref,
|
||||||
|
label: split.base,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHttpUrl(source)) {
|
||||||
|
try {
|
||||||
|
const url = new URL(split.base);
|
||||||
|
if (url.hostname !== "github.com") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean);
|
||||||
|
if (parts.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const repo = `${parts[0]}/${parts[1]?.replace(/\.git$/i, "")}`;
|
||||||
|
return {
|
||||||
|
url: `https://github.com/${repo}.git`,
|
||||||
|
ref: split.ref,
|
||||||
|
label: repo,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneMarketplaceRepo(params: {
|
||||||
|
source: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
logger?: MarketplaceLogger;
|
||||||
|
}): Promise<
|
||||||
|
| { ok: true; rootDir: string; cleanup: () => Promise<void>; label: string }
|
||||||
|
| { ok: false; error: string }
|
||||||
|
> {
|
||||||
|
const normalized = normalizeGitCloneSource(params.source);
|
||||||
|
if (!normalized) {
|
||||||
|
return { ok: false, error: `unsupported marketplace source: ${params.source}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-"));
|
||||||
|
const repoDir = path.join(tmpDir, "repo");
|
||||||
|
const argv = ["git", "clone", "--depth", "1"];
|
||||||
|
if (normalized.ref) {
|
||||||
|
argv.push("--branch", normalized.ref);
|
||||||
|
}
|
||||||
|
argv.push(normalized.url, repoDir);
|
||||||
|
params.logger?.info?.(`Cloning marketplace source ${normalized.label}...`);
|
||||||
|
const res = await runCommandWithTimeout(argv, {
|
||||||
|
timeoutMs: params.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
if (res.code !== 0) {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
const detail = res.stderr.trim() || res.stdout.trim() || "git clone failed";
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `failed to clone marketplace source ${normalized.label}: ${detail}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
rootDir: repoDir,
|
||||||
|
label: normalized.label,
|
||||||
|
cleanup: async () => {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMarketplace(params: {
|
||||||
|
source: string;
|
||||||
|
logger?: MarketplaceLogger;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> {
|
||||||
|
const knownMarketplaces = await readClaudeKnownMarketplaces();
|
||||||
|
const known = knownMarketplaces[params.source];
|
||||||
|
if (known) {
|
||||||
|
if (known.installLocation) {
|
||||||
|
const local = await resolveLocalMarketplaceSource(known.installLocation);
|
||||||
|
if (local?.ok) {
|
||||||
|
const raw = await fs.readFile(local.manifestPath, "utf-8");
|
||||||
|
const parsed = parseMarketplaceManifest(raw, local.manifestPath);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
marketplace: {
|
||||||
|
manifest: parsed.manifest,
|
||||||
|
rootDir: local.rootDir,
|
||||||
|
sourceLabel: params.source,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSource = normalizeEntrySource(known.source);
|
||||||
|
if (normalizedSource.ok) {
|
||||||
|
return await loadMarketplace({
|
||||||
|
source: marketplaceEntrySourceToInput(normalizedSource.source),
|
||||||
|
logger: params.logger,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const local = await resolveLocalMarketplaceSource(params.source);
|
||||||
|
if (local?.ok === false) {
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local?.ok) {
|
||||||
|
const raw = await fs.readFile(local.manifestPath, "utf-8");
|
||||||
|
const parsed = parseMarketplaceManifest(raw, local.manifestPath);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
marketplace: {
|
||||||
|
manifest: parsed.manifest,
|
||||||
|
rootDir: local.rootDir,
|
||||||
|
sourceLabel: local.manifestPath,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloned = await cloneMarketplaceRepo({
|
||||||
|
source: params.source,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
logger: params.logger,
|
||||||
|
});
|
||||||
|
if (!cloned.ok) {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifestPath: string | undefined;
|
||||||
|
for (const candidate of MARKETPLACE_MANIFEST_CANDIDATES) {
|
||||||
|
const next = path.join(cloned.rootDir, candidate);
|
||||||
|
if (await pathExists(next)) {
|
||||||
|
manifestPath = next;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!manifestPath) {
|
||||||
|
await cloned.cleanup();
|
||||||
|
return { ok: false, error: `marketplace manifest not found in ${cloned.label}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await fs.readFile(manifestPath, "utf-8");
|
||||||
|
const parsed = parseMarketplaceManifest(raw, manifestPath);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
await cloned.cleanup();
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
marketplace: {
|
||||||
|
manifest: parsed.manifest,
|
||||||
|
rootDir: cloned.rootDir,
|
||||||
|
sourceLabel: cloned.label,
|
||||||
|
cleanup: cloned.cleanup,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadUrlToTempFile(url: string): Promise<
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
path: string;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
return { ok: false, error: `failed to download ${url}: HTTP ${response.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = new URL(url).pathname;
|
||||||
|
const fileName = path.basename(pathname) || "plugin.tgz";
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-download-"));
|
||||||
|
const targetPath = path.join(tmpDir, fileName);
|
||||||
|
await fs.writeFile(targetPath, Buffer.from(await response.arrayBuffer()));
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: targetPath,
|
||||||
|
cleanup: async () => {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureInsideMarketplaceRoot(
|
||||||
|
rootDir: string,
|
||||||
|
candidate: string,
|
||||||
|
): { ok: true; path: string } | { ok: false; error: string } {
|
||||||
|
const resolved = path.resolve(rootDir, candidate);
|
||||||
|
const relative = path.relative(rootDir, resolved);
|
||||||
|
if (relative === ".." || relative.startsWith(`..${path.sep}`)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `plugin source escapes marketplace root: ${candidate}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, path: resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveMarketplaceEntryInstallPath(params: {
|
||||||
|
source: MarketplaceEntrySource;
|
||||||
|
marketplaceRootDir: string;
|
||||||
|
logger?: MarketplaceLogger;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
path: string;
|
||||||
|
cleanup?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
> {
|
||||||
|
if (params.source.kind === "path") {
|
||||||
|
if (isHttpUrl(params.source.path)) {
|
||||||
|
if (resolveArchiveKind(params.source.path)) {
|
||||||
|
return await downloadUrlToTempFile(params.source.path);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `unsupported remote plugin path source: ${params.source.path}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const resolved = path.isAbsolute(params.source.path)
|
||||||
|
? { ok: true as const, path: params.source.path }
|
||||||
|
: ensureInsideMarketplaceRoot(params.marketplaceRootDir, params.source.path);
|
||||||
|
if (!resolved.ok) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
return { ok: true, path: resolved.path };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
params.source.kind === "github" ||
|
||||||
|
params.source.kind === "git" ||
|
||||||
|
params.source.kind === "git-subdir"
|
||||||
|
) {
|
||||||
|
const sourceSpec =
|
||||||
|
params.source.kind === "github"
|
||||||
|
? `${params.source.repo}${params.source.ref ? `#${params.source.ref}` : ""}`
|
||||||
|
: `${params.source.url}${params.source.ref ? `#${params.source.ref}` : ""}`;
|
||||||
|
const cloned = await cloneMarketplaceRepo({
|
||||||
|
source: sourceSpec,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
logger: params.logger,
|
||||||
|
});
|
||||||
|
if (!cloned.ok) {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
const subPath =
|
||||||
|
params.source.kind === "github" || params.source.kind === "git"
|
||||||
|
? params.source.path?.trim() || "."
|
||||||
|
: params.source.path.trim();
|
||||||
|
const target = ensureInsideMarketplaceRoot(cloned.rootDir, subPath);
|
||||||
|
if (!target.ok) {
|
||||||
|
await cloned.cleanup();
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: target.path,
|
||||||
|
cleanup: cloned.cleanup,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolveArchiveKind(params.source.url)) {
|
||||||
|
return await downloadUrlToTempFile(params.source.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizeGitCloneSource(params.source.url)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `unsupported URL plugin source: ${params.source.url}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloned = await cloneMarketplaceRepo({
|
||||||
|
source: params.source.url,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
logger: params.logger,
|
||||||
|
});
|
||||||
|
if (!cloned.ok) {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
path: cloned.rootDir,
|
||||||
|
cleanup: cloned.cleanup,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMarketplacePlugins(params: {
|
||||||
|
marketplace: string;
|
||||||
|
logger?: MarketplaceLogger;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<MarketplacePluginListResult> {
|
||||||
|
const loaded = await loadMarketplace({
|
||||||
|
source: params.marketplace,
|
||||||
|
logger: params.logger,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
});
|
||||||
|
if (!loaded.ok) {
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
manifest: loaded.marketplace.manifest,
|
||||||
|
sourceLabel: loaded.marketplace.sourceLabel,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await loaded.marketplace.cleanup?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveMarketplaceInstallShortcut(
|
||||||
|
raw: string,
|
||||||
|
): Promise<MarketplaceShortcutResolution> {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const atIndex = trimmed.lastIndexOf("@");
|
||||||
|
if (atIndex <= 0 || atIndex >= trimmed.length - 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = trimmed.slice(0, atIndex).trim();
|
||||||
|
const marketplaceName = trimmed.slice(atIndex + 1).trim();
|
||||||
|
if (!plugin || !marketplaceName || plugin.includes("/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownMarketplaces = await readClaudeKnownMarketplaces();
|
||||||
|
const known = knownMarketplaces[marketplaceName];
|
||||||
|
if (!known) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (known.installLocation) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
plugin,
|
||||||
|
marketplaceName,
|
||||||
|
marketplaceSource: marketplaceName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSource = normalizeEntrySource(known.source);
|
||||||
|
if (!normalizedSource.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `known Claude marketplace "${marketplaceName}" has an invalid source: ${normalizedSource.error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
plugin,
|
||||||
|
marketplaceName,
|
||||||
|
marketplaceSource: marketplaceName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installPluginFromMarketplace(params: {
|
||||||
|
marketplace: string;
|
||||||
|
plugin: string;
|
||||||
|
logger?: MarketplaceLogger;
|
||||||
|
timeoutMs?: number;
|
||||||
|
mode?: "install" | "update";
|
||||||
|
dryRun?: boolean;
|
||||||
|
expectedPluginId?: string;
|
||||||
|
}): Promise<MarketplaceInstallResult> {
|
||||||
|
const loaded = await loadMarketplace({
|
||||||
|
source: params.marketplace,
|
||||||
|
logger: params.logger,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
});
|
||||||
|
if (!loaded.ok) {
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
let installCleanup: (() => Promise<void>) | undefined;
|
||||||
|
try {
|
||||||
|
const entry = loaded.marketplace.manifest.plugins.find(
|
||||||
|
(plugin) => plugin.name === params.plugin,
|
||||||
|
);
|
||||||
|
if (!entry) {
|
||||||
|
const known = loaded.marketplace.manifest.plugins.map((plugin) => plugin.name).toSorted();
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
`plugin "${params.plugin}" not found in marketplace ${loaded.marketplace.sourceLabel}` +
|
||||||
|
(known.length > 0 ? ` (available: ${known.join(", ")})` : ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await resolveMarketplaceEntryInstallPath({
|
||||||
|
source: entry.source,
|
||||||
|
marketplaceRootDir: loaded.marketplace.rootDir,
|
||||||
|
logger: params.logger,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
});
|
||||||
|
if (!resolved.ok) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
installCleanup = resolved.cleanup;
|
||||||
|
|
||||||
|
const result = await installPluginFromPath({
|
||||||
|
path: resolved.path,
|
||||||
|
logger: params.logger,
|
||||||
|
mode: params.mode,
|
||||||
|
dryRun: params.dryRun,
|
||||||
|
expectedPluginId: params.expectedPluginId,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
marketplaceName: loaded.marketplace.manifest.name,
|
||||||
|
marketplaceVersion: loaded.marketplace.manifest.version,
|
||||||
|
marketplacePlugin: entry.name,
|
||||||
|
marketplaceSource: params.marketplace,
|
||||||
|
marketplaceEntryVersion: entry.version,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await installCleanup?.();
|
||||||
|
await loaded.marketplace.cleanup?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const installPluginFromNpmSpecMock = vi.fn();
|
const installPluginFromNpmSpecMock = vi.fn();
|
||||||
|
const installPluginFromMarketplaceMock = vi.fn();
|
||||||
const resolveBundledPluginSourcesMock = vi.fn();
|
const resolveBundledPluginSourcesMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("./install.js", () => ({
|
vi.mock("./install.js", () => ({
|
||||||
@ -11,6 +12,10 @@ vi.mock("./install.js", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./marketplace.js", () => ({
|
||||||
|
installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplaceMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./bundled-sources.js", () => ({
|
vi.mock("./bundled-sources.js", () => ({
|
||||||
resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSourcesMock(...args),
|
resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSourcesMock(...args),
|
||||||
}));
|
}));
|
||||||
@ -18,6 +23,7 @@ vi.mock("./bundled-sources.js", () => ({
|
|||||||
describe("updateNpmInstalledPlugins", () => {
|
describe("updateNpmInstalledPlugins", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
installPluginFromNpmSpecMock.mockReset();
|
installPluginFromNpmSpecMock.mockReset();
|
||||||
|
installPluginFromMarketplaceMock.mockReset();
|
||||||
resolveBundledPluginSourcesMock.mockReset();
|
resolveBundledPluginSourcesMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -213,6 +219,95 @@ describe("updateNpmInstalledPlugins", () => {
|
|||||||
});
|
});
|
||||||
expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined();
|
expect(result.config.plugins?.installs?.["voice-call"]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("checks marketplace installs during dry-run updates", async () => {
|
||||||
|
installPluginFromMarketplaceMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
pluginId: "claude-bundle",
|
||||||
|
targetDir: "/tmp/claude-bundle",
|
||||||
|
version: "1.2.0",
|
||||||
|
extensions: ["index.ts"],
|
||||||
|
marketplaceSource: "vincentkoc/claude-marketplace",
|
||||||
|
marketplacePlugin: "claude-bundle",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { updateNpmInstalledPlugins } = await import("./update.js");
|
||||||
|
const result = await updateNpmInstalledPlugins({
|
||||||
|
config: {
|
||||||
|
plugins: {
|
||||||
|
installs: {
|
||||||
|
"claude-bundle": {
|
||||||
|
source: "marketplace",
|
||||||
|
marketplaceSource: "vincentkoc/claude-marketplace",
|
||||||
|
marketplacePlugin: "claude-bundle",
|
||||||
|
installPath: "/tmp/claude-bundle",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pluginIds: ["claude-bundle"],
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(installPluginFromMarketplaceMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
marketplace: "vincentkoc/claude-marketplace",
|
||||||
|
plugin: "claude-bundle",
|
||||||
|
expectedPluginId: "claude-bundle",
|
||||||
|
dryRun: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.outcomes).toEqual([
|
||||||
|
{
|
||||||
|
pluginId: "claude-bundle",
|
||||||
|
status: "updated",
|
||||||
|
currentVersion: undefined,
|
||||||
|
nextVersion: "1.2.0",
|
||||||
|
message: "Would update claude-bundle: unknown -> 1.2.0.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates marketplace installs and preserves source metadata", async () => {
|
||||||
|
installPluginFromMarketplaceMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
pluginId: "claude-bundle",
|
||||||
|
targetDir: "/tmp/claude-bundle",
|
||||||
|
version: "1.3.0",
|
||||||
|
extensions: ["index.ts"],
|
||||||
|
marketplaceName: "Vincent's Claude Plugins",
|
||||||
|
marketplaceSource: "vincentkoc/claude-marketplace",
|
||||||
|
marketplacePlugin: "claude-bundle",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { updateNpmInstalledPlugins } = await import("./update.js");
|
||||||
|
const result = await updateNpmInstalledPlugins({
|
||||||
|
config: {
|
||||||
|
plugins: {
|
||||||
|
installs: {
|
||||||
|
"claude-bundle": {
|
||||||
|
source: "marketplace",
|
||||||
|
marketplaceName: "Vincent's Claude Plugins",
|
||||||
|
marketplaceSource: "vincentkoc/claude-marketplace",
|
||||||
|
marketplacePlugin: "claude-bundle",
|
||||||
|
installPath: "/tmp/claude-bundle",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pluginIds: ["claude-bundle"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.changed).toBe(true);
|
||||||
|
expect(result.config.plugins?.installs?.["claude-bundle"]).toMatchObject({
|
||||||
|
source: "marketplace",
|
||||||
|
installPath: "/tmp/claude-bundle",
|
||||||
|
version: "1.3.0",
|
||||||
|
marketplaceName: "Vincent's Claude Plugins",
|
||||||
|
marketplaceSource: "vincentkoc/claude-marketplace",
|
||||||
|
marketplacePlugin: "claude-bundle",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("syncPluginsForUpdateChannel", () => {
|
describe("syncPluginsForUpdateChannel", () => {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
resolvePluginInstallDir,
|
resolvePluginInstallDir,
|
||||||
} from "./install.js";
|
} from "./install.js";
|
||||||
import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js";
|
import { buildNpmResolutionInstallFields, recordPluginInstall } from "./installs.js";
|
||||||
|
import { installPluginFromMarketplace } from "./marketplace.js";
|
||||||
|
|
||||||
export type PluginUpdateLogger = {
|
export type PluginUpdateLogger = {
|
||||||
info?: (message: string) => void;
|
info?: (message: string) => void;
|
||||||
@ -70,6 +71,19 @@ function formatNpmInstallFailure(params: {
|
|||||||
return `Failed to ${params.phase} ${params.pluginId}: ${params.result.error}`;
|
return `Failed to ${params.phase} ${params.pluginId}: ${params.result.error}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMarketplaceInstallFailure(params: {
|
||||||
|
pluginId: string;
|
||||||
|
marketplaceSource: string;
|
||||||
|
marketplacePlugin: string;
|
||||||
|
phase: "check" | "update";
|
||||||
|
error: string;
|
||||||
|
}): string {
|
||||||
|
return (
|
||||||
|
`Failed to ${params.phase} ${params.pluginId}: ` +
|
||||||
|
`${params.error} (marketplace plugin ${params.marketplacePlugin} from ${params.marketplaceSource}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type InstallIntegrityDrift = {
|
type InstallIntegrityDrift = {
|
||||||
spec: string;
|
spec: string;
|
||||||
expectedIntegrity: string;
|
expectedIntegrity: string;
|
||||||
@ -306,7 +320,7 @@ export async function updateNpmInstalledPlugins(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (record.source !== "npm") {
|
if (record.source !== "npm" && record.source !== "marketplace") {
|
||||||
outcomes.push({
|
outcomes.push({
|
||||||
pluginId,
|
pluginId,
|
||||||
status: "skipped",
|
status: "skipped",
|
||||||
@ -315,7 +329,7 @@ export async function updateNpmInstalledPlugins(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!record.spec) {
|
if (record.source === "npm" && !record.spec) {
|
||||||
outcomes.push({
|
outcomes.push({
|
||||||
pluginId,
|
pluginId,
|
||||||
status: "skipped",
|
status: "skipped",
|
||||||
@ -324,6 +338,18 @@ export async function updateNpmInstalledPlugins(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
record.source === "marketplace" &&
|
||||||
|
(!record.marketplaceSource || !record.marketplacePlugin)
|
||||||
|
) {
|
||||||
|
outcomes.push({
|
||||||
|
pluginId,
|
||||||
|
status: "skipped",
|
||||||
|
message: `Skipping "${pluginId}" (missing marketplace source metadata).`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let installPath: string;
|
let installPath: string;
|
||||||
try {
|
try {
|
||||||
installPath = record.installPath ?? resolvePluginInstallDir(pluginId);
|
installPath = record.installPath ?? resolvePluginInstallDir(pluginId);
|
||||||
@ -338,22 +364,34 @@ export async function updateNpmInstalledPlugins(params: {
|
|||||||
const currentVersion = await readInstalledPackageVersion(installPath);
|
const currentVersion = await readInstalledPackageVersion(installPath);
|
||||||
|
|
||||||
if (params.dryRun) {
|
if (params.dryRun) {
|
||||||
let probe: Awaited<ReturnType<typeof installPluginFromNpmSpec>>;
|
let probe:
|
||||||
|
| Awaited<ReturnType<typeof installPluginFromNpmSpec>>
|
||||||
|
| Awaited<ReturnType<typeof installPluginFromMarketplace>>;
|
||||||
try {
|
try {
|
||||||
probe = await installPluginFromNpmSpec({
|
probe =
|
||||||
spec: record.spec,
|
record.source === "npm"
|
||||||
mode: "update",
|
? await installPluginFromNpmSpec({
|
||||||
dryRun: true,
|
spec: record.spec!,
|
||||||
expectedPluginId: pluginId,
|
mode: "update",
|
||||||
expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity),
|
dryRun: true,
|
||||||
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
|
expectedPluginId: pluginId,
|
||||||
pluginId,
|
expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity),
|
||||||
dryRun: true,
|
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
|
||||||
logger,
|
pluginId,
|
||||||
onIntegrityDrift: params.onIntegrityDrift,
|
dryRun: true,
|
||||||
}),
|
logger,
|
||||||
logger,
|
onIntegrityDrift: params.onIntegrityDrift,
|
||||||
});
|
}),
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
: await installPluginFromMarketplace({
|
||||||
|
marketplace: record.marketplaceSource!,
|
||||||
|
plugin: record.marketplacePlugin!,
|
||||||
|
mode: "update",
|
||||||
|
dryRun: true,
|
||||||
|
expectedPluginId: pluginId,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
outcomes.push({
|
outcomes.push({
|
||||||
pluginId,
|
pluginId,
|
||||||
@ -366,12 +404,21 @@ export async function updateNpmInstalledPlugins(params: {
|
|||||||
outcomes.push({
|
outcomes.push({
|
||||||
pluginId,
|
pluginId,
|
||||||
status: "error",
|
status: "error",
|
||||||
message: formatNpmInstallFailure({
|
message:
|
||||||
pluginId,
|
record.source === "npm"
|
||||||
spec: record.spec,
|
? formatNpmInstallFailure({
|
||||||
phase: "check",
|
pluginId,
|
||||||
result: probe,
|
spec: record.spec!,
|
||||||
}),
|
phase: "check",
|
||||||
|
result: probe,
|
||||||
|
})
|
||||||
|
: formatMarketplaceInstallFailure({
|
||||||
|
pluginId,
|
||||||
|
marketplaceSource: record.marketplaceSource!,
|
||||||
|
marketplacePlugin: record.marketplacePlugin!,
|
||||||
|
phase: "check",
|
||||||
|
error: probe.error,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -398,21 +445,32 @@ export async function updateNpmInstalledPlugins(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: Awaited<ReturnType<typeof installPluginFromNpmSpec>>;
|
let result:
|
||||||
|
| Awaited<ReturnType<typeof installPluginFromNpmSpec>>
|
||||||
|
| Awaited<ReturnType<typeof installPluginFromMarketplace>>;
|
||||||
try {
|
try {
|
||||||
result = await installPluginFromNpmSpec({
|
result =
|
||||||
spec: record.spec,
|
record.source === "npm"
|
||||||
mode: "update",
|
? await installPluginFromNpmSpec({
|
||||||
expectedPluginId: pluginId,
|
spec: record.spec!,
|
||||||
expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity),
|
mode: "update",
|
||||||
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
|
expectedPluginId: pluginId,
|
||||||
pluginId,
|
expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity),
|
||||||
dryRun: false,
|
onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({
|
||||||
logger,
|
pluginId,
|
||||||
onIntegrityDrift: params.onIntegrityDrift,
|
dryRun: false,
|
||||||
}),
|
logger,
|
||||||
logger,
|
onIntegrityDrift: params.onIntegrityDrift,
|
||||||
});
|
}),
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
: await installPluginFromMarketplace({
|
||||||
|
marketplace: record.marketplaceSource!,
|
||||||
|
plugin: record.marketplacePlugin!,
|
||||||
|
mode: "update",
|
||||||
|
expectedPluginId: pluginId,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
outcomes.push({
|
outcomes.push({
|
||||||
pluginId,
|
pluginId,
|
||||||
@ -425,12 +483,21 @@ export async function updateNpmInstalledPlugins(params: {
|
|||||||
outcomes.push({
|
outcomes.push({
|
||||||
pluginId,
|
pluginId,
|
||||||
status: "error",
|
status: "error",
|
||||||
message: formatNpmInstallFailure({
|
message:
|
||||||
pluginId,
|
record.source === "npm"
|
||||||
spec: record.spec,
|
? formatNpmInstallFailure({
|
||||||
phase: "update",
|
pluginId,
|
||||||
result: result,
|
spec: record.spec!,
|
||||||
}),
|
phase: "update",
|
||||||
|
result: result,
|
||||||
|
})
|
||||||
|
: formatMarketplaceInstallFailure({
|
||||||
|
pluginId,
|
||||||
|
marketplaceSource: record.marketplaceSource!,
|
||||||
|
marketplacePlugin: record.marketplacePlugin!,
|
||||||
|
phase: "update",
|
||||||
|
error: result.error,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -441,14 +508,30 @@ export async function updateNpmInstalledPlugins(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
|
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
|
||||||
next = recordPluginInstall(next, {
|
if (record.source === "npm") {
|
||||||
pluginId: resolvedPluginId,
|
next = recordPluginInstall(next, {
|
||||||
source: "npm",
|
pluginId: resolvedPluginId,
|
||||||
spec: record.spec,
|
source: "npm",
|
||||||
installPath: result.targetDir,
|
spec: record.spec,
|
||||||
version: nextVersion,
|
installPath: result.targetDir,
|
||||||
...buildNpmResolutionInstallFields(result.npmResolution),
|
version: nextVersion,
|
||||||
});
|
...buildNpmResolutionInstallFields(result.npmResolution),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const marketplaceResult = result as Extract<
|
||||||
|
Awaited<ReturnType<typeof installPluginFromMarketplace>>,
|
||||||
|
{ ok: true }
|
||||||
|
>;
|
||||||
|
next = recordPluginInstall(next, {
|
||||||
|
pluginId: resolvedPluginId,
|
||||||
|
source: "marketplace",
|
||||||
|
installPath: result.targetDir,
|
||||||
|
version: nextVersion,
|
||||||
|
marketplaceName: marketplaceResult.marketplaceName ?? record.marketplaceName,
|
||||||
|
marketplaceSource: record.marketplaceSource,
|
||||||
|
marketplacePlugin: record.marketplacePlugin,
|
||||||
|
});
|
||||||
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
|
|
||||||
const currentLabel = currentVersion ?? "unknown";
|
const currentLabel = currentVersion ?? "unknown";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user