@tokensapi/dsh-plugin-check 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -79
- package/bin/dsh-plugin-check.mjs +113 -113
- package/cordis.patch.yml +3 -0
- package/lib/cowork-plugin.mjs +78 -0
- package/lib/fetch-package.mjs +44 -44
- package/lib/manifest-check.mjs +144 -144
- package/lib/npm.mjs +13 -13
- package/lib/patch-rows.mjs +86 -86
- package/lib/registry-check.mjs +158 -0
- package/lib/smoke-check.mjs +174 -174
- package/package.json +44 -30
package/README.md
CHANGED
|
@@ -1,79 +1,79 @@
|
|
|
1
|
-
# dsh-plugin-check
|
|
2
|
-
|
|
3
|
-
TokensCowork / DSH 插件合格性体检。给所有插件开发者的统一标准:一条命令,当场知道你的插件**能否安全装入宿主、会不会把用户的 Cowork 搞崩**。同一套规则同时用于:开发者本地自查、插件仓库 CI、插件市场上架门禁。
|
|
4
|
-
|
|
5
|
-
## 用法
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
# 在插件目录里
|
|
9
|
-
npx @tokensapi/dsh-plugin-check
|
|
10
|
-
|
|
11
|
-
# 指定目标运行时(启用兼容范围判定;当前产品运行时版本见市场公告)
|
|
12
|
-
npx @tokensapi/dsh-plugin-check --runtime 0.1.3-alpha.1
|
|
13
|
-
|
|
14
|
-
# 只跑离线清单体检(秒级,无网络)
|
|
15
|
-
npx @tokensapi/dsh-plugin-check --skip-smoke
|
|
16
|
-
|
|
17
|
-
# 机器可读输出(CI / 市场门禁)
|
|
18
|
-
npx @tokensapi/dsh-plugin-check --json
|
|
19
|
-
|
|
20
|
-
# 体检一个已发布的 npm 包(市场上架审核用)
|
|
21
|
-
npx @tokensapi/dsh-plugin-check --package @scope/name@1.2.3 --runtime 0.1.3-alpha.1
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
退出码:`0` 合格,`1` 存在 error(`--strict` 时 warning 也计入)。冒烟失败时加 `--keep-workspace` 保留临时工作区排查。
|
|
25
|
-
|
|
26
|
-
## 体检分两个阶段
|
|
27
|
-
|
|
28
|
-
**阶段① 清单体检**(纯静态、离线、秒级):检查 package.json 与 cordis 补丁的声明是否合规。
|
|
29
|
-
**阶段② 隔离启动冒烟**(联网、约 1–3 分钟):在临时工作区按你声明的 peer 范围装出真实运行时,然后逐补丁行做真实网关启动时做的事——解析入口 → `import` → `new Context()` + `ctx.plugin()` 应用。三段都过,你的插件就不会以"装上即崩"的方式杀死用户的 Cowork。全程 `--ignore-scripts`,你的代码只在一次性子进程里运行,超时即杀。
|
|
30
|
-
|
|
31
|
-
清单存在 error 时冒烟不执行——先修再跑。
|
|
32
|
-
|
|
33
|
-
## 规则清单
|
|
34
|
-
|
|
35
|
-
每条规则都对应一种真实发生过的宿主故障。
|
|
36
|
-
|
|
37
|
-
| 规则 | 级别 | 内容 | 拦的是什么事故 |
|
|
38
|
-
|---|---|---|---|
|
|
39
|
-
| M1-manifest | error/warning | package.json 合法,name/version 齐全;预发布版本给出上架提示 | 无法入库 |
|
|
40
|
-
| M2-lifecycle | error | 禁止 `preinstall/install/postinstall/prepare` 脚本(构建用 `prepack`) | 安装即执行任意代码;市场受控安装的既有红线 |
|
|
41
|
-
| M3-core-peer | error | `@deepseek-ai/cordis*`、`@deepseek-ai/dsh*` 只能是 peerDependencies | 双内核实例 → Symbol 不等 → 服务注册对不上 → **Cowork 启动失败** |
|
|
42
|
-
| M4-bundle | error | `dsh.bundle.patch` 必填、文件存在、能解析出插件行 | 宿主无法把插件挂进加载树 |
|
|
43
|
-
| M5-row-scope | error | 补丁行只能指向本包(或其子路径) | 插件行劫持挂载其他包 |
|
|
44
|
-
| M6-engine | error/warning | `dsh.engine` 声明目标运行时 SemVer 范围;与 `--runtime` 不相交为 error(未声明当前为 warning,将转必填) | 装进不适配的宿主版本 |
|
|
45
|
-
| M7-peer-range | warning | 各 `dsh-*` peer 范围应包含目标运行时 | 宿主升级后接口错配,运行时断裂 |
|
|
46
|
-
| M8-node-engines | warning | 建议声明 `engines.node`(宿主为 Node 22+) | 语法/API 不可用 |
|
|
47
|
-
| M9-license | warning | 建议声明 license | 分发合规 |
|
|
48
|
-
| M10-secrets | error/warning | 发布内容不得包含 `.env`、私钥等凭据样式文件 | 凭据泄露 |
|
|
49
|
-
| S1-pack | error | `npm pack --ignore-scripts` 必须成功 | 包本身发布不出来 |
|
|
50
|
-
| S2-install | error | 按你声明的依赖必须装得出来 | 用户受控安装会同样失败 |
|
|
51
|
-
| S3/S4-apply | error | 每个补丁行:入口可解析、`import` 不抛、`ctx.plugin()` 应用不抛(声明 `inject` 等待服务注入属正常,不算失败) | **启动链击穿**——一行 import 失败会拖死整棵插件树 |
|
|
52
|
-
|
|
53
|
-
## `dsh.engine` 字段
|
|
54
|
-
|
|
55
|
-
在 package.json 声明你适配的 DSH 运行时范围:
|
|
56
|
-
|
|
57
|
-
```json
|
|
58
|
-
{
|
|
59
|
-
"dsh": {
|
|
60
|
-
"engine": ">=0.1.3-alpha.1 <0.2.0",
|
|
61
|
-
"bundle": { "patch": "./cordis.patch.yml" }
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
宿主升级越过你的范围时,市场会把你的插件标记为"待适配"而不是硬载崩溃。
|
|
67
|
-
|
|
68
|
-
## CI 集成示例
|
|
69
|
-
|
|
70
|
-
```yaml
|
|
71
|
-
# .github/workflows/check.yml
|
|
72
|
-
- run: npx @tokensapi/dsh-plugin-check --runtime 0.1.3-alpha.1 --strict
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
## 上架流程中的位置
|
|
76
|
-
|
|
77
|
-
1. 开发者:发布 npm 版本前本地跑体检,全绿再发。
|
|
78
|
-
2. 管理员:在仓库 Actions 的 **Plugin Check** workflow 填「包名@版本」手动触发,体检报告生成在该次运行的 Summary。
|
|
79
|
-
3. 上架:后台 publish 对话框把这次运行的链接填入「检查记录链接」——链接的红/绿即审核证据,`reviewed_version` 随上架落库。存在 error 的版本不满足上架条件。
|
|
1
|
+
# dsh-plugin-check
|
|
2
|
+
|
|
3
|
+
TokensCowork / DSH 插件合格性体检。给所有插件开发者的统一标准:一条命令,当场知道你的插件**能否安全装入宿主、会不会把用户的 Cowork 搞崩**。同一套规则同时用于:开发者本地自查、插件仓库 CI、插件市场上架门禁。
|
|
4
|
+
|
|
5
|
+
## 用法
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 在插件目录里
|
|
9
|
+
npx @tokensapi/dsh-plugin-check
|
|
10
|
+
|
|
11
|
+
# 指定目标运行时(启用兼容范围判定;当前产品运行时版本见市场公告)
|
|
12
|
+
npx @tokensapi/dsh-plugin-check --runtime 0.1.3-alpha.1
|
|
13
|
+
|
|
14
|
+
# 只跑离线清单体检(秒级,无网络)
|
|
15
|
+
npx @tokensapi/dsh-plugin-check --skip-smoke
|
|
16
|
+
|
|
17
|
+
# 机器可读输出(CI / 市场门禁)
|
|
18
|
+
npx @tokensapi/dsh-plugin-check --json
|
|
19
|
+
|
|
20
|
+
# 体检一个已发布的 npm 包(市场上架审核用)
|
|
21
|
+
npx @tokensapi/dsh-plugin-check --package @scope/name@1.2.3 --runtime 0.1.3-alpha.1
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
退出码:`0` 合格,`1` 存在 error(`--strict` 时 warning 也计入)。冒烟失败时加 `--keep-workspace` 保留临时工作区排查。
|
|
25
|
+
|
|
26
|
+
## 体检分两个阶段
|
|
27
|
+
|
|
28
|
+
**阶段① 清单体检**(纯静态、离线、秒级):检查 package.json 与 cordis 补丁的声明是否合规。
|
|
29
|
+
**阶段② 隔离启动冒烟**(联网、约 1–3 分钟):在临时工作区按你声明的 peer 范围装出真实运行时,然后逐补丁行做真实网关启动时做的事——解析入口 → `import` → `new Context()` + `ctx.plugin()` 应用。三段都过,你的插件就不会以"装上即崩"的方式杀死用户的 Cowork。全程 `--ignore-scripts`,你的代码只在一次性子进程里运行,超时即杀。
|
|
30
|
+
|
|
31
|
+
清单存在 error 时冒烟不执行——先修再跑。
|
|
32
|
+
|
|
33
|
+
## 规则清单
|
|
34
|
+
|
|
35
|
+
每条规则都对应一种真实发生过的宿主故障。
|
|
36
|
+
|
|
37
|
+
| 规则 | 级别 | 内容 | 拦的是什么事故 |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| M1-manifest | error/warning | package.json 合法,name/version 齐全;预发布版本给出上架提示 | 无法入库 |
|
|
40
|
+
| M2-lifecycle | error | 禁止 `preinstall/install/postinstall/prepare` 脚本(构建用 `prepack`) | 安装即执行任意代码;市场受控安装的既有红线 |
|
|
41
|
+
| M3-core-peer | error | `@deepseek-ai/cordis*`、`@deepseek-ai/dsh*` 只能是 peerDependencies | 双内核实例 → Symbol 不等 → 服务注册对不上 → **Cowork 启动失败** |
|
|
42
|
+
| M4-bundle | error | `dsh.bundle.patch` 必填、文件存在、能解析出插件行 | 宿主无法把插件挂进加载树 |
|
|
43
|
+
| M5-row-scope | error | 补丁行只能指向本包(或其子路径) | 插件行劫持挂载其他包 |
|
|
44
|
+
| M6-engine | error/warning | `dsh.engine` 声明目标运行时 SemVer 范围;与 `--runtime` 不相交为 error(未声明当前为 warning,将转必填) | 装进不适配的宿主版本 |
|
|
45
|
+
| M7-peer-range | warning | 各 `dsh-*` peer 范围应包含目标运行时 | 宿主升级后接口错配,运行时断裂 |
|
|
46
|
+
| M8-node-engines | warning | 建议声明 `engines.node`(宿主为 Node 22+) | 语法/API 不可用 |
|
|
47
|
+
| M9-license | warning | 建议声明 license | 分发合规 |
|
|
48
|
+
| M10-secrets | error/warning | 发布内容不得包含 `.env`、私钥等凭据样式文件 | 凭据泄露 |
|
|
49
|
+
| S1-pack | error | `npm pack --ignore-scripts` 必须成功 | 包本身发布不出来 |
|
|
50
|
+
| S2-install | error | 按你声明的依赖必须装得出来 | 用户受控安装会同样失败 |
|
|
51
|
+
| S3/S4-apply | error | 每个补丁行:入口可解析、`import` 不抛、`ctx.plugin()` 应用不抛(声明 `inject` 等待服务注入属正常,不算失败) | **启动链击穿**——一行 import 失败会拖死整棵插件树 |
|
|
52
|
+
|
|
53
|
+
## `dsh.engine` 字段
|
|
54
|
+
|
|
55
|
+
在 package.json 声明你适配的 DSH 运行时范围:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"dsh": {
|
|
60
|
+
"engine": ">=0.1.3-alpha.1 <0.2.0",
|
|
61
|
+
"bundle": { "patch": "./cordis.patch.yml" }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
宿主升级越过你的范围时,市场会把你的插件标记为"待适配"而不是硬载崩溃。
|
|
67
|
+
|
|
68
|
+
## CI 集成示例
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
# .github/workflows/check.yml
|
|
72
|
+
- run: npx @tokensapi/dsh-plugin-check --runtime 0.1.3-alpha.1 --strict
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 上架流程中的位置
|
|
76
|
+
|
|
77
|
+
1. 开发者:发布 npm 版本前本地跑体检,全绿再发。
|
|
78
|
+
2. 管理员:在仓库 Actions 的 **Plugin Check** workflow 填「包名@版本」手动触发,体检报告生成在该次运行的 Summary。
|
|
79
|
+
3. 上架:后台 publish 对话框把这次运行的链接填入「检查记录链接」——链接的红/绿即审核证据,`reviewed_version` 随上架落库。存在 error 的版本不满足上架条件。
|
package/bin/dsh-plugin-check.mjs
CHANGED
|
@@ -1,113 +1,113 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/* ============================================================
|
|
3
|
-
* dsh-plugin-check:插件合格性体检 CLI
|
|
4
|
-
* ============================================================
|
|
5
|
-
* 用法:
|
|
6
|
-
* npx @tokensapi/dsh-plugin-check [插件目录] [选项]
|
|
7
|
-
*
|
|
8
|
-
* 选项:
|
|
9
|
-
* --runtime <version> 目标 DSH 运行时版本(启用 engine/peer 交集判定)
|
|
10
|
-
* --skip-smoke 只跑清单体检(离线、秒级)
|
|
11
|
-
* --keep-workspace 保留冒烟临时工作区供排查
|
|
12
|
-
* --strict warning 也计入失败
|
|
13
|
-
* --json 机器可读输出(市场门禁用)
|
|
14
|
-
*
|
|
15
|
-
* 退出码:0 = 合格;1 = 存在 error(--strict 时含 warning)。
|
|
16
|
-
* ============================================================ */
|
|
17
|
-
import { resolve } from 'node:path'
|
|
18
|
-
import { checkManifest } from '../lib/manifest-check.mjs'
|
|
19
|
-
import { checkSmoke } from '../lib/smoke-check.mjs'
|
|
20
|
-
import { fetchPackage } from '../lib/fetch-package.mjs'
|
|
21
|
-
|
|
22
|
-
const args = process.argv.slice(2)
|
|
23
|
-
const options = { dir: process.cwd(), package: undefined, runtime: undefined, skipSmoke: false, keepWorkspace: false, strict: false, json: false }
|
|
24
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
25
|
-
const arg = args[index]
|
|
26
|
-
if (arg === '--runtime') { options.runtime = args[++index]; continue }
|
|
27
|
-
if (arg === '--package') { options.package = args[++index]; continue }
|
|
28
|
-
if (arg === '--skip-smoke') { options.skipSmoke = true; continue }
|
|
29
|
-
if (arg === '--keep-workspace') { options.keepWorkspace = true; continue }
|
|
30
|
-
if (arg === '--strict') { options.strict = true; continue }
|
|
31
|
-
if (arg === '--json') { options.json = true; continue }
|
|
32
|
-
if (arg === '--help' || arg === '-h') {
|
|
33
|
-
process.stdout.write('用法: dsh-plugin-check [插件目录] [--package <name@version>] [--runtime <version>] [--skip-smoke] [--keep-workspace] [--strict] [--json]\n')
|
|
34
|
-
process.exit(0)
|
|
35
|
-
}
|
|
36
|
-
if (arg.startsWith('--')) {
|
|
37
|
-
process.stderr.write(`未知选项: ${arg}\n`)
|
|
38
|
-
process.exit(2)
|
|
39
|
-
}
|
|
40
|
-
options.dir = resolve(arg)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const findings = []
|
|
44
|
-
const phases = []
|
|
45
|
-
|
|
46
|
-
if (options.package !== undefined) {
|
|
47
|
-
const fetched = fetchPackage(options.package)
|
|
48
|
-
if ('error' in fetched) {
|
|
49
|
-
process.stderr.write(`无法获取 ${options.package}: ${fetched.error}
|
|
50
|
-
`)
|
|
51
|
-
process.exit(1)
|
|
52
|
-
}
|
|
53
|
-
options.dir = fetched.dir
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const manifestResult = checkManifest(options.dir, { runtime: options.runtime })
|
|
57
|
-
findings.push(...manifestResult.findings)
|
|
58
|
-
phases.push({ phase: 'manifest', findings: manifestResult.findings })
|
|
59
|
-
|
|
60
|
-
let smokeSkipped = true
|
|
61
|
-
if (!options.skipSmoke
|
|
62
|
-
&& manifestResult.manifest !== undefined
|
|
63
|
-
&& manifestResult.rows !== undefined
|
|
64
|
-
&& !manifestResult.findings.some(finding => finding.level === 'error')) {
|
|
65
|
-
smokeSkipped = false
|
|
66
|
-
const smokeResult = checkSmoke(options.dir, manifestResult.manifest, manifestResult.rows, {
|
|
67
|
-
keepWorkspace: options.keepWorkspace,
|
|
68
|
-
})
|
|
69
|
-
findings.push(...smokeResult.findings)
|
|
70
|
-
phases.push({ phase: 'smoke', findings: smokeResult.findings, workspace: smokeResult.workspace })
|
|
71
|
-
} else if (!options.skipSmoke) {
|
|
72
|
-
phases.push({ phase: 'smoke', skipped: '清单体检存在 error,冒烟不再执行' })
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const errors = findings.filter(finding => finding.level === 'error')
|
|
76
|
-
const warnings = findings.filter(finding => finding.level === 'warning')
|
|
77
|
-
const infos = findings.filter(finding => finding.level === 'info')
|
|
78
|
-
const failed = errors.length > 0 || (options.strict && warnings.length > 0)
|
|
79
|
-
|
|
80
|
-
if (options.json) {
|
|
81
|
-
process.stdout.write(`${JSON.stringify({
|
|
82
|
-
ok: !failed,
|
|
83
|
-
plugin: manifestResult.manifest?.name,
|
|
84
|
-
version: manifestResult.manifest?.version,
|
|
85
|
-
runtime: options.runtime ?? null,
|
|
86
|
-
errors,
|
|
87
|
-
warnings,
|
|
88
|
-
phases,
|
|
89
|
-
}, undefined, 2)}\n`)
|
|
90
|
-
process.exit(failed ? 1 : 0)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const title = manifestResult.manifest?.name !== undefined
|
|
94
|
-
? `${manifestResult.manifest.name}@${manifestResult.manifest.version ?? '?'}`
|
|
95
|
-
: options.dir
|
|
96
|
-
process.stdout.write(`\ndsh-plugin-check · ${title}\n`)
|
|
97
|
-
if (options.runtime !== undefined) process.stdout.write(`目标运行时: ${options.runtime}\n`)
|
|
98
|
-
process.stdout.write('\n')
|
|
99
|
-
for (const finding of errors) process.stdout.write(` ❌ [${finding.rule}] ${finding.message}\n`)
|
|
100
|
-
for (const finding of warnings) process.stdout.write(` ⚠️ [${finding.rule}] ${finding.message}\n`)
|
|
101
|
-
for (const finding of infos) process.stdout.write(` ✅ ${finding.message}
|
|
102
|
-
`)
|
|
103
|
-
if (findings.length === 0) process.stdout.write(' 全部规则通过\n')
|
|
104
|
-
process.stdout.write('\n')
|
|
105
|
-
if (smokeSkipped && !options.skipSmoke && errors.length > 0) {
|
|
106
|
-
process.stdout.write('清单存在 error,隔离冒烟未执行;修复后重跑。\n')
|
|
107
|
-
} else if (options.skipSmoke) {
|
|
108
|
-
process.stdout.write('已按 --skip-smoke 跳过隔离冒烟。\n')
|
|
109
|
-
}
|
|
110
|
-
process.stdout.write(failed
|
|
111
|
-
? `结论: 不合格(${errors.length} error / ${warnings.length} warning)\n`
|
|
112
|
-
: `结论: 合格(0 error / ${warnings.length} warning)\n`)
|
|
113
|
-
process.exit(failed ? 1 : 0)
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* ============================================================
|
|
3
|
+
* dsh-plugin-check:插件合格性体检 CLI
|
|
4
|
+
* ============================================================
|
|
5
|
+
* 用法:
|
|
6
|
+
* npx @tokensapi/dsh-plugin-check [插件目录] [选项]
|
|
7
|
+
*
|
|
8
|
+
* 选项:
|
|
9
|
+
* --runtime <version> 目标 DSH 运行时版本(启用 engine/peer 交集判定)
|
|
10
|
+
* --skip-smoke 只跑清单体检(离线、秒级)
|
|
11
|
+
* --keep-workspace 保留冒烟临时工作区供排查
|
|
12
|
+
* --strict warning 也计入失败
|
|
13
|
+
* --json 机器可读输出(市场门禁用)
|
|
14
|
+
*
|
|
15
|
+
* 退出码:0 = 合格;1 = 存在 error(--strict 时含 warning)。
|
|
16
|
+
* ============================================================ */
|
|
17
|
+
import { resolve } from 'node:path'
|
|
18
|
+
import { checkManifest } from '../lib/manifest-check.mjs'
|
|
19
|
+
import { checkSmoke } from '../lib/smoke-check.mjs'
|
|
20
|
+
import { fetchPackage } from '../lib/fetch-package.mjs'
|
|
21
|
+
|
|
22
|
+
const args = process.argv.slice(2)
|
|
23
|
+
const options = { dir: process.cwd(), package: undefined, runtime: undefined, skipSmoke: false, keepWorkspace: false, strict: false, json: false }
|
|
24
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
25
|
+
const arg = args[index]
|
|
26
|
+
if (arg === '--runtime') { options.runtime = args[++index]; continue }
|
|
27
|
+
if (arg === '--package') { options.package = args[++index]; continue }
|
|
28
|
+
if (arg === '--skip-smoke') { options.skipSmoke = true; continue }
|
|
29
|
+
if (arg === '--keep-workspace') { options.keepWorkspace = true; continue }
|
|
30
|
+
if (arg === '--strict') { options.strict = true; continue }
|
|
31
|
+
if (arg === '--json') { options.json = true; continue }
|
|
32
|
+
if (arg === '--help' || arg === '-h') {
|
|
33
|
+
process.stdout.write('用法: dsh-plugin-check [插件目录] [--package <name@version>] [--runtime <version>] [--skip-smoke] [--keep-workspace] [--strict] [--json]\n')
|
|
34
|
+
process.exit(0)
|
|
35
|
+
}
|
|
36
|
+
if (arg.startsWith('--')) {
|
|
37
|
+
process.stderr.write(`未知选项: ${arg}\n`)
|
|
38
|
+
process.exit(2)
|
|
39
|
+
}
|
|
40
|
+
options.dir = resolve(arg)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const findings = []
|
|
44
|
+
const phases = []
|
|
45
|
+
|
|
46
|
+
if (options.package !== undefined) {
|
|
47
|
+
const fetched = fetchPackage(options.package)
|
|
48
|
+
if ('error' in fetched) {
|
|
49
|
+
process.stderr.write(`无法获取 ${options.package}: ${fetched.error}
|
|
50
|
+
`)
|
|
51
|
+
process.exit(1)
|
|
52
|
+
}
|
|
53
|
+
options.dir = fetched.dir
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const manifestResult = checkManifest(options.dir, { runtime: options.runtime })
|
|
57
|
+
findings.push(...manifestResult.findings)
|
|
58
|
+
phases.push({ phase: 'manifest', findings: manifestResult.findings })
|
|
59
|
+
|
|
60
|
+
let smokeSkipped = true
|
|
61
|
+
if (!options.skipSmoke
|
|
62
|
+
&& manifestResult.manifest !== undefined
|
|
63
|
+
&& manifestResult.rows !== undefined
|
|
64
|
+
&& !manifestResult.findings.some(finding => finding.level === 'error')) {
|
|
65
|
+
smokeSkipped = false
|
|
66
|
+
const smokeResult = checkSmoke(options.dir, manifestResult.manifest, manifestResult.rows, {
|
|
67
|
+
keepWorkspace: options.keepWorkspace,
|
|
68
|
+
})
|
|
69
|
+
findings.push(...smokeResult.findings)
|
|
70
|
+
phases.push({ phase: 'smoke', findings: smokeResult.findings, workspace: smokeResult.workspace })
|
|
71
|
+
} else if (!options.skipSmoke) {
|
|
72
|
+
phases.push({ phase: 'smoke', skipped: '清单体检存在 error,冒烟不再执行' })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const errors = findings.filter(finding => finding.level === 'error')
|
|
76
|
+
const warnings = findings.filter(finding => finding.level === 'warning')
|
|
77
|
+
const infos = findings.filter(finding => finding.level === 'info')
|
|
78
|
+
const failed = errors.length > 0 || (options.strict && warnings.length > 0)
|
|
79
|
+
|
|
80
|
+
if (options.json) {
|
|
81
|
+
process.stdout.write(`${JSON.stringify({
|
|
82
|
+
ok: !failed,
|
|
83
|
+
plugin: manifestResult.manifest?.name,
|
|
84
|
+
version: manifestResult.manifest?.version,
|
|
85
|
+
runtime: options.runtime ?? null,
|
|
86
|
+
errors,
|
|
87
|
+
warnings,
|
|
88
|
+
phases,
|
|
89
|
+
}, undefined, 2)}\n`)
|
|
90
|
+
process.exit(failed ? 1 : 0)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const title = manifestResult.manifest?.name !== undefined
|
|
94
|
+
? `${manifestResult.manifest.name}@${manifestResult.manifest.version ?? '?'}`
|
|
95
|
+
: options.dir
|
|
96
|
+
process.stdout.write(`\ndsh-plugin-check · ${title}\n`)
|
|
97
|
+
if (options.runtime !== undefined) process.stdout.write(`目标运行时: ${options.runtime}\n`)
|
|
98
|
+
process.stdout.write('\n')
|
|
99
|
+
for (const finding of errors) process.stdout.write(` ❌ [${finding.rule}] ${finding.message}\n`)
|
|
100
|
+
for (const finding of warnings) process.stdout.write(` ⚠️ [${finding.rule}] ${finding.message}\n`)
|
|
101
|
+
for (const finding of infos) process.stdout.write(` ✅ ${finding.message}
|
|
102
|
+
`)
|
|
103
|
+
if (findings.length === 0) process.stdout.write(' 全部规则通过\n')
|
|
104
|
+
process.stdout.write('\n')
|
|
105
|
+
if (smokeSkipped && !options.skipSmoke && errors.length > 0) {
|
|
106
|
+
process.stdout.write('清单存在 error,隔离冒烟未执行;修复后重跑。\n')
|
|
107
|
+
} else if (options.skipSmoke) {
|
|
108
|
+
process.stdout.write('已按 --skip-smoke 跳过隔离冒烟。\n')
|
|
109
|
+
}
|
|
110
|
+
process.stdout.write(failed
|
|
111
|
+
? `结论: 不合格(${errors.length} error / ${warnings.length} warning)\n`
|
|
112
|
+
: `结论: 合格(0 error / ${warnings.length} warning)\n`)
|
|
113
|
+
process.exit(failed ? 1 : 0)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* Cowork 插件入口:会话内插件体检工具
|
|
3
|
+
* ============================================================
|
|
4
|
+
* 装进 TokensCowork 后注册 plugin_check 工具:对任意 npm 插件包
|
|
5
|
+
* 跑清单规则(与 CLI 的清单阶段同源),模型或用户在会话里即可
|
|
6
|
+
* 判断"这个插件装进来会不会有问题"。含隔离启动冒烟的完整体检
|
|
7
|
+
* 仍走 CLI:npx @tokensapi/dsh-plugin-check --package <name@ver>。
|
|
8
|
+
* ============================================================ */
|
|
9
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
10
|
+
import { checkPublishedManifest } from './registry-check.mjs'
|
|
11
|
+
|
|
12
|
+
/** 缺省比对的运行时版本;宿主升级后可通过插件配置覆盖。 */
|
|
13
|
+
const DEFAULT_RUNTIME = '0.1.3-alpha.1'
|
|
14
|
+
const REGISTRY = 'https://registry.npmjs.org'
|
|
15
|
+
const FETCH_TIMEOUT_MS = 15_000
|
|
16
|
+
|
|
17
|
+
export const name = 'tokens-plugin-check'
|
|
18
|
+
export const inject = ['tools']
|
|
19
|
+
|
|
20
|
+
async function fetchManifest(packageName, version) {
|
|
21
|
+
const controller = new AbortController()
|
|
22
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(
|
|
25
|
+
`${REGISTRY}/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}`,
|
|
26
|
+
{ signal: controller.signal, headers: { accept: 'application/json' } },
|
|
27
|
+
)
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(response.status === 404 ? `npm 上未找到 ${packageName}@${version}` : `npm 查询失败(HTTP ${response.status})`)
|
|
30
|
+
}
|
|
31
|
+
return await response.json()
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(timer)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
39
|
+
* @param {{ runtime?: string }} [config]
|
|
40
|
+
*/
|
|
41
|
+
export function apply(ctx, config = {}) {
|
|
42
|
+
const runtime = typeof config.runtime === 'string' && config.runtime !== '' ? config.runtime : DEFAULT_RUNTIME
|
|
43
|
+
ctx.tools.register(defineTool({
|
|
44
|
+
name: 'plugin_check',
|
|
45
|
+
description: 'Check whether a published DSH/TokensCowork plugin npm package is safe to install: '
|
|
46
|
+
+ 'forbidden lifecycle scripts, dual-kernel core dependencies, missing dsh bundle patch, '
|
|
47
|
+
+ 'and runtime/peer compatibility against the current DSH runtime. '
|
|
48
|
+
+ 'Returns ok=false with per-rule findings when the plugin would break the host.',
|
|
49
|
+
parameters: {
|
|
50
|
+
package: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
required: true,
|
|
53
|
+
description: 'npm package name, e.g. "@tokensapi/dsh-progressive-tools".',
|
|
54
|
+
},
|
|
55
|
+
version: {
|
|
56
|
+
type: 'string',
|
|
57
|
+
description: 'Exact version to check. Defaults to the latest dist-tag.',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
output: {
|
|
61
|
+
schema: { type: 'json' },
|
|
62
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
63
|
+
},
|
|
64
|
+
isConcurrencySafe: () => true,
|
|
65
|
+
async execute(args) {
|
|
66
|
+
const manifest = await fetchManifest(args.package, args.version ?? 'latest')
|
|
67
|
+
const findings = checkPublishedManifest(manifest, runtime)
|
|
68
|
+
return {
|
|
69
|
+
package: manifest.name,
|
|
70
|
+
version: manifest.version,
|
|
71
|
+
runtime,
|
|
72
|
+
ok: !findings.some(finding => finding.level === 'error'),
|
|
73
|
+
findings,
|
|
74
|
+
note: '以上为清单规则;含隔离启动冒烟的完整体检请运行 npx @tokensapi/dsh-plugin-check --package <name@version>',
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
}))
|
|
78
|
+
}
|
package/lib/fetch-package.mjs
CHANGED
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
/* ============================================================
|
|
2
|
-
* 已发布包获取:npm pack 指定 spec 并解包,供市场门禁按
|
|
3
|
-
* "包名@版本"体检(而不是本地目录)。
|
|
4
|
-
* ============================================================ */
|
|
5
|
-
import { mkdtempSync, mkdirSync, readdirSync } from 'node:fs'
|
|
6
|
-
import { spawnSync } from 'node:child_process'
|
|
7
|
-
import { tmpdir } from 'node:os'
|
|
8
|
-
import { join } from 'node:path'
|
|
9
|
-
import { runNpm } from './npm.mjs'
|
|
10
|
-
|
|
11
|
-
const FETCH_TIMEOUT_MS = 180_000
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* 拉取并解包一个已发布的 npm 包。
|
|
15
|
-
* @param {string} spec - 如 "@scope/name@1.2.3"(建议锁定精确版本)。
|
|
16
|
-
* @returns {{ dir: string } | { error: string }} dir 为解出的包根目录
|
|
17
|
-
* (临时位置,由调用方决定何时清理)。
|
|
18
|
-
*/
|
|
19
|
-
export function fetchPackage(spec) {
|
|
20
|
-
const workspace = mkdtempSync(join(tmpdir(), 'dsh-plugin-fetch-'))
|
|
21
|
-
const pack = runNpm(['pack', spec, '--pack-destination', workspace], workspace, FETCH_TIMEOUT_MS)
|
|
22
|
-
if (pack.status !== 0) {
|
|
23
|
-
return { error: `npm pack ${spec} 失败: ${(pack.stderr || pack.stdout || '').trim().split('\n').slice(-3).join(' ')}` }
|
|
24
|
-
}
|
|
25
|
-
const tarball = readdirSync(workspace).find(name => name.endsWith('.tgz'))
|
|
26
|
-
if (tarball === undefined) return { error: 'npm pack 未产出 tgz' }
|
|
27
|
-
const extractDir = join(workspace, 'extracted')
|
|
28
|
-
mkdirSync(extractDir)
|
|
29
|
-
// Windows 自带 bsdtar,macOS/Linux 自带 tar;相对路径调用避开
|
|
30
|
-
// MSYS tar 对盘符路径的误判。
|
|
31
|
-
const extract = spawnSync('tar', ['-xzf', tarball, '-C', 'extracted'], {
|
|
32
|
-
cwd: workspace,
|
|
33
|
-
encoding: 'utf8',
|
|
34
|
-
timeout: FETCH_TIMEOUT_MS,
|
|
35
|
-
shell: process.platform === 'win32',
|
|
36
|
-
})
|
|
37
|
-
if (extract.status !== 0) {
|
|
38
|
-
return { error: `解包失败: ${(extract.stderr || '').trim().split('\n').slice(-2).join(' ')}` }
|
|
39
|
-
}
|
|
40
|
-
// npm 打包约定顶层目录名为 package/。
|
|
41
|
-
const [root] = readdirSync(extractDir)
|
|
42
|
-
if (root === undefined) return { error: '解包结果为空' }
|
|
43
|
-
return { dir: join(extractDir, root) }
|
|
44
|
-
}
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* 已发布包获取:npm pack 指定 spec 并解包,供市场门禁按
|
|
3
|
+
* "包名@版本"体检(而不是本地目录)。
|
|
4
|
+
* ============================================================ */
|
|
5
|
+
import { mkdtempSync, mkdirSync, readdirSync } from 'node:fs'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { runNpm } from './npm.mjs'
|
|
10
|
+
|
|
11
|
+
const FETCH_TIMEOUT_MS = 180_000
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 拉取并解包一个已发布的 npm 包。
|
|
15
|
+
* @param {string} spec - 如 "@scope/name@1.2.3"(建议锁定精确版本)。
|
|
16
|
+
* @returns {{ dir: string } | { error: string }} dir 为解出的包根目录
|
|
17
|
+
* (临时位置,由调用方决定何时清理)。
|
|
18
|
+
*/
|
|
19
|
+
export function fetchPackage(spec) {
|
|
20
|
+
const workspace = mkdtempSync(join(tmpdir(), 'dsh-plugin-fetch-'))
|
|
21
|
+
const pack = runNpm(['pack', spec, '--pack-destination', workspace], workspace, FETCH_TIMEOUT_MS)
|
|
22
|
+
if (pack.status !== 0) {
|
|
23
|
+
return { error: `npm pack ${spec} 失败: ${(pack.stderr || pack.stdout || '').trim().split('\n').slice(-3).join(' ')}` }
|
|
24
|
+
}
|
|
25
|
+
const tarball = readdirSync(workspace).find(name => name.endsWith('.tgz'))
|
|
26
|
+
if (tarball === undefined) return { error: 'npm pack 未产出 tgz' }
|
|
27
|
+
const extractDir = join(workspace, 'extracted')
|
|
28
|
+
mkdirSync(extractDir)
|
|
29
|
+
// Windows 自带 bsdtar,macOS/Linux 自带 tar;相对路径调用避开
|
|
30
|
+
// MSYS tar 对盘符路径的误判。
|
|
31
|
+
const extract = spawnSync('tar', ['-xzf', tarball, '-C', 'extracted'], {
|
|
32
|
+
cwd: workspace,
|
|
33
|
+
encoding: 'utf8',
|
|
34
|
+
timeout: FETCH_TIMEOUT_MS,
|
|
35
|
+
shell: process.platform === 'win32',
|
|
36
|
+
})
|
|
37
|
+
if (extract.status !== 0) {
|
|
38
|
+
return { error: `解包失败: ${(extract.stderr || '').trim().split('\n').slice(-2).join(' ')}` }
|
|
39
|
+
}
|
|
40
|
+
// npm 打包约定顶层目录名为 package/。
|
|
41
|
+
const [root] = readdirSync(extractDir)
|
|
42
|
+
if (root === undefined) return { error: '解包结果为空' }
|
|
43
|
+
return { dir: join(extractDir, root) }
|
|
44
|
+
}
|