@tokensapi/dsh-plugin-check 0.3.0 → 0.3.2

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 CHANGED
@@ -47,9 +47,21 @@ npx @tokensapi/dsh-plugin-check --package @scope/name@1.2.3 --runtime 0.1.3-alph
47
47
  | M9-license | warning | 建议声明 license | 分发合规 |
48
48
  | M10-secrets | error/warning | 发布内容不得包含 `.env`、私钥等凭据样式文件 | 凭据泄露 |
49
49
  | S1-pack | error | `npm pack --ignore-scripts` 必须成功 | 包本身发布不出来 |
50
- | S2-install | error | 按你声明的依赖必须装得出来 | 用户受控安装会同样失败 |
50
+ | S2-install | error/warning | 按你声明的依赖必须装得出来;宿主内核包(`@deepseek-ai/*`)的内部版本在公开源取不到时降级为 warning 并跳过冒烟 | 用户受控安装会同样失败 |
51
51
  | S3/S4-apply | error | 每个补丁行:入口可解析、`import` 不抛、`ctx.plugin()` 应用不抛(声明 `inject` 等待服务注入属正常,不算失败) | **启动链击穿**——一行 import 失败会拖死整棵插件树 |
52
52
 
53
+ ## 会话内体检(装进 Cowork)
54
+
55
+ 本包同时是一个 Cowork 插件。装进宿主后会注册 `plugin_check` 工具,在会话里直接问"这个插件能装吗":
56
+
57
+ ```
58
+ plugin_check(package="@scope/name", version="1.2.3")
59
+ ```
60
+
61
+ 会话内跑的是**清单规则 + 包内补丁行检查**:工具会把已发布的 tarball 取下来,解出 `cordis.patch.yml` 真读补丁行——只看 registry 清单会漏掉"补丁行 `name` 写成 cordis 插件名而不是 npm 包名"这类装上即让整棵插件树崩溃的缺陷。取不到包时如实标注补丁未检查(warning),不会因网络问题把插件判成不合格。
62
+
63
+ 隔离启动冒烟无法在宿主进程里执行,完整体检仍走 CLI 或 Plugin Check workflow。
64
+
53
65
  ## `dsh.engine` 字段
54
66
 
55
67
  在 package.json 声明你适配的 DSH 运行时范围:
@@ -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 CHANGED
@@ -1,3 +1,3 @@
1
- - insert:
2
- - id: tokens-plugin-check
3
- name: '@tokensapi/dsh-plugin-check'
1
+ - insert:
2
+ - id: tokens-plugin-check
3
+ name: '@tokensapi/dsh-plugin-check'
@@ -2,12 +2,14 @@
2
2
  * Cowork 插件入口:会话内插件体检工具
3
3
  * ============================================================
4
4
  * 装进 TokensCowork 后注册 plugin_check 工具:对任意 npm 插件包
5
- * 跑清单规则(与 CLI 的清单阶段同源),模型或用户在会话里即可
6
- * 判断"这个插件装进来会不会有问题"。含隔离启动冒烟的完整体检
7
- * 仍走 CLI:npx @tokensapi/dsh-plugin-check --package <name@ver>。
5
+ * 跑清单规则 + 包内补丁行检查(与 CLI 的清单阶段同源),模型或
6
+ * 用户在会话里即可判断"这个插件装进来会不会有问题"。补丁行会
7
+ * tarball 里真读出来 —— 只看 registry 清单会漏掉"补丁行 name
8
+ * 写成插件名"这类装上即崩的缺陷。含隔离启动冒烟的完整体检仍走
9
+ * CLI:npx @tokensapi/dsh-plugin-check --package <name@ver>。
8
10
  * ============================================================ */
9
11
  import { defineTool } from '@deepseek-ai/dsh-tools'
10
- import { checkPublishedManifest } from './registry-check.mjs'
12
+ import { checkPublishedPackage } from './registry-check.mjs'
11
13
 
12
14
  /** 缺省比对的运行时版本;宿主升级后可通过插件配置覆盖。 */
13
15
  const DEFAULT_RUNTIME = '0.1.3-alpha.1'
