mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
[codex] harden clawhub plugin publishing and install (#56870)
* fix: harden clawhub plugin publishing and install * fix(process): preserve windows shim exit success
This commit is contained in:
@@ -52,7 +52,15 @@ and provider plugins have dedicated guides linked above.
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": ["./index.ts"],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -71,7 +79,8 @@ and provider plugins have dedicated guides linked above.
|
||||
</CodeGroup>
|
||||
|
||||
Every plugin needs a manifest, even with no config. See
|
||||
[Manifest](/plugins/manifest) for the full schema.
|
||||
[Manifest](/plugins/manifest) for the full schema. The canonical ClawHub
|
||||
publish snippets live in `docs/snippets/plugin-publish/`.
|
||||
|
||||
</Step>
|
||||
|
||||
@@ -107,13 +116,16 @@ and provider plugins have dedicated guides linked above.
|
||||
|
||||
<Step title="Test and publish">
|
||||
|
||||
**External plugins:** publish to [ClawHub](/tools/clawhub) or npm, then install:
|
||||
**External plugins:** validate and publish with ClawHub, then install:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @myorg/openclaw-my-plugin
|
||||
clawhub package publish your-org/your-plugin --dry-run
|
||||
clawhub package publish your-org/your-plugin
|
||||
openclaw plugins install clawhub:@myorg/openclaw-my-plugin
|
||||
```
|
||||
|
||||
OpenClaw checks ClawHub first, then falls back to npm.
|
||||
OpenClaw also checks ClawHub before npm for bare package specs like
|
||||
`@myorg/openclaw-my-plugin`.
|
||||
|
||||
**In-repo plugins:** place under the bundled plugin workspace tree — automatically discovered.
|
||||
|
||||
|
||||
@@ -32,7 +32,15 @@ API key auth, and dynamic model resolution.
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": ["./index.ts"],
|
||||
"providers": ["acme-ai"]
|
||||
"providers": ["acme-ai"],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -68,7 +76,9 @@ API key auth, and dynamic model resolution.
|
||||
</CodeGroup>
|
||||
|
||||
The manifest declares `providerAuthEnvVars` so OpenClaw can detect
|
||||
credentials without loading your plugin runtime.
|
||||
credentials without loading your plugin runtime. If you publish the
|
||||
provider on ClawHub, those `openclaw.compat` and `openclaw.build` fields
|
||||
are required in `package.json`.
|
||||
|
||||
</Step>
|
||||
|
||||
@@ -383,6 +393,18 @@ API key auth, and dynamic model resolution.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Publish to ClawHub
|
||||
|
||||
Provider plugins publish the same way as any other external code plugin:
|
||||
|
||||
```bash
|
||||
clawhub package publish your-org/your-plugin --dry-run
|
||||
clawhub package publish your-org/your-plugin
|
||||
```
|
||||
|
||||
Do not use the legacy skill-only publish alias here; plugin packages should use
|
||||
`clawhub package publish`.
|
||||
|
||||
## File structure
|
||||
|
||||
```
|
||||
|
||||
@@ -43,20 +43,31 @@ your plugin provides:
|
||||
}
|
||||
```
|
||||
|
||||
**Provider plugin:**
|
||||
**Provider plugin / ClawHub publish baseline:**
|
||||
|
||||
```json
|
||||
```json openclaw-clawhub-package.json
|
||||
{
|
||||
"name": "@myorg/openclaw-my-provider",
|
||||
"name": "@myorg/openclaw-my-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": ["./index.ts"],
|
||||
"providers": ["my-provider"]
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you publish the plugin externally on ClawHub, those `compat` and `build`
|
||||
fields are required. The canonical publish snippets live in
|
||||
`docs/snippets/plugin-publish/`.
|
||||
|
||||
### `openclaw` fields
|
||||
|
||||
| Field | Type | Description |
|
||||
@@ -147,6 +158,18 @@ Even plugins with no config must ship a schema. An empty schema is valid:
|
||||
|
||||
See [Plugin Manifest](/plugins/manifest) for the full schema reference.
|
||||
|
||||
## ClawHub publishing
|
||||
|
||||
For plugin packages, use the package-specific ClawHub command:
|
||||
|
||||
```bash
|
||||
clawhub package publish your-org/your-plugin --dry-run
|
||||
clawhub package publish your-org/your-plugin
|
||||
```
|
||||
|
||||
The legacy skill-only publish alias is for skills. Plugin packages should
|
||||
always use `clawhub package publish`.
|
||||
|
||||
## Setup entry
|
||||
|
||||
The `setup-entry.ts` file is a lightweight alternative to `index.ts` that
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "my-plugin",
|
||||
"name": "My Plugin",
|
||||
"description": "Adds a custom tool to OpenClaw",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
16
docs/snippets/plugin-publish/minimal-package.json
Normal file
16
docs/snippets/plugin-publish/minimal-package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@myorg/openclaw-my-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": ["./index.ts"],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
summary: "ClawHub guide: public registry, native OpenClaw install flows, and ClawHub CLI workflows"
|
||||
read_when:
|
||||
- Introducing ClawHub to new users
|
||||
- Installing, searching, or publishing skills
|
||||
- Installing, searching, or publishing skills or plugins
|
||||
- Explaining ClawHub CLI flags and sync behavior
|
||||
title: "ClawHub"
|
||||
---
|
||||
@@ -46,7 +46,7 @@ metadata so later `update` calls can stay on ClawHub.
|
||||
|
||||
## What ClawHub is
|
||||
|
||||
- A public registry for OpenClaw skills.
|
||||
- A public registry for OpenClaw skills and plugins.
|
||||
- A versioned store of skill bundles and metadata.
|
||||
- A discovery surface for search, tags, and usage signals.
|
||||
|
||||
@@ -201,15 +201,23 @@ List:
|
||||
|
||||
- `clawhub list` (reads `.clawhub/lock.json`)
|
||||
|
||||
Publish:
|
||||
Publish skills:
|
||||
|
||||
- `clawhub publish <path>`
|
||||
- `clawhub skill publish <path>`
|
||||
- `--slug <slug>`: Skill slug.
|
||||
- `--name <name>`: Display name.
|
||||
- `--version <version>`: Semver version.
|
||||
- `--changelog <text>`: Changelog text (can be empty).
|
||||
- `--tags <tags>`: Comma-separated tags (default: `latest`).
|
||||
|
||||
Publish plugins:
|
||||
|
||||
- `clawhub package publish <source>`
|
||||
- `<source>` can be a local folder, `owner/repo`, `owner/repo@ref`, or a GitHub URL.
|
||||
- `--dry-run`: Build the exact publish plan without uploading anything.
|
||||
- `--json`: Emit machine-readable output for CI.
|
||||
- `--source-repo`, `--source-commit`, `--source-ref`: Optional overrides when auto-detection is not enough.
|
||||
|
||||
Delete/undelete (owner/admin only):
|
||||
|
||||
- `clawhub delete <slug> --yes`
|
||||
@@ -251,7 +259,7 @@ clawhub update --all
|
||||
For a single skill folder:
|
||||
|
||||
```bash
|
||||
clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 --tags latest
|
||||
clawhub skill publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 --tags latest
|
||||
```
|
||||
|
||||
To scan and back up many skills at once:
|
||||
@@ -260,6 +268,36 @@ To scan and back up many skills at once:
|
||||
clawhub sync --all
|
||||
```
|
||||
|
||||
### Publish a plugin from GitHub
|
||||
|
||||
```bash
|
||||
clawhub package publish your-org/your-plugin --dry-run
|
||||
clawhub package publish your-org/your-plugin
|
||||
clawhub package publish your-org/your-plugin@v1.0.0
|
||||
clawhub package publish https://github.com/your-org/your-plugin
|
||||
```
|
||||
|
||||
Code plugins must include the required OpenClaw metadata in `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@myorg/openclaw-my-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": ["./index.ts"],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced details (technical)
|
||||
|
||||
### Versioning and tags
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
read_when:
|
||||
- 向新用户介绍 ClawHub
|
||||
- 安装、搜索或发布 Skills
|
||||
- 安装、搜索或发布 Skills 或插件
|
||||
- 说明 ClawHub CLI 标志和同步行为
|
||||
summary: ClawHub 指南:公共 Skills 注册中心 + CLI 工作流
|
||||
summary: ClawHub 指南:公共 Skills / 插件注册中心与 CLI 工作流
|
||||
title: ClawHub
|
||||
x-i18n:
|
||||
generated_at: "2026-02-01T21:42:32Z"
|
||||
@@ -16,9 +16,9 @@ x-i18n:
|
||||
|
||||
# ClawHub
|
||||
|
||||
ClawHub 是 **OpenClaw 的公共 Skills 注册中心**。它是一项免费服务:所有 Skills 都是公开的、开放的,所有人都可以查看、共享和复用。Skills 就是一个包含 `SKILL.md` 文件(以及辅助文本文件)的文件夹。你可以在网页应用中浏览 Skills,也可以使用 CLI 来搜索、安装、更新和发布 Skills。
|
||||
ClawHub 是 **OpenClaw 的公共 Skills 与插件注册中心**。你可以在网页应用中浏览资源,也可以使用 CLI 来搜索、安装、更新和发布 Skills / 插件。
|
||||
|
||||
网站:[clawhub.com](https://clawhub.com)
|
||||
网站:[clawhub.ai](https://clawhub.ai)
|
||||
|
||||
## 适用人群(新手友好)
|
||||
|
||||
@@ -112,15 +112,22 @@ pnpm add -g clawhub
|
||||
|
||||
- `clawhub list`(读取 `.clawhub/lock.json`)
|
||||
|
||||
发布:
|
||||
发布 Skills:
|
||||
|
||||
- `clawhub publish <path>`
|
||||
- `clawhub skill publish <path>`
|
||||
- `--slug <slug>`:Skills 标识符。
|
||||
- `--name <name>`:显示名称。
|
||||
- `--version <version>`:语义化版本号。
|
||||
- `--changelog <text>`:变更日志文本(可以为空)。
|
||||
- `--tags <tags>`:逗号分隔的标签(默认:`latest`)。
|
||||
|
||||
发布插件:
|
||||
|
||||
- `clawhub package publish <source>`
|
||||
- `<source>` 可以是本地文件夹、`owner/repo`、`owner/repo@ref` 或 GitHub URL。
|
||||
- `--dry-run`:只生成发布计划,不实际上传。
|
||||
- `--json`:为 CI 输出结构化 JSON。
|
||||
|
||||
删除/恢复(仅所有者/管理员):
|
||||
|
||||
- `clawhub delete <slug> --yes`
|
||||
@@ -162,7 +169,7 @@ clawhub update --all
|
||||
对于单个 Skills 文件夹:
|
||||
|
||||
```bash
|
||||
clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 --tags latest
|
||||
clawhub skill publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 --tags latest
|
||||
```
|
||||
|
||||
一次扫描并备份多个 Skills:
|
||||
@@ -171,6 +178,15 @@ clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 --t
|
||||
clawhub sync --all
|
||||
```
|
||||
|
||||
### 从 GitHub 发布插件
|
||||
|
||||
```bash
|
||||
clawhub package publish your-org/your-plugin --dry-run
|
||||
clawhub package publish your-org/your-plugin
|
||||
clawhub package publish your-org/your-plugin@v1.0.0
|
||||
clawhub package publish https://github.com/your-org/your-plugin
|
||||
```
|
||||
|
||||
## 高级详情(技术性)
|
||||
|
||||
### 版本管理和标签
|
||||
|
||||
@@ -1197,6 +1197,7 @@
|
||||
"@mariozechner/pi-tui": "0.63.2",
|
||||
"@modelcontextprotocol/sdk": "1.28.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@openclaw/plugin-package-contract": "workspace:*",
|
||||
"@sinclair/typebox": "0.34.49",
|
||||
"ajv": "^8.18.0",
|
||||
"chalk": "^5.6.2",
|
||||
|
||||
9
packages/plugin-package-contract/package.json
Normal file
9
packages/plugin-package-contract/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@openclaw/plugin-package-contract",
|
||||
"version": "0.0.0-private",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
85
packages/plugin-package-contract/src/index.test.ts
Normal file
85
packages/plugin-package-contract/src/index.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS,
|
||||
listMissingExternalCodePluginFieldPaths,
|
||||
normalizeExternalPluginCompatibility,
|
||||
validateExternalCodePluginPackageJson,
|
||||
} from "./index.js";
|
||||
|
||||
describe("@openclaw/plugin-package-contract", () => {
|
||||
it("normalizes the OpenClaw compatibility block for external plugins", () => {
|
||||
expect(
|
||||
normalizeExternalPluginCompatibility({
|
||||
version: "1.2.3",
|
||||
openclaw: {
|
||||
compat: {
|
||||
pluginApi: ">=2026.3.24-beta.2",
|
||||
minGatewayVersion: "2026.3.24-beta.2",
|
||||
},
|
||||
build: {
|
||||
openclawVersion: "2026.3.24-beta.2",
|
||||
pluginSdkVersion: "0.9.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
pluginApiRange: ">=2026.3.24-beta.2",
|
||||
builtWithOpenClawVersion: "2026.3.24-beta.2",
|
||||
pluginSdkVersion: "0.9.0",
|
||||
minGatewayVersion: "2026.3.24-beta.2",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to install.minHostVersion and package version when compatible", () => {
|
||||
expect(
|
||||
normalizeExternalPluginCompatibility({
|
||||
version: "1.2.3",
|
||||
openclaw: {
|
||||
compat: {
|
||||
pluginApi: ">=1.0.0",
|
||||
},
|
||||
install: {
|
||||
minHostVersion: "2026.3.24-beta.2",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
pluginApiRange: ">=1.0.0",
|
||||
builtWithOpenClawVersion: "1.2.3",
|
||||
minGatewayVersion: "2026.3.24-beta.2",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists the required external code-plugin fields", () => {
|
||||
expect(EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS).toEqual([
|
||||
"openclaw.compat.pluginApi",
|
||||
"openclaw.build.openclawVersion",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports missing required fields with stable field paths", () => {
|
||||
const packageJson = {
|
||||
openclaw: {
|
||||
compat: {},
|
||||
build: {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(listMissingExternalCodePluginFieldPaths(packageJson)).toEqual([
|
||||
"openclaw.compat.pluginApi",
|
||||
"openclaw.build.openclawVersion",
|
||||
]);
|
||||
expect(validateExternalCodePluginPackageJson(packageJson).issues).toEqual([
|
||||
{
|
||||
fieldPath: "openclaw.compat.pluginApi",
|
||||
message:
|
||||
"openclaw.compat.pluginApi is required for external code plugins published to ClawHub.",
|
||||
},
|
||||
{
|
||||
fieldPath: "openclaw.build.openclawVersion",
|
||||
message:
|
||||
"openclaw.build.openclawVersion is required for external code plugins published to ClawHub.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
96
packages/plugin-package-contract/src/index.ts
Normal file
96
packages/plugin-package-contract/src/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export type ExternalPluginCompatibility = {
|
||||
pluginApiRange?: string;
|
||||
builtWithOpenClawVersion?: string;
|
||||
pluginSdkVersion?: string;
|
||||
minGatewayVersion?: string;
|
||||
};
|
||||
|
||||
export type ExternalPluginValidationIssue = {
|
||||
fieldPath: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ExternalCodePluginValidationResult = {
|
||||
compatibility?: ExternalPluginCompatibility;
|
||||
issues: ExternalPluginValidationIssue[];
|
||||
};
|
||||
|
||||
export const 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, openclaw, compat, build, install };
|
||||
}
|
||||
|
||||
export function normalizeExternalPluginCompatibility(
|
||||
packageJson: unknown,
|
||||
): ExternalPluginCompatibility | undefined {
|
||||
const { root, compat, build, install } = readOpenClawBlock(packageJson);
|
||||
const version = getTrimmedString(root?.version);
|
||||
const minHostVersion = getTrimmedString(install?.minHostVersion);
|
||||
const compatibility: ExternalPluginCompatibility = {};
|
||||
|
||||
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 listMissingExternalCodePluginFieldPaths(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 validateExternalCodePluginPackageJson(
|
||||
packageJson: unknown,
|
||||
): ExternalCodePluginValidationResult {
|
||||
const issues = listMissingExternalCodePluginFieldPaths(packageJson).map((fieldPath) => ({
|
||||
fieldPath,
|
||||
message: `${fieldPath} is required for external code plugins published to ClawHub.`,
|
||||
}));
|
||||
return {
|
||||
compatibility: normalizeExternalPluginCompatibility(packageJson),
|
||||
issues,
|
||||
};
|
||||
}
|
||||
5
pnpm-lock.yaml
generated
5
pnpm-lock.yaml
generated
@@ -69,6 +69,9 @@ importers:
|
||||
'@napi-rs/canvas':
|
||||
specifier: ^0.1.89
|
||||
version: 0.1.92
|
||||
'@openclaw/plugin-package-contract':
|
||||
specifier: workspace:*
|
||||
version: link:packages/plugin-package-contract
|
||||
'@sinclair/typebox':
|
||||
specifier: 0.34.49
|
||||
version: 0.34.49
|
||||
@@ -697,6 +700,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
packages/plugin-package-contract: {}
|
||||
|
||||
ui:
|
||||
dependencies:
|
||||
'@create-markdown/preview':
|
||||
|
||||
@@ -8,6 +8,7 @@ const downloadClawHubSkillArchiveMock = vi.fn();
|
||||
const listClawHubSkillsMock = vi.fn();
|
||||
const resolveClawHubBaseUrlMock = vi.fn(() => "https://clawhub.ai");
|
||||
const searchClawHubSkillsMock = vi.fn();
|
||||
const archiveCleanupMock = vi.fn();
|
||||
const withExtractedArchiveRootMock = vi.fn();
|
||||
const installPackageDirMock = vi.fn();
|
||||
const fileExistsMock = vi.fn();
|
||||
@@ -42,6 +43,7 @@ describe("skills-clawhub", () => {
|
||||
listClawHubSkillsMock.mockReset();
|
||||
resolveClawHubBaseUrlMock.mockReset();
|
||||
searchClawHubSkillsMock.mockReset();
|
||||
archiveCleanupMock.mockReset();
|
||||
withExtractedArchiveRootMock.mockReset();
|
||||
installPackageDirMock.mockReset();
|
||||
fileExistsMock.mockReset();
|
||||
@@ -63,7 +65,9 @@ describe("skills-clawhub", () => {
|
||||
downloadClawHubSkillArchiveMock.mockResolvedValue({
|
||||
archivePath: "/tmp/agentreceipt.zip",
|
||||
integrity: "sha256-test",
|
||||
cleanup: archiveCleanupMock,
|
||||
});
|
||||
archiveCleanupMock.mockResolvedValue(undefined);
|
||||
searchClawHubSkillsMock.mockResolvedValue([]);
|
||||
withExtractedArchiveRootMock.mockImplementation(async (params) => {
|
||||
expect(params.rootMarkers).toEqual(["SKILL.md"]);
|
||||
@@ -97,6 +101,7 @@ describe("skills-clawhub", () => {
|
||||
version: "1.0.0",
|
||||
targetDir: "/tmp/workspace/skills/agentreceipt",
|
||||
});
|
||||
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("legacy tracked slugs remain updatable", () => {
|
||||
|
||||
@@ -331,10 +331,7 @@ async function performClawHubSkillInstall(
|
||||
detail,
|
||||
};
|
||||
} finally {
|
||||
await fs.rm(archive.archivePath, { force: true }).catch(() => undefined);
|
||||
await fs
|
||||
.rm(path.dirname(archive.archivePath), { recursive: true, force: true })
|
||||
.catch(() => undefined);
|
||||
await archive.cleanup().catch(() => undefined);
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
71
src/docs/clawhub-plugin-docs.test.ts
Normal file
71
src/docs/clawhub-plugin-docs.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.js";
|
||||
|
||||
const DOCS_ROOT = path.join(process.cwd(), "docs");
|
||||
const pluginDocs = [
|
||||
path.join(DOCS_ROOT, "tools", "clawhub.md"),
|
||||
path.join(DOCS_ROOT, "plugins", "building-plugins.md"),
|
||||
path.join(DOCS_ROOT, "plugins", "sdk-setup.md"),
|
||||
path.join(DOCS_ROOT, "plugins", "sdk-provider-plugins.md"),
|
||||
];
|
||||
|
||||
function extractNamedJsonBlock(markdown: string, label: string) {
|
||||
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = markdown.match(
|
||||
new RegExp(
|
||||
`^[ \\t]*\\\`\\\`\\\`json ${escapedLabel}\\n([\\s\\S]*?)\\n[ \\t]*\\\`\\\`\\\``,
|
||||
"m",
|
||||
),
|
||||
);
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`Missing json code block for ${label}`);
|
||||
}
|
||||
return JSON.parse(match[1].trim()) as unknown;
|
||||
}
|
||||
|
||||
describe("ClawHub plugin docs", () => {
|
||||
it("keeps the canonical plugin-publish snippets contract-valid", async () => {
|
||||
const packageJson = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(DOCS_ROOT, "snippets", "plugin-publish", "minimal-package.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as unknown;
|
||||
const pluginManifest = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(DOCS_ROOT, "snippets", "plugin-publish", "minimal-openclaw.plugin.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { id?: unknown; configSchema?: unknown };
|
||||
|
||||
expect(validateExternalCodePluginPackageJson(packageJson).issues).toEqual([]);
|
||||
expect(typeof pluginManifest.id).toBe("string");
|
||||
expect(pluginManifest.configSchema).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not tell plugin authors to use bare clawhub publish", async () => {
|
||||
for (const docPath of pluginDocs) {
|
||||
const markdown = await fs.readFile(docPath, "utf8");
|
||||
expect(markdown).not.toMatch(/(^|[\s`])clawhub publish\b/);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the canonical package snippet embedded in the primary plugin docs", async () => {
|
||||
const snippet = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(DOCS_ROOT, "snippets", "plugin-publish", "minimal-package.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as unknown;
|
||||
const buildingPlugins = await fs.readFile(
|
||||
path.join(DOCS_ROOT, "plugins", "building-plugins.md"),
|
||||
"utf8",
|
||||
);
|
||||
const sdkSetup = await fs.readFile(path.join(DOCS_ROOT, "plugins", "sdk-setup.md"), "utf8");
|
||||
|
||||
expect(extractNamedJsonBlock(buildingPlugins, "package.json")).toEqual(snippet);
|
||||
expect(extractNamedJsonBlock(sdkSetup, "openclaw-clawhub-package.json")).toEqual(snippet);
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
downloadClawHubSkillArchive,
|
||||
parseClawHubPluginSpec,
|
||||
resolveClawHubAuthToken,
|
||||
searchClawHubSkills,
|
||||
resolveLatestVersionFromPackage,
|
||||
satisfiesGatewayMinimum,
|
||||
satisfiesPluginApiRange,
|
||||
searchClawHubSkills,
|
||||
} from "./clawhub.js";
|
||||
|
||||
describe("clawhub helpers", () => {
|
||||
@@ -166,10 +166,10 @@ describe("clawhub helpers", () => {
|
||||
|
||||
await expect(searchClawHubSkills({ query: "calendar", fetchImpl })).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("writes scoped package archives to a safe temp file name", async () => {
|
||||
it("downloads package archives to sanitized temp paths and cleans them up", async () => {
|
||||
const archive = await downloadClawHubPackageArchive({
|
||||
name: "@soimy/dingtalk",
|
||||
name: "@hyf/zai-external-alpha",
|
||||
version: "0.0.1",
|
||||
fetchImpl: async () =>
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
@@ -178,16 +178,20 @@ describe("clawhub helpers", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
expect(path.basename(archive.archivePath)).toBe("@soimy__dingtalk.zip");
|
||||
expect(path.basename(archive.archivePath)).toBe("zai-external-alpha.zip");
|
||||
expect(archive.archivePath.includes("@hyf")).toBe(false);
|
||||
await expect(fs.readFile(archive.archivePath)).resolves.toEqual(Buffer.from([1, 2, 3]));
|
||||
} finally {
|
||||
await fs.rm(path.dirname(archive.archivePath), { recursive: true, force: true });
|
||||
const archiveDir = path.dirname(archive.archivePath);
|
||||
await archive.cleanup();
|
||||
await expect(fs.stat(archiveDir)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("writes skill archives to a safe temp file name when slugs contain separators", async () => {
|
||||
it("downloads skill archives to sanitized temp paths and cleans them up", async () => {
|
||||
const archive = await downloadClawHubSkillArchive({
|
||||
slug: "ops/calendar",
|
||||
slug: "agentreceipt",
|
||||
version: "1.0.0",
|
||||
fetchImpl: async () =>
|
||||
new Response(new Uint8Array([4, 5, 6]), {
|
||||
status: 200,
|
||||
@@ -196,10 +200,12 @@ describe("clawhub helpers", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
expect(path.basename(archive.archivePath)).toBe("ops__calendar.zip");
|
||||
expect(path.basename(archive.archivePath)).toBe("agentreceipt.zip");
|
||||
await expect(fs.readFile(archive.archivePath)).resolves.toEqual(Buffer.from([4, 5, 6]));
|
||||
} finally {
|
||||
await fs.rm(path.dirname(archive.archivePath), { recursive: true, force: true });
|
||||
const archiveDir = path.dirname(archive.archivePath);
|
||||
await archive.cleanup();
|
||||
await expect(fs.stat(archiveDir)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,21 +2,17 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { safeDirName } from "./install-safe-path.js";
|
||||
import type { ExternalPluginCompatibility } from "@openclaw/plugin-package-contract";
|
||||
import { isAtLeast, parseSemver } from "./runtime-guard.js";
|
||||
import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js";
|
||||
import { createTempDownloadTarget } from "./temp-download.js";
|
||||
|
||||
const DEFAULT_CLAWHUB_URL = "https://clawhub.ai";
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
export type ClawHubPackageFamily = "skill" | "code-plugin" | "bundle-plugin";
|
||||
export type ClawHubPackageChannel = "official" | "community" | "private";
|
||||
export type ClawHubPackageCompatibility = {
|
||||
pluginApiRange?: string;
|
||||
builtWithOpenClawVersion?: string;
|
||||
minGatewayVersion?: string;
|
||||
};
|
||||
|
||||
export type ClawHubPackageCompatibility = ExternalPluginCompatibility;
|
||||
export type ClawHubPackageListItem = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
@@ -33,7 +29,6 @@ export type ClawHubPackageListItem = {
|
||||
executesCode?: boolean;
|
||||
verificationTier?: string | null;
|
||||
};
|
||||
|
||||
export type ClawHubPackageDetail = {
|
||||
package:
|
||||
| (ClawHubPackageListItem & {
|
||||
@@ -158,6 +153,7 @@ export type ClawHubSkillListResponse = {
|
||||
export type ClawHubDownloadResult = {
|
||||
archivePath: string;
|
||||
integrity: string;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
@@ -580,12 +576,16 @@ export async function downloadClawHubPackageArchive(params: {
|
||||
});
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-package-"));
|
||||
const archivePath = path.join(tmpDir, `${safeDirName(params.name)}.zip`);
|
||||
await fs.writeFile(archivePath, bytes);
|
||||
const target = await createTempDownloadTarget({
|
||||
prefix: "openclaw-clawhub-package",
|
||||
fileName: `${params.name}.zip`,
|
||||
tmpDir: os.tmpdir(),
|
||||
});
|
||||
await fs.writeFile(target.path, bytes);
|
||||
return {
|
||||
archivePath,
|
||||
archivePath: target.path,
|
||||
integrity: formatSha256Integrity(bytes),
|
||||
cleanup: target.cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -618,12 +618,16 @@ export async function downloadClawHubSkillArchive(params: {
|
||||
});
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-clawhub-skill-"));
|
||||
const archivePath = path.join(tmpDir, `${safeDirName(params.slug)}.zip`);
|
||||
await fs.writeFile(archivePath, bytes);
|
||||
const target = await createTempDownloadTarget({
|
||||
prefix: "openclaw-clawhub-skill",
|
||||
fileName: `${params.slug}.zip`,
|
||||
tmpDir: os.tmpdir(),
|
||||
});
|
||||
await fs.writeFile(target.path, bytes);
|
||||
return {
|
||||
archivePath,
|
||||
archivePath: target.path,
|
||||
integrity: formatSha256Integrity(bytes),
|
||||
cleanup: target.cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
107
src/infra/temp-download.ts
Normal file
107
src/infra/temp-download.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import crypto from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
|
||||
|
||||
export { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
|
||||
|
||||
export type TempDownloadTarget = {
|
||||
dir: string;
|
||||
path: string;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
function sanitizePrefix(prefix: string): string {
|
||||
const normalized = prefix.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return normalized || "tmp";
|
||||
}
|
||||
|
||||
function sanitizeExtension(extension?: string): string {
|
||||
if (!extension) {
|
||||
return "";
|
||||
}
|
||||
const normalized = extension.startsWith(".") ? extension : `.${extension}`;
|
||||
const suffix = normalized.match(/[a-zA-Z0-9._-]+$/)?.[0] ?? "";
|
||||
const token = suffix.replace(/^[._-]+/, "");
|
||||
return token ? `.${token}` : "";
|
||||
}
|
||||
|
||||
export function sanitizeTempFileName(fileName: string): string {
|
||||
const base = path.basename(fileName).replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
const normalized = base.replace(/^-+|-+$/g, "");
|
||||
return normalized || "download.bin";
|
||||
}
|
||||
|
||||
function resolveTempRoot(tmpDir?: string): string {
|
||||
return tmpDir ?? resolvePreferredOpenClawTmpDir();
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(err: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupTempDir(dir: string) {
|
||||
try {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
if (!isNodeErrorWithCode(err, "ENOENT")) {
|
||||
console.warn(`temp-path cleanup failed for ${dir}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRandomTempFilePath(params: {
|
||||
prefix: string;
|
||||
extension?: string;
|
||||
tmpDir?: string;
|
||||
now?: number;
|
||||
uuid?: string;
|
||||
}): string {
|
||||
const prefix = sanitizePrefix(params.prefix);
|
||||
const extension = sanitizeExtension(params.extension);
|
||||
const nowCandidate = params.now;
|
||||
const now =
|
||||
typeof nowCandidate === "number" && Number.isFinite(nowCandidate)
|
||||
? Math.trunc(nowCandidate)
|
||||
: Date.now();
|
||||
const uuid = params.uuid?.trim() || crypto.randomUUID();
|
||||
return path.join(resolveTempRoot(params.tmpDir), `${prefix}-${now}-${uuid}${extension}`);
|
||||
}
|
||||
|
||||
export async function createTempDownloadTarget(params: {
|
||||
prefix: string;
|
||||
fileName?: string;
|
||||
tmpDir?: string;
|
||||
}): Promise<TempDownloadTarget> {
|
||||
const tempRoot = resolveTempRoot(params.tmpDir);
|
||||
const prefix = `${sanitizePrefix(params.prefix)}-`;
|
||||
const dir = await mkdtemp(path.join(tempRoot, prefix));
|
||||
return {
|
||||
dir,
|
||||
path: path.join(dir, sanitizeTempFileName(params.fileName ?? "download.bin")),
|
||||
cleanup: async () => {
|
||||
await cleanupTempDir(dir);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function withTempDownloadPath<T>(
|
||||
params: {
|
||||
prefix: string;
|
||||
fileName?: string;
|
||||
tmpDir?: string;
|
||||
},
|
||||
fn: (tmpPath: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const target = await createTempDownloadTarget(params);
|
||||
try {
|
||||
return await fn(target.path);
|
||||
} finally {
|
||||
await target.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -1,88 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
|
||||
export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
|
||||
function sanitizePrefix(prefix: string): string {
|
||||
const normalized = prefix.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return normalized || "tmp";
|
||||
}
|
||||
|
||||
function sanitizeExtension(extension?: string): string {
|
||||
if (!extension) {
|
||||
return "";
|
||||
}
|
||||
const normalized = extension.startsWith(".") ? extension : `.${extension}`;
|
||||
const suffix = normalized.match(/[a-zA-Z0-9._-]+$/)?.[0] ?? "";
|
||||
const token = suffix.replace(/^[._-]+/, "");
|
||||
if (!token) {
|
||||
return "";
|
||||
}
|
||||
return `.${token}`;
|
||||
}
|
||||
|
||||
function sanitizeFileName(fileName: string): string {
|
||||
const base = path.basename(fileName).replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
const normalized = base.replace(/^-+|-+$/g, "");
|
||||
return normalized || "download.bin";
|
||||
}
|
||||
|
||||
function resolveTempRoot(tmpDir?: string): string {
|
||||
return tmpDir ?? resolvePreferredOpenClawTmpDir();
|
||||
}
|
||||
|
||||
function isNodeErrorWithCode(err: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: string }).code === code
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a unique temp file path with sanitized prefix/extension parts. */
|
||||
export function buildRandomTempFilePath(params: {
|
||||
prefix: string;
|
||||
extension?: string;
|
||||
tmpDir?: string;
|
||||
now?: number;
|
||||
uuid?: string;
|
||||
}): string {
|
||||
const prefix = sanitizePrefix(params.prefix);
|
||||
const extension = sanitizeExtension(params.extension);
|
||||
const nowCandidate = params.now;
|
||||
const now =
|
||||
typeof nowCandidate === "number" && Number.isFinite(nowCandidate)
|
||||
? Math.trunc(nowCandidate)
|
||||
: Date.now();
|
||||
const uuid = params.uuid?.trim() || crypto.randomUUID();
|
||||
return path.join(resolveTempRoot(params.tmpDir), `${prefix}-${now}-${uuid}${extension}`);
|
||||
}
|
||||
|
||||
/** Create a temporary download directory, run the callback, then clean it up best-effort. */
|
||||
export async function withTempDownloadPath<T>(
|
||||
params: {
|
||||
prefix: string;
|
||||
fileName?: string;
|
||||
tmpDir?: string;
|
||||
},
|
||||
fn: (tmpPath: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const tempRoot = resolveTempRoot(params.tmpDir);
|
||||
const prefix = `${sanitizePrefix(params.prefix)}-`;
|
||||
const dir = await mkdtemp(path.join(tempRoot, prefix));
|
||||
const tmpPath = path.join(dir, sanitizeFileName(params.fileName ?? "download.bin"));
|
||||
try {
|
||||
return await fn(tmpPath);
|
||||
} finally {
|
||||
try {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
if (!isNodeErrorWithCode(err, "ENOENT")) {
|
||||
console.warn(`temp-path cleanup failed for ${dir}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export {
|
||||
buildRandomTempFilePath,
|
||||
createTempDownloadTarget,
|
||||
resolvePreferredOpenClawTmpDir,
|
||||
sanitizeTempFileName,
|
||||
withTempDownloadPath,
|
||||
} from "../infra/temp-download.js";
|
||||
|
||||
@@ -4,6 +4,7 @@ const parseClawHubPluginSpecMock = vi.fn();
|
||||
const fetchClawHubPackageDetailMock = vi.fn();
|
||||
const fetchClawHubPackageVersionMock = vi.fn();
|
||||
const downloadClawHubPackageArchiveMock = vi.fn();
|
||||
const archiveCleanupMock = vi.fn();
|
||||
const resolveLatestVersionFromPackageMock = vi.fn();
|
||||
const resolveCompatibilityHostVersionMock = vi.fn();
|
||||
const installPluginFromArchiveMock = vi.fn();
|
||||
@@ -102,6 +103,7 @@ describe("installPluginFromClawHub", () => {
|
||||
fetchClawHubPackageDetailMock.mockReset();
|
||||
fetchClawHubPackageVersionMock.mockReset();
|
||||
downloadClawHubPackageArchiveMock.mockReset();
|
||||
archiveCleanupMock.mockReset();
|
||||
resolveLatestVersionFromPackageMock.mockReset();
|
||||
resolveCompatibilityHostVersionMock.mockReset();
|
||||
installPluginFromArchiveMock.mockReset();
|
||||
@@ -137,7 +139,9 @@ describe("installPluginFromClawHub", () => {
|
||||
downloadClawHubPackageArchiveMock.mockResolvedValue({
|
||||
archivePath: "/tmp/clawhub-demo/archive.zip",
|
||||
integrity: "sha256-demo",
|
||||
cleanup: archiveCleanupMock,
|
||||
});
|
||||
archiveCleanupMock.mockResolvedValue(undefined);
|
||||
resolveCompatibilityHostVersionMock.mockReturnValue("2026.3.22");
|
||||
installPluginFromArchiveMock.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -171,6 +175,25 @@ describe("installPluginFromClawHub", () => {
|
||||
"Compatibility: pluginApi=>=2026.3.22 minGateway=2026.3.0",
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cleans up the downloaded archive even when archive install fails", async () => {
|
||||
installPluginFromArchiveMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: "bad archive",
|
||||
});
|
||||
|
||||
const result = await installPluginFromClawHub({
|
||||
spec: "clawhub:demo",
|
||||
baseUrl: "https://clawhub.ai",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: "bad archive",
|
||||
});
|
||||
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ClawHubRequestError,
|
||||
downloadClawHubPackageArchive,
|
||||
@@ -343,9 +341,6 @@ export async function installPluginFromClawHub(params: {
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await fs.rm(archive.archivePath, { force: true }).catch(() => undefined);
|
||||
await fs
|
||||
.rm(path.dirname(archive.archivePath), { recursive: true, force: true })
|
||||
.catch(() => undefined);
|
||||
await archive.cleanup().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,8 @@ export async function runCommandWithTimeout(
|
||||
const finalArgv = process.platform === "win32" ? (resolveNpmArgvForWindows(argv) ?? argv) : argv;
|
||||
const resolvedCommand = finalArgv !== argv ? (finalArgv[0] ?? "") : resolveCommand(argv[0] ?? "");
|
||||
const useCmdWrapper = isWindowsBatchCommand(resolvedCommand);
|
||||
const usesWindowsExitCodeShim =
|
||||
process.platform === "win32" && (useCmdWrapper || finalArgv !== argv);
|
||||
const child = spawn(
|
||||
useCmdWrapper ? (process.env.ComSpec ?? "cmd.exe") : resolvedCommand,
|
||||
useCmdWrapper
|
||||
@@ -341,8 +343,18 @@ export async function runCommandWithTimeout(
|
||||
clearTimeout(timer);
|
||||
clearNoOutputTimer();
|
||||
clearCloseFallbackTimer();
|
||||
const resolvedCode = childExitState?.code ?? code ?? child.exitCode ?? null;
|
||||
const resolvedSignal = childExitState?.signal ?? signal ?? child.signalCode ?? null;
|
||||
const resolvedCode =
|
||||
childExitState?.code ??
|
||||
code ??
|
||||
child.exitCode ??
|
||||
(usesWindowsExitCodeShim &&
|
||||
resolvedSignal == null &&
|
||||
!timedOut &&
|
||||
!noOutputTimedOut &&
|
||||
!child.killed
|
||||
? 0
|
||||
: null);
|
||||
const termination = noOutputTimedOut
|
||||
? "no-output-timeout"
|
||||
: timedOut
|
||||
|
||||
@@ -143,6 +143,25 @@ describe("windows command wrapper behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("treats shimmed Windows commands without a reported exit code as success when they close cleanly", async () => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
const child = createMockChild({
|
||||
closeCode: null,
|
||||
exitCode: null,
|
||||
});
|
||||
|
||||
spawnMock.mockImplementation(() => child);
|
||||
|
||||
try {
|
||||
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 1000 });
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.termination).toBe("exit");
|
||||
} finally {
|
||||
platformSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses cmd.exe wrapper with windowsVerbatimArguments in runExec for .cmd shims", async () => {
|
||||
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
const expectedComSpec = process.env.ComSpec ?? "cmd.exe";
|
||||
|
||||
Reference in New Issue
Block a user