@tokensapi/dsh-plugin-check 0.1.0

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 ADDED
@@ -0,0 +1,74 @@
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
+
21
+ 退出码:`0` 合格,`1` 存在 error(`--strict` 时 warning 也计入)。冒烟失败时加 `--keep-workspace` 保留临时工作区排查。
22
+
23
+ ## 体检分两个阶段
24
+
25
+ **阶段① 清单体检**(纯静态、离线、秒级):检查 package.json 与 cordis 补丁的声明是否合规。
26
+ **阶段② 隔离启动冒烟**(联网、约 1–3 分钟):在临时工作区按你声明的 peer 范围装出真实运行时,然后逐补丁行做真实网关启动时做的事——解析入口 → `import` → `new Context()` + `ctx.plugin()` 应用。三段都过,你的插件就不会以"装上即崩"的方式杀死用户的 Cowork。全程 `--ignore-scripts`,你的代码只在一次性子进程里运行,超时即杀。
27
+
28
+ 清单存在 error 时冒烟不执行——先修再跑。
29
+
30
+ ## 规则清单
31
+
32
+ 每条规则都对应一种真实发生过的宿主故障。
33
+
34
+ | 规则 | 级别 | 内容 | 拦的是什么事故 |
35
+ |---|---|---|---|
36
+ | M1-manifest | error/warning | package.json 合法,name/version 齐全;预发布版本给出上架提示 | 无法入库 |
37
+ | M2-lifecycle | error | 禁止 `preinstall/install/postinstall/prepare` 脚本(构建用 `prepack`) | 安装即执行任意代码;市场受控安装的既有红线 |
38
+ | M3-core-peer | error | `@deepseek-ai/cordis*`、`@deepseek-ai/dsh*` 只能是 peerDependencies | 双内核实例 → Symbol 不等 → 服务注册对不上 → **Cowork 启动失败** |
39
+ | M4-bundle | error | `dsh.bundle.patch` 必填、文件存在、能解析出插件行 | 宿主无法把插件挂进加载树 |
40
+ | M5-row-scope | error | 补丁行只能指向本包(或其子路径) | 插件行劫持挂载其他包 |
41
+ | M6-engine | error/warning | `dsh.engine` 声明目标运行时 SemVer 范围;与 `--runtime` 不相交为 error(未声明当前为 warning,将转必填) | 装进不适配的宿主版本 |
42
+ | M7-peer-range | warning | 各 `dsh-*` peer 范围应包含目标运行时 | 宿主升级后接口错配,运行时断裂 |
43
+ | M8-node-engines | warning | 建议声明 `engines.node`(宿主为 Node 22+) | 语法/API 不可用 |
44
+ | M9-license | warning | 建议声明 license | 分发合规 |
45
+ | M10-secrets | error/warning | 发布内容不得包含 `.env`、私钥等凭据样式文件 | 凭据泄露 |
46
+ | S1-pack | error | `npm pack --ignore-scripts` 必须成功 | 包本身发布不出来 |
47
+ | S2-install | error | 按你声明的依赖必须装得出来 | 用户受控安装会同样失败 |
48
+ | S3/S4-apply | error | 每个补丁行:入口可解析、`import` 不抛、`ctx.plugin()` 应用不抛(声明 `inject` 等待服务注入属正常,不算失败) | **启动链击穿**——一行 import 失败会拖死整棵插件树 |
49
+
50
+ ## `dsh.engine` 字段
51
+
52
+ 在 package.json 声明你适配的 DSH 运行时范围:
53
+
54
+ ```json
55
+ {
56
+ "dsh": {
57
+ "engine": ">=0.1.3-alpha.1 <0.2.0",
58
+ "bundle": { "patch": "./cordis.patch.yml" }
59
+ }
60
+ }
61
+ ```
62
+
63
+ 宿主升级越过你的范围时,市场会把你的插件标记为"待适配"而不是硬载崩溃。
64
+
65
+ ## CI 集成示例
66
+
67
+ ```yaml
68
+ # .github/workflows/check.yml
69
+ - run: npx @tokensapi/dsh-plugin-check --runtime 0.1.3-alpha.1 --strict
70
+ ```
71
+
72
+ ## 上架流程中的位置
73
+
74
+ 市场上架要求本工具体检通过:提交插件版本后,门禁会以相同规则复检并记录"验证于运行时 x.y.z"。本地先跑一遍,提交即通过。
@@ -0,0 +1,101 @@
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
+
21
+ const args = process.argv.slice(2)
22
+ const options = { dir: process.cwd(), runtime: undefined, skipSmoke: false, keepWorkspace: false, strict: false, json: false }
23
+ for (let index = 0; index < args.length; index += 1) {
24
+ const arg = args[index]
25
+ if (arg === '--runtime') { options.runtime = args[++index]; continue }
26
+ if (arg === '--skip-smoke') { options.skipSmoke = true; continue }
27
+ if (arg === '--keep-workspace') { options.keepWorkspace = true; continue }
28
+ if (arg === '--strict') { options.strict = true; continue }
29
+ if (arg === '--json') { options.json = true; continue }
30
+ if (arg === '--help' || arg === '-h') {
31
+ process.stdout.write('用法: dsh-plugin-check [插件目录] [--runtime <version>] [--skip-smoke] [--keep-workspace] [--strict] [--json]\n')
32
+ process.exit(0)
33
+ }
34
+ if (arg.startsWith('--')) {
35
+ process.stderr.write(`未知选项: ${arg}\n`)
36
+ process.exit(2)
37
+ }
38
+ options.dir = resolve(arg)
39
+ }
40
+
41
+ const findings = []
42
+ const phases = []
43
+
44
+ const manifestResult = checkManifest(options.dir, { runtime: options.runtime })
45
+ findings.push(...manifestResult.findings)
46
+ phases.push({ phase: 'manifest', findings: manifestResult.findings })
47
+
48
+ let smokeSkipped = true
49
+ if (!options.skipSmoke
50
+ && manifestResult.manifest !== undefined
51
+ && manifestResult.rows !== undefined
52
+ && !manifestResult.findings.some(finding => finding.level === 'error')) {
53
+ smokeSkipped = false
54
+ const smokeResult = checkSmoke(options.dir, manifestResult.manifest, manifestResult.rows, {
55
+ keepWorkspace: options.keepWorkspace,
56
+ })
57
+ findings.push(...smokeResult.findings)
58
+ phases.push({ phase: 'smoke', findings: smokeResult.findings, workspace: smokeResult.workspace })
59
+ } else if (!options.skipSmoke) {
60
+ phases.push({ phase: 'smoke', skipped: '清单体检存在 error,冒烟不再执行' })
61
+ }
62
+
63
+ const errors = findings.filter(finding => finding.level === 'error')
64
+ const warnings = findings.filter(finding => finding.level === 'warning')
65
+ const infos = findings.filter(finding => finding.level === 'info')
66
+ const failed = errors.length > 0 || (options.strict && warnings.length > 0)
67
+
68
+ if (options.json) {
69
+ process.stdout.write(`${JSON.stringify({
70
+ ok: !failed,
71
+ plugin: manifestResult.manifest?.name,
72
+ version: manifestResult.manifest?.version,
73
+ runtime: options.runtime ?? null,
74
+ errors,
75
+ warnings,
76
+ phases,
77
+ }, undefined, 2)}\n`)
78
+ process.exit(failed ? 1 : 0)
79
+ }
80
+
81
+ const title = manifestResult.manifest?.name !== undefined
82
+ ? `${manifestResult.manifest.name}@${manifestResult.manifest.version ?? '?'}`
83
+ : options.dir
84
+ process.stdout.write(`\ndsh-plugin-check · ${title}\n`)
85
+ if (options.runtime !== undefined) process.stdout.write(`目标运行时: ${options.runtime}\n`)
86
+ process.stdout.write('\n')
87
+ for (const finding of errors) process.stdout.write(` ❌ [${finding.rule}] ${finding.message}\n`)
88
+ for (const finding of warnings) process.stdout.write(` ⚠️ [${finding.rule}] ${finding.message}\n`)
89
+ for (const finding of infos) process.stdout.write(` ✅ ${finding.message}
90
+ `)
91
+ if (findings.length === 0) process.stdout.write(' 全部规则通过\n')
92
+ process.stdout.write('\n')
93
+ if (smokeSkipped && !options.skipSmoke && errors.length > 0) {
94
+ process.stdout.write('清单存在 error,隔离冒烟未执行;修复后重跑。\n')
95
+ } else if (options.skipSmoke) {
96
+ process.stdout.write('已按 --skip-smoke 跳过隔离冒烟。\n')
97
+ }
98
+ process.stdout.write(failed
99
+ ? `结论: 不合格(${errors.length} error / ${warnings.length} warning)\n`
100
+ : `结论: 合格(0 error / ${warnings.length} warning)\n`)
101
+ process.exit(failed ? 1 : 0)
@@ -0,0 +1,144 @@
1
+ /* ============================================================
2
+ * 阶段①:清单体检(纯静态,秒级,离线)
3
+ * ============================================================
4
+ * 每一条规则都对应一种已经真实发生过的"把宿主搞崩/搞坏"路径,
5
+ * 规则说明写在 README;error 挡上架,warning 提示即将收紧或
6
+ * 潜在错配。判定为纯函数,便于单测与市场门禁复用。
7
+ * ============================================================ */
8
+ import { existsSync, readFileSync, statSync } from 'node:fs'
9
+ import { join, resolve } from 'node:path'
10
+ import semver from 'semver'
11
+ import { extractPatchRows } from './patch-rows.mjs'
12
+
13
+ /** 安装即执行任意代码的脚本;市场受控安装的既有红线。 */
14
+ const FORBIDDEN_LIFECYCLE = ['preinstall', 'install', 'postinstall', 'prepare']
15
+
16
+ /**
17
+ * 携带身份语义的核心包:cordis 上下文与 dsh 服务在宿主/插件各持一份时
18
+ * Symbol 不等,注册对不上。schemastery、cosmokit 等纯工具库不在此列,
19
+ * 插件可以正常依赖。
20
+ */
21
+ const CORE_SCOPE = '@deepseek-ai/'
22
+ const isIdentityCore = (name) => name === '@deepseek-ai/cordis'
23
+ || name.startsWith('@deepseek-ai/cordis-')
24
+ || name.startsWith('@deepseek-ai/dsh')
25
+
26
+ const SUSPICIOUS_FILE = /(\.env(\.|$)|id_rsa|\.pem$|\.p12$|\.key$)/u
27
+
28
+ /**
29
+ * 对一个插件目录执行全部清单规则。
30
+ * @param {string} pluginDir - 插件根目录(含 package.json)。
31
+ * @param {{ runtime?: string }} [options] - runtime 为目标 DSH 运行时
32
+ * 版本(如 0.1.3-alpha.1);给出时才执行 engine/peer 与其的交集判定。
33
+ * @returns {{ findings: Array<{level: 'error'|'warning', rule: string, message: string}>, manifest?: object, rows?: object[] }}
34
+ */
35
+ export function checkManifest(pluginDir, options = {}) {
36
+ const findings = []
37
+ const error = (rule, message) => findings.push({ level: 'error', rule, message })
38
+ const warning = (rule, message) => findings.push({ level: 'warning', rule, message })
39
+
40
+ const manifestPath = resolve(pluginDir, 'package.json')
41
+ if (!existsSync(manifestPath)) {
42
+ error('M1-manifest', 'package.json 不存在')
43
+ return { findings }
44
+ }
45
+ let manifest
46
+ try {
47
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
48
+ } catch (cause) {
49
+ error('M1-manifest', `package.json 不是合法 JSON: ${cause instanceof Error ? cause.message : String(cause)}`)
50
+ return { findings }
51
+ }
52
+
53
+ if (typeof manifest.name !== 'string' || manifest.name === '') {
54
+ error('M1-manifest', 'name 缺失')
55
+ }
56
+ if (typeof manifest.version !== 'string' || semver.valid(manifest.version) === null) {
57
+ error('M1-manifest', 'version 不是合法 SemVer')
58
+ } else if (semver.prerelease(manifest.version) !== null) {
59
+ warning('M1-manifest', `version ${manifest.version} 是预发布版;市场上架要求精确稳定版`)
60
+ }
61
+
62
+ const scripts = manifest.scripts ?? {}
63
+ for (const script of FORBIDDEN_LIFECYCLE) {
64
+ if (typeof scripts[script] === 'string') {
65
+ error('M2-lifecycle', `禁止 ${script} 生命周期脚本(安装即执行任意代码,市场受控安装会拒绝);构建请改用 prepack`)
66
+ }
67
+ }
68
+
69
+ const dependencies = manifest.dependencies ?? {}
70
+ for (const name of Object.keys(dependencies)) {
71
+ if (isIdentityCore(name)) {
72
+ error('M3-core-peer', `${name} 出现在 dependencies;核心运行时必须声明为 peerDependencies,否则宿主与插件各持一份实例,服务注册无法对上,典型症状是整个 Cowork 启动失败`)
73
+ }
74
+ }
75
+
76
+ const dsh = manifest.dsh ?? {}
77
+ const bundle = dsh.bundle ?? {}
78
+ let rows
79
+ if (typeof bundle.patch !== 'string' || bundle.patch === '') {
80
+ error('M4-bundle', 'dsh.bundle.patch 缺失;宿主靠它把插件挂进加载树')
81
+ } else {
82
+ const patchPath = resolve(pluginDir, bundle.patch)
83
+ if (!existsSync(patchPath)) {
84
+ error('M4-bundle', `dsh.bundle.patch 指向的文件不存在: ${bundle.patch}`)
85
+ } else {
86
+ const extracted = extractPatchRows(readFileSync(patchPath, 'utf8'))
87
+ for (const problem of extracted.problems) error('M4-bundle', `补丁行不合法: ${problem}`)
88
+ rows = extracted.rows
89
+ if (rows.length === 0 && extracted.problems.length === 0) {
90
+ error('M4-bundle', '补丁未声明任何插件行')
91
+ }
92
+ const packageName = typeof manifest.name === 'string' ? manifest.name : ''
93
+ for (const row of rows ?? []) {
94
+ if (packageName !== '' && row.name !== packageName && !row.name.startsWith(`${packageName}/`)) {
95
+ error('M5-row-scope', `补丁行 "${row.id ?? row.name}" 指向 ${row.name},超出本包命名空间(${packageName});插件只能挂载自己的入口`)
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ const engine = typeof dsh.engine === 'string' ? dsh.engine : undefined
102
+ if (engine === undefined) {
103
+ warning('M6-engine', 'dsh.engine 未声明(目标 DSH 运行时 SemVer 范围,如 ">=0.1.3-alpha.1 <0.2");该字段将成为上架必填')
104
+ } else if (semver.validRange(engine, { includePrerelease: true }) === null) {
105
+ error('M6-engine', `dsh.engine 不是合法 SemVer 范围: ${engine}`)
106
+ } else if (options.runtime !== undefined
107
+ && !semver.satisfies(options.runtime, engine, { includePrerelease: true })) {
108
+ error('M6-engine', `dsh.engine "${engine}" 不覆盖目标运行时 ${options.runtime}`)
109
+ }
110
+
111
+ const peers = manifest.peerDependencies ?? {}
112
+ if (options.runtime !== undefined) {
113
+ for (const [name, range] of Object.entries(peers)) {
114
+ if (!name.startsWith(`${CORE_SCOPE}dsh`)) continue
115
+ if (semver.validRange(range, { includePrerelease: true }) === null) {
116
+ error('M7-peer-range', `peer ${name} 的范围不合法: ${range}`)
117
+ } else if (!semver.satisfies(options.runtime, range, { includePrerelease: true })) {
118
+ warning('M7-peer-range', `peer ${name}@"${range}" 不含目标运行时 ${options.runtime};宿主升级后此插件可能在运行时断裂(接口错配类故障)`)
119
+ }
120
+ }
121
+ }
122
+
123
+ if (manifest.engines?.node === undefined) {
124
+ warning('M8-node-engines', 'engines.node 未声明;宿主运行在 Node 22+')
125
+ }
126
+ if (manifest.license === undefined) {
127
+ warning('M9-license', 'license 未声明')
128
+ }
129
+
130
+ const filesList = Array.isArray(manifest.files) ? manifest.files : []
131
+ for (const entry of filesList) {
132
+ if (typeof entry === 'string' && SUSPICIOUS_FILE.test(entry)) {
133
+ error('M10-secrets', `files 白名单包含疑似凭据文件: ${entry}`)
134
+ }
135
+ }
136
+ for (const candidate of ['.env', 'id_rsa']) {
137
+ const candidatePath = join(pluginDir, candidate)
138
+ if (existsSync(candidatePath) && statSync(candidatePath).isFile() && filesList.length === 0) {
139
+ warning('M10-secrets', `目录含 ${candidate} 且未用 files 白名单限定发布内容,npm pack 可能把它带上`)
140
+ }
141
+ }
142
+
143
+ return { findings, manifest, rows }
144
+ }
@@ -0,0 +1,86 @@
1
+ /* ============================================================
2
+ * cordis.patch.yml 行提取
3
+ * ============================================================
4
+ * 补丁文件由 cordis-plugin-loader 的方言消费,可能携带 !!js 等
5
+ * 自定义标签。体检工具只做只读判定,解析必须永不因未知标签而死:
6
+ * 未知标签统一收敛为占位对象,携带它的行照常提取,配置标记为
7
+ * 不可静态求值(冒烟时跳过该行的 config)。
8
+ * ============================================================ */
9
+ import yaml from 'js-yaml'
10
+
11
+ const JS_EXPR_PLACEHOLDER = Symbol('dsh-plugin-check:js-expr')
12
+
13
+ const tolerantSchema = yaml.DEFAULT_SCHEMA.extend(
14
+ ['scalar', 'sequence', 'mapping'].map(kind => new yaml.Type('!', {
15
+ kind,
16
+ multi: true,
17
+ construct: () => ({ [JS_EXPR_PLACEHOLDER]: true }),
18
+ })),
19
+ )
20
+
21
+ /** 配置里是否携带无法静态求值的表达式(!!js 等自定义标签)。 */
22
+ export function hasDynamicConfig(value) {
23
+ if (value === null || typeof value !== 'object') return false
24
+ if (value[JS_EXPR_PLACEHOLDER] === true) return true
25
+ const children = Array.isArray(value) ? value : Object.values(value)
26
+ return children.some(child => hasDynamicConfig(child))
27
+ }
28
+
29
+ /**
30
+ * 从补丁内容提取全部插件行。
31
+ * 支持顶层两种形状:直接的行数组,或 { insert: [...] } 之类的
32
+ * 操作映射(取所有数组值展平)。行内的 group 嵌套按 config 递归。
33
+ * @param {string} content - cordis.patch.yml 的完整文本。
34
+ * @returns {{ rows: Array<{id?: string, name: string, config?: unknown, disabled?: boolean, dynamicConfig: boolean}>, problems: string[] }}
35
+ */
36
+ export function extractPatchRows(content) {
37
+ const problems = []
38
+ let document
39
+ try {
40
+ document = yaml.load(content, { schema: tolerantSchema })
41
+ } catch (cause) {
42
+ return { rows: [], problems: [`补丁不是合法 YAML: ${cause instanceof Error ? cause.message.split('\n')[0] : String(cause)}`] }
43
+ }
44
+ const topLevel = []
45
+ if (Array.isArray(document)) {
46
+ topLevel.push(...document)
47
+ } else if (document !== null && typeof document === 'object') {
48
+ for (const value of Object.values(document)) {
49
+ if (Array.isArray(value)) topLevel.push(...value)
50
+ }
51
+ }
52
+ const candidates = []
53
+ for (const operation of topLevel) {
54
+ if (Array.isArray(operation)) candidates.push(...operation)
55
+ else if (operation !== null && typeof operation === 'object' && !('name' in operation)) {
56
+ for (const value of Object.values(operation)) {
57
+ if (Array.isArray(value)) candidates.push(...value)
58
+ }
59
+ } else candidates.push(operation)
60
+ }
61
+ const rows = []
62
+ const visit = (entry, at) => {
63
+ if (entry === null || typeof entry !== 'object') {
64
+ problems.push(`${at} 不是行对象`)
65
+ return
66
+ }
67
+ if (entry.group === true) {
68
+ const children = Array.isArray(entry.config) ? entry.config : []
69
+ children.forEach((child, index) => visit(child, `${at} group 第 ${index + 1} 行`))
70
+ return
71
+ }
72
+ if (typeof entry.name !== 'string' || entry.name === '') {
73
+ problems.push(`${at} 缺少 name`)
74
+ return
75
+ }
76
+ rows.push({
77
+ id: typeof entry.id === 'string' ? entry.id : undefined,
78
+ name: entry.name,
79
+ config: entry.config,
80
+ disabled: entry.disabled === true,
81
+ dynamicConfig: hasDynamicConfig(entry.config),
82
+ })
83
+ }
84
+ candidates.forEach((entry, index) => visit(entry, `第 ${index + 1} 行`))
85
+ return { rows, problems }
86
+ }
@@ -0,0 +1,182 @@
1
+ /* ============================================================
2
+ * 阶段②:隔离启动冒烟(联网,分钟级)
3
+ * ============================================================
4
+ * 在临时工作区里按插件自己声明的 peer 范围装出一套真实运行时,
5
+ * 然后在子进程里逐行做真实网关会做的事:import 补丁行入口、
6
+ * new Context()、ctx.plugin() 应用。这里能过,插件就不会以
7
+ * "装上即崩"的方式杀死用户的 Cowork:
8
+ * - 装不上(依赖解析失败)在 install 步暴露;
9
+ * - import 期崩溃(顶层异常/缺模块/ESM 形状)在 import 步暴露;
10
+ * - 应用期崩溃(apply 抛出/插件形状不合法)在 plugin 步暴露;
11
+ * - 声明了 inject 而服务未注入属正常停车,不算失败。
12
+ * 全程 --ignore-scripts,被测代码没有机会在检查器进程里执行
13
+ * 安装钩子;插件代码只在一次性子进程里运行,超时即杀。
14
+ * ============================================================ */
15
+ import { spawnSync } from 'node:child_process'
16
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
17
+ import { tmpdir } from 'node:os'
18
+ import { join, resolve } from 'node:path'
19
+
20
+ const RUNNER_TIMEOUT_MS = 90_000
21
+ const INSTALL_TIMEOUT_MS = 300_000
22
+
23
+ /** 子进程执行的行级冒烟脚本;结果写文件,避开插件对 stdout 的污染。 */
24
+ const RUNNER_SOURCE = `
25
+ import { pathToFileURL } from 'node:url'
26
+ import { writeFileSync } from 'node:fs'
27
+ const [workspace, resultPath, rowsJson] = process.argv.slice(2)
28
+ const rows = JSON.parse(rowsJson)
29
+ const results = []
30
+ let current = null
31
+ process.on('uncaughtException', (cause) => finish(cause))
32
+ process.on('unhandledRejection', (cause) => finish(cause))
33
+ function finish(cause) {
34
+ if (current !== null) {
35
+ results.push({ ...current, status: 'failed', stage: current.stage,
36
+ error: cause instanceof Error ? cause.message : String(cause) })
37
+ current = null
38
+ }
39
+ writeFileSync(resultPath, JSON.stringify(results))
40
+ process.exit(0)
41
+ }
42
+ const { createRequire } = await import('node:module')
43
+ const workspaceRequire = createRequire(pathToFileURL(workspace + '/x.js'))
44
+ let Context
45
+ try {
46
+ const cordis = await import(pathToFileURL(workspaceRequire.resolve('@deepseek-ai/cordis')))
47
+ Context = cordis.Context
48
+ } catch (cause) {
49
+ writeFileSync(resultPath, JSON.stringify([{ name: '@deepseek-ai/cordis', stage: 'import',
50
+ status: 'failed', error: '无法载入 cordis 运行时: ' + (cause instanceof Error ? cause.message : String(cause)) }]))
51
+ process.exit(0)
52
+ }
53
+ for (const row of rows) {
54
+ current = { name: row.name, stage: 'resolve' }
55
+ try {
56
+ const entryPath = workspaceRequire.resolve(row.name)
57
+ current.stage = 'import'
58
+ const mod = await import(pathToFileURL(entryPath))
59
+ const plugin = mod.default ?? mod
60
+ current.stage = 'apply'
61
+ const ctx = new Context()
62
+ if (row.dynamicConfig) ctx.plugin(plugin)
63
+ else if (row.config === undefined) ctx.plugin(plugin)
64
+ else ctx.plugin(plugin, row.config)
65
+ await new Promise((r) => setTimeout(r, 750))
66
+ results.push({ name: row.name, status: 'ok',
67
+ note: row.dynamicConfig ? '配置含动态表达式,以空配置应用' : undefined })
68
+ current = null
69
+ } catch (cause) {
70
+ results.push({ name: row.name, status: 'failed', stage: current.stage,
71
+ error: cause instanceof Error ? cause.message : String(cause) })
72
+ current = null
73
+ }
74
+ }
75
+ writeFileSync(resultPath, JSON.stringify(results))
76
+ process.exit(0)
77
+ `
78
+
79
+ function runNpm(args, cwd, timeout) {
80
+ return spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, {
81
+ cwd,
82
+ encoding: 'utf8',
83
+ timeout,
84
+ shell: process.platform === 'win32',
85
+ env: { ...process.env, npm_config_ignore_scripts: 'true' },
86
+ })
87
+ }
88
+
89
+ /**
90
+ * 对插件目录执行隔离冒烟。
91
+ * @param {string} pluginDir - 插件根目录。
92
+ * @param {object} manifest - 已解析的 package.json。
93
+ * @param {Array<{name: string, config?: unknown, disabled?: boolean, dynamicConfig: boolean}>} rows - 补丁行。
94
+ * @param {{ keepWorkspace?: boolean }} [options]
95
+ * @returns {{ findings: Array<{level: 'error'|'warning', rule: string, message: string}>, workspace?: string }}
96
+ */
97
+ export function checkSmoke(pluginDir, manifest, rows, options = {}) {
98
+ const findings = []
99
+ const error = (rule, message) => findings.push({ level: 'error', rule, message })
100
+ const warning = (rule, message) => findings.push({ level: 'warning', rule, message })
101
+
102
+ const workspace = mkdtempSync(join(tmpdir(), 'dsh-plugin-check-'))
103
+ const cleanup = () => {
104
+ if (options.keepWorkspace) return
105
+ try { rmSync(workspace, { recursive: true, force: true }) } catch { /* 临时目录残留不影响判定 */ }
106
+ }
107
+
108
+ try {
109
+ // S1 打包:--ignore-scripts 跳过 prepack 等脚本,只验证发布内容本身。
110
+ const pack = runNpm(['pack', resolve(pluginDir), '--ignore-scripts', '--pack-destination', workspace], workspace, INSTALL_TIMEOUT_MS)
111
+ if (pack.status !== 0) {
112
+ error('S1-pack', `npm pack 失败: ${(pack.stderr || pack.stdout || '').trim().split('\n').slice(-3).join(' ')}`)
113
+ return { findings, workspace: options.keepWorkspace ? workspace : undefined }
114
+ }
115
+ const tarball = readdirSync(workspace).find(name => name.endsWith('.tgz'))
116
+ if (tarball === undefined) {
117
+ error('S1-pack', 'npm pack 未产出 tgz')
118
+ return { findings, workspace: options.keepWorkspace ? workspace : undefined }
119
+ }
120
+
121
+ // S2 安装:插件走 tgz(等价用户侧的受控安装),运行时按插件自己
122
+ // 声明的 peer 范围解析;cordis 是冒烟宿主的最低要求,未声明则补上。
123
+ const dependencies = { [manifest.name]: `file:./${tarball}` }
124
+ const peers = manifest.peerDependencies ?? {}
125
+ for (const [name, range] of Object.entries(peers)) dependencies[name] = range
126
+ if (dependencies['@deepseek-ai/cordis'] === undefined) {
127
+ dependencies['@deepseek-ai/cordis'] = '*'
128
+ warning('S2-install', 'peerDependencies 未声明 @deepseek-ai/cordis,冒烟以最新版补齐;建议显式声明')
129
+ }
130
+ writeFileSync(join(workspace, 'package.json'), JSON.stringify({
131
+ name: 'dsh-plugin-check-workspace',
132
+ private: true,
133
+ dependencies,
134
+ }, undefined, 2))
135
+ const install = runNpm(
136
+ ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--loglevel=error'],
137
+ workspace,
138
+ INSTALL_TIMEOUT_MS,
139
+ )
140
+ if (install.status !== 0) {
141
+ error('S2-install', `依赖装不出来(用户侧受控安装会同样失败): ${(install.stderr || '').trim().split('\n').slice(-4).join(' ')}`)
142
+ return { findings, workspace: options.keepWorkspace ? workspace : undefined }
143
+ }
144
+
145
+ // S3+S4 行级冒烟:解析/import/apply 三段判定在子进程完成。
146
+ const activeRows = rows.filter(row => !row.disabled)
147
+ if (activeRows.length === 0) {
148
+ warning('S3-rows', '补丁没有启用状态的行,冒烟无事可做')
149
+ return { findings, workspace: options.keepWorkspace ? workspace : undefined }
150
+ }
151
+ const runnerPath = join(workspace, '__runner.mjs')
152
+ const resultPath = join(workspace, '__result.json')
153
+ writeFileSync(runnerPath, RUNNER_SOURCE)
154
+ const run = spawnSync(process.execPath, [
155
+ runnerPath,
156
+ workspace,
157
+ resultPath,
158
+ JSON.stringify(activeRows.map(row => ({ name: row.name, config: row.config, dynamicConfig: row.dynamicConfig }))),
159
+ ], { cwd: workspace, encoding: 'utf8', timeout: RUNNER_TIMEOUT_MS })
160
+ if (!existsSync(resultPath)) {
161
+ const reason = run.signal === 'SIGTERM'
162
+ ? `超时(${RUNNER_TIMEOUT_MS / 1000}s):入口疑似在顶层做阻塞等待`
163
+ : (run.stderr || '子进程未产出结果').trim().split('\n').slice(-3).join(' ')
164
+ error('S4-apply', `冒烟子进程异常: ${reason}`)
165
+ return { findings, workspace: options.keepWorkspace ? workspace : undefined }
166
+ }
167
+ const results = JSON.parse(readFileSync(resultPath, 'utf8'))
168
+ const passed = results.filter(result => result.status === 'ok').length
169
+ findings.push({ level: 'info', rule: 'S4-apply', message: `隔离冒烟: ${passed}/${results.length} 行通过(解析→import→应用)` })
170
+ for (const result of results) {
171
+ if (result.status === 'ok') {
172
+ if (result.note !== undefined) warning('S4-apply', `${result.name}: ${result.note}`)
173
+ continue
174
+ }
175
+ const stageLabel = { resolve: '入口解析', import: 'import 载入', apply: '插件应用' }[result.stage] ?? result.stage
176
+ error('S4-apply', `${result.name} 在 ${stageLabel} 阶段失败: ${result.error}(装入用户 Cowork 会在启动链同点崩溃)`)
177
+ }
178
+ return { findings, workspace: options.keepWorkspace ? workspace : undefined }
179
+ } finally {
180
+ cleanup()
181
+ }
182
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@tokensapi/dsh-plugin-check",
3
+ "version": "0.1.0",
4
+ "description": "TokensCowork/DSH \u63d2\u4ef6\u5408\u683c\u6027\u4f53\u68c0:\u4e00\u6761\u547d\u4ee4\u5224\u65ad\u63d2\u4ef6\u80fd\u5426\u5b89\u5168\u88c5\u5165\u5bbf\u4e3b,\u4e0d\u628a Cowork \u641e\u5d29\u3002",
5
+ "type": "module",
6
+ "bin": {
7
+ "dsh-plugin-check": "bin/dsh-plugin-check.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "test": "node --test \"tests/*.test.mjs\""
16
+ },
17
+ "engines": {
18
+ "node": ">=22.0.0"
19
+ },
20
+ "dependencies": {
21
+ "js-yaml": "^4.1.0",
22
+ "semver": "^7.6.0"
23
+ },
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/TokensAPI/TokensCowork.git",
28
+ "directory": "plugin-check"
29
+ }
30
+ }