@@ -64,14 +66,17 @@ export function apply(ctx, config = {}) {
64
66
  isConcurrencySafe: () => true,
65
67
  async execute(args) {
66
68
  const manifest = await fetchManifest(args.package, args.version ?? 'latest')
67
- const findings = checkPublishedManifest(manifest, runtime)
69
+ const { findings, inspectedPatch } = await checkPublishedPackage(manifest, runtime)
68
70
  return {
69
71
  package: manifest.name,
70
72
  version: manifest.version,
71
73
  runtime,
72
74
  ok: !findings.some(finding => finding.level === 'error'),
75
+ inspectedPatch,
73
76
  findings,
74
- note: '以上为清单规则;含隔离启动冒烟的完整体检请运行 npx @tokensapi/dsh-plugin-check --package <name@version>',
77
+ note: inspectedPatch
78
+ ? '以上为清单规则 + 包内补丁行检查;含隔离启动冒烟的完整体检请运行 npx @tokensapi/dsh-plugin-check --package <name@version>'
79
+ : '未读到包内补丁文件,补丁行未检查;请运行 npx @tokensapi/dsh-plugin-check --package <name@version> 做完整体检',
75
80
  }
76
81
  },
77
82
  }))
@@ -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
+ }
@@ -8,7 +8,7 @@
8
8
  import { existsSync, readFileSync, statSync } from 'node:fs'
9
9
  import { join, resolve } from 'node:path'
10
10
  import semver from 'semver'
11
- import { extractPatchRows } from './patch-rows.mjs'
11
+ import { checkPatchContent } from './patch-rows.mjs'
12
12
 
13
13
  /** 安装即执行任意代码的脚本;市场受控安装的既有红线。 */
14
14
  const FORBIDDEN_LIFECYCLE = ['preinstall', 'install', 'postinstall', 'prepare']
@@ -83,18 +83,13 @@ export function checkManifest(pluginDir, options = {}) {
83
83
  if (!existsSync(patchPath)) {
84
84
  error('M4-bundle', `dsh.bundle.patch 指向的文件不存在: ${bundle.patch}`)
85
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
- }
86
+ // M4/M5 与会话内体检共用同一份实现,避免两条路径判定漂移。
87
+ const checked = checkPatchContent(
88
+ typeof manifest.name === 'string' ? manifest.name : '',
89
+ readFileSync(patchPath, 'utf8'),
90
+ )
91
+ findings.push(...checked.findings)
92
+ rows = checked.rows
98
93
  }
99
94
  }
100
95
 
package/lib/npm.mjs CHANGED
@@ -1,13 +1,13 @@
1
- /* npm 子进程调用:统一 --ignore-scripts,被检代码没有机会在检查器
2
- * 进程里执行安装钩子。 */
3
- import { spawnSync } from 'node:child_process'
4
-
5
- export function runNpm(args, cwd, timeout) {
6
- return spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, {
7
- cwd,
8
- encoding: 'utf8',
9
- timeout,
10
- shell: process.platform === 'win32',
11
- env: { ...process.env, npm_config_ignore_scripts: 'true' },
12
- })
13
- }
1
+ /* npm 子进程调用:统一 --ignore-scripts,被检代码没有机会在检查器
2
+ * 进程里执行安装钩子。 */
3
+ import { spawnSync } from 'node:child_process'
4
+
5
+ export function runNpm(args, cwd, timeout) {
6
+ return spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, {
7
+ cwd,
8
+ encoding: 'utf8',
9
+ timeout,
10
+ shell: process.platform === 'win32',
11
+ env: { ...process.env, npm_config_ignore_scripts: 'true' },
12
+ })
13
+ }
@@ -1,86 +1,108 @@
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
- }
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
+ }
28
87
 
29
88
  /**
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[] }}
89
+ * 对补丁内容执行 M4/M5 判定。
90
+ * 目录体检与会话内体检共用这一份实现:两边曾经各写各的,结果
91
+ * 会话内看不到 M5,把装上即崩的插件判成合格。
92
+ * @param {string} packageName - 本包 npm 包名。
93
+ * @param {string} content - cordis.patch.yml 全文。
94
+ * @returns {{ findings: Array<{level: 'error', rule: string, message: string}>, rows: object[] }}
35
95
  */
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
96
+ export function checkPatchContent(packageName, content) {
97
+ const findings = []
98
+ const error = (rule, message) => findings.push({ level: 'error', rule, message })
99
+ const { rows, problems } = extractPatchRows(content)
100
+ for (const problem of problems) error('M4-bundle', `补丁行不合法: ${problem}`)
101
+ if (rows.length === 0 && problems.length === 0) error('M4-bundle', '补丁未声明任何插件行')
102
+ for (const row of rows) {
103
+ if (packageName !== '' && row.name !== packageName && !row.name.startsWith(`${packageName}/`)) {
104
+ error('M5-row-scope', `补丁行 "${row.id ?? row.name}" 指向 ${row.name},超出本包命名空间(${packageName});插件只能挂载自己的入口`)
75
105
  }
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
106
  }
84
- candidates.forEach((entry, index) => visit(entry, `第 ${index + 1} 行`))
85
- return { rows, problems }
107
+ return { findings, rows }
86
108
  }
@@ -3,9 +3,17 @@
3
3
  * ============================================================
4
4
  * 对 npm registry 的版本文档跑清单规则,与目录体检(manifest-check)
5
5
  * 同一套语义,但不需要文件系统 —— 供 Cowork 插件进程内使用。
6
+ *
7
+ * 两个入口:
8
+ * - checkPublishedManifest 纯同步零依赖,只看清单字段;
9
+ * - checkPublishedPackage 额外取 tarball 读出 cordis.patch.yml,
10
+ * 补上 M4/M5 —— 只看清单会漏掉"补丁行 name 写成插件名"这类
11
+ * 装上即让整棵插件树崩溃的缺陷。
6
12
  * 冒烟阶段无法在宿主进程执行,完整体检走 CLI 或 Plugin Check
7
13
  * workflow。
8
14
  * ============================================================ */
15
+ import { checkPatchContent } from './patch-rows.mjs'
16
+ import { fetchTarEntry } from './tarball.mjs'
9
17
 
10
18
  const FORBIDDEN_LIFECYCLE = ['preinstall', 'install', 'postinstall', 'prepare']
11
19
  const isIdentityCore = (name) =>
@@ -156,3 +164,42 @@ export function checkPublishedManifest(manifest, runtime) {
156
164
 
157
165
  return findings
158
166
  }
167
+
168
+ /**
169
+ * 已发布包的完整清单侧体检:清单规则 + 包内补丁行检查。
170
+ * 取不到 tarball(离线、超时、体积过大)时降级为 warning 并如实
171
+ * 标注未检查,绝不因网络问题把插件判成不合格。
172
+ * @param {object} manifest - registry 版本文档(需含 dist.tarball)。
173
+ * @param {string} runtime - 目标 DSH 运行时版本。
174
+ * @param {{ maxBytes?: number, timeoutMs?: number }} [options]
175
+ * @returns {Promise<{ findings: object[], inspectedPatch: boolean }>}
176
+ */
177
+ export async function checkPublishedPackage(manifest, runtime, options = {}) {
178
+ const findings = checkPublishedManifest(manifest, runtime)
179
+ const patch = typeof manifest.dsh?.bundle?.patch === 'string' ? manifest.dsh.bundle.patch : ''
180
+ const tarball = typeof manifest.dist?.tarball === 'string' ? manifest.dist.tarball : ''
181
+ // 补丁未声明时 M4 已在清单阶段报错,无需再取包。
182
+ if (patch === '' || tarball === '') return { findings, inspectedPatch: false }
183
+
184
+ const relative = patch.replace(/^\.?\//u, '')
185
+ const result = await fetchTarEntry(tarball, entry => entry === relative, options)
186
+ if (result.error !== undefined) {
187
+ findings.push({
188
+ level: 'warning',
189
+ rule: 'M4-bundle',
190
+ message: `未能读取包内 ${relative}(${result.error});本次跳过补丁行检查,完整体检请走 CLI`,
191
+ })
192
+ return { findings, inspectedPatch: false }
193
+ }
194
+ if (result.content === undefined) {
195
+ findings.push({
196
+ level: 'error',
197
+ rule: 'M4-bundle',
198
+ message: `dsh.bundle.patch 指向的文件不在发布内容里: ${patch};宿主取不到补丁就挂不上插件(检查 files 白名单)`,
199
+ })
200
+ return { findings, inspectedPatch: true }
201
+ }
202
+ const packageName = typeof manifest.name === 'string' ? manifest.name : ''
203
+ findings.push(...checkPatchContent(packageName, result.content).findings)
204
+ return { findings, inspectedPatch: true }
205
+ }
@@ -20,6 +20,7 @@ import { runNpm } from './npm.mjs'
20
20
 
21
21
  const RUNNER_TIMEOUT_MS = 90_000
22
22
  const INSTALL_TIMEOUT_MS = 300_000
23
+ const PROBE_TIMEOUT_MS = 60_000
23
24
 
24
25
  /** 子进程执行的行级冒烟脚本;结果写文件,避开插件对 stdout 的污染。 */
25
26
  const RUNNER_SOURCE = `
@@ -78,6 +79,33 @@ process.exit(0)
78
79
  `
79
80
 
80
81
 
82
+ /** 宿主内核作用域:这些包由宿主提供,私有版本本就不在公开源上。 */
83
+ const CORE_SCOPE = '@deepseek-ai/'
84
+
85
+ /**
86
+ * 找出「公开源上取不到、但不该算插件问题」的依赖。
87
+ * 宿主内核包(@deepseek-ai/*)的内部版本只发在私有源,检查器在
88
+ * 公网环境里必然解析不到 —— 据此判插件不合格是冤枉它。其它包
89
+ * 解析不到就是插件自己声明错了范围,仍按 error 处理。
90
+ * @param {Record<string, string>} dependencies - 工作区依赖表。
91
+ * @param {string} workspace - 探测执行目录。
92
+ * @returns {string[]} 取不到的内核包(带版本范围)。
93
+ */
94
+ function findUnreachableCoreDependencies(dependencies, workspace) {
95
+ const missing = []
96
+ for (const [name, range] of Object.entries(dependencies)) {
97
+ if (!name.startsWith(CORE_SCOPE)) continue
98
+ if (typeof range !== 'string' || range.startsWith('file:')) continue
99
+ const view = runNpm(['view', `${name}@${range}`, 'version', '--json'], workspace, PROBE_TIMEOUT_MS)
100
+ const output = `${view.stderr}${view.stdout}`
101
+ // 版本存在时 npm view 会打印版本号;解析不到才算取不到。
102
+ if (view.status !== 0 || output.trim() === '' || output.trim() === '[]') {
103
+ missing.push(`${name}@${range}`)
104
+ }
105
+ }
106
+ return missing
107
+ }
108
+
81
109
  /**
82
110
  * 对插件目录执行隔离冒烟。
83
111
  * @param {string} pluginDir - 插件根目录。
@@ -130,7 +158,16 @@ export function checkSmoke(pluginDir, manifest, rows, options = {}) {
130
158
  INSTALL_TIMEOUT_MS,
131
159
  )
132
160
  if (install.status !== 0) {
133
- error('S2-install', `依赖装不出来(用户侧受控安装会同样失败): ${(install.stderr || '').trim().split('\n').slice(-4).join(' ')}`)
161
+ const detail = (install.stderr || '').trim().split('\n').slice(-4).join(' ')
162
+ // 区分「真装不出来」和「内核内部版本在公开源取不到」:后者是
163
+ // 检查环境的限制,判成不合格会冤枉插件,只如实标注冒烟未执行。
164
+ const unreachable = findUnreachableCoreDependencies(dependencies, workspace)
165
+ if (unreachable.length > 0) {
166
+ warning('S2-install', `宿主内核依赖 ${unreachable.join('、')} 在公开 npm 上取不到(内部版本),隔离冒烟无法执行,已跳过;`
167
+ + `清单规则仍已判定。如需完整冒烟,请在能访问这些包的环境里重跑。原始错误: ${detail}`)
168
+ } else {
169
+ error('S2-install', `依赖装不出来(用户侧受控安装会同样失败): ${detail}`)
170
+ }
134
171
  return { findings, workspace: options.keepWorkspace ? workspace : undefined }
135
172
  }
136
173
 
@@ -0,0 +1,98 @@
1
+ /* ============================================================
2
+ * npm tarball 单文件读取(零依赖)
3
+ * ============================================================
4
+ * registry 的版本清单里没有包内文件内容,而 M4/M5 这类规则必须
5
+ * 读到 cordis.patch.yml 本身 —— 曾有插件把补丁行的 name 写成
6
+ * cordis 插件名而非包名,清单看不出任何异常,装上却让整棵插件树
7
+ * 加载失败。会话内体检没有 npm 子进程可用,因此这里直接取
8
+ * dist.tarball,用 node:zlib 解 gzip 后自行走 tar 头,只取需要的
9
+ * 那一个文件。
10
+ * ============================================================ */
11
+ import { gunzipSync } from 'node:zlib'
12
+
13
+ const BLOCK = 512
14
+ const DEFAULT_MAX_BYTES = 25 * 1024 * 1024
15
+ const DEFAULT_TIMEOUT_MS = 20_000
16
+
17
+ const readString = (buffer, offset, length) => {
18
+ const slice = buffer.subarray(offset, offset + length)
19
+ const end = slice.indexOf(0)
20
+ return slice.subarray(0, end === -1 ? slice.length : end).toString('utf8')
21
+ }
22
+
23
+ /** tar 头里的数值字段是补零的八进制字符串;空字段按 0 处理。 */
24
+ const readOctal = (buffer, offset, length) => {
25
+ const raw = readString(buffer, offset, length).trim()
26
+ if (raw === '') return 0
27
+ const value = Number.parseInt(raw, 8)
28
+ return Number.isFinite(value) ? value : 0
29
+ }
30
+
31
+ /**
32
+ * 遍历 tar,返回首个匹配的条目内容。
33
+ * 处理 ustar 的 prefix 拼接与 pax(typeflag 'x')的路径覆盖,
34
+ * 因此长路径与非 ASCII 路径同样可取。
35
+ * @param {Buffer} tar - 解压后的 tar 字节。
36
+ * @param {(path: string) => boolean} match - 路径判定(已剥掉顶层 package/)。
37
+ * @returns {string | undefined}
38
+ */
39
+ export function readTarEntry(tar, match) {
40
+ let offset = 0
41
+ let paxPath
42
+ while (offset + BLOCK <= tar.length) {
43
+ const header = tar.subarray(offset, offset + BLOCK)
44
+ if (header.every(byte => byte === 0)) break
45
+ const name = readString(header, 0, 100)
46
+ const size = readOctal(header, 124, 12)
47
+ const typeflag = readString(header, 156, 1)
48
+ const prefix = readString(header, 345, 155)
49
+ const dataOffset = offset + BLOCK
50
+ const padded = Math.ceil(size / BLOCK) * BLOCK
51
+ if (typeflag === 'x' || typeflag === 'X') {
52
+ // pax 扩展头:形如 "<len> path=<value>\n",覆盖紧随其后的条目路径。
53
+ const record = tar.subarray(dataOffset, dataOffset + size).toString('utf8')
54
+ const found = /(?:^|\n)\d+ path=([^\n]*)\n/u.exec(record)
55
+ paxPath = found === null ? undefined : found[1]
56
+ offset = dataOffset + padded
57
+ continue
58
+ }
59
+ const full = paxPath ?? (prefix === '' ? name : `${prefix}/${name}`)
60
+ paxPath = undefined
61
+ if (typeflag === '' || typeflag === '0') {
62
+ // npm 打包约定顶层统一为 package/。
63
+ const relative = full.replace(/^[^/]+\//u, '')
64
+ if (match(relative)) return tar.subarray(dataOffset, dataOffset + size).toString('utf8')
65
+ }
66
+ offset = dataOffset + padded
67
+ }
68
+ return undefined
69
+ }
70
+
71
+ /**
72
+ * 下载并解出 tarball 中的一个文件。
73
+ * 任何取不到的情形都以 { error } 返回,由调用方降级成提示而不是判定
74
+ * 插件不合格 —— 网络问题不是插件的过错。
75
+ * @param {string} url - registry 的 dist.tarball。
76
+ * @param {(path: string) => boolean} match
77
+ * @param {{ maxBytes?: number, timeoutMs?: number }} [options]
78
+ * @returns {Promise<{ content?: string } | { error: string }>}
79
+ */
80
+ export async function fetchTarEntry(url, match, options = {}) {
81
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES
82
+ const controller = new AbortController()
83
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
84
+ try {
85
+ const response = await fetch(url, { signal: controller.signal })
86
+ if (!response.ok) return { error: `下载 tarball 失败(HTTP ${response.status})` }
87
+ const declared = Number(response.headers.get('content-length') ?? '0')
88
+ if (declared > maxBytes) return { error: `tarball 超过 ${Math.round(maxBytes / 1024 / 1024)}MB,跳过包内检查` }
89
+ const raw = Buffer.from(await response.arrayBuffer())
90
+ if (raw.length > maxBytes) return { error: `tarball 超过 ${Math.round(maxBytes / 1024 / 1024)}MB,跳过包内检查` }
91
+ return { content: readTarEntry(gunzipSync(raw), match) }
92
+ } catch (cause) {
93
+ const reason = cause instanceof Error ? cause.message : String(cause)
94
+ return { error: cause?.name === 'AbortError' ? '下载 tarball 超时' : `读取 tarball 失败: ${reason}` }
95
+ } finally {
96
+ clearTimeout(timer)
97
+ }
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokensapi/dsh-plugin-check",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "TokensCowork/DSH \u63d2\u4ef6\u5408\u683c\u6027\u4f53\u68c0:CLI \u4e00\u6761\u547d\u4ee4\u5224\u5b9a\u63d2\u4ef6\u80fd\u5426\u5b89\u5168\u88c5\u5165\u5bbf\u4e3b;\u88c5\u5165 Cowork \u540e\u63d0\u4f9b\u4f1a\u8bdd\u5185 plugin_check \u5de5\u5177\u3002",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,8 +25,7 @@
25
25
  "license": "MIT",
26
26
  "repository": {
27
27
  "type": "git",
28
- "url": "https://github.com/TokensAPI/TokensCowork.git",
29
- "directory": "plugin-check"
28
+ "url": "https://github.com/TokensAPI/tokens_DshPluginCheck_code.git"
30
29
  },
31
30
  "main": "lib/cowork-plugin.mjs",
32
31
  "exports": {