@tokensapi/dsh-plugin-check 0.1.0 → 0.3.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 CHANGED
@@ -16,6 +16,9 @@ npx @tokensapi/dsh-plugin-check --skip-smoke
16
16
 
17
17
  # 机器可读输出(CI / 市场门禁)
18
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
19
22
  ```
20
23
 
21
24
  退出码:`0` 合格,`1` 存在 error(`--strict` 时 warning 也计入)。冒烟失败时加 `--keep-workspace` 保留临时工作区排查。
@@ -71,4 +74,6 @@ npx @tokensapi/dsh-plugin-check --json
71
74
 
72
75
  ## 上架流程中的位置
73
76
 
74
- 市场上架要求本工具体检通过:提交插件版本后,门禁会以相同规则复检并记录"验证于运行时 x.y.z"。本地先跑一遍,提交即通过。
77
+ 1. 开发者:发布 npm 版本前本地跑体检,全绿再发。
78
+ 2. 管理员:在仓库 Actions 的 **Plugin Check** workflow 填「包名@版本」手动触发,体检报告生成在该次运行的 Summary。
79
+ 3. 上架:后台 publish 对话框把这次运行的链接填入「检查记录链接」——链接的红/绿即审核证据,`reviewed_version` 随上架落库。存在 error 的版本不满足上架条件。
@@ -17,18 +17,20 @@
17
17
  import { resolve } from 'node:path'
18
18
  import { checkManifest } from '../lib/manifest-check.mjs'
19
19
  import { checkSmoke } from '../lib/smoke-check.mjs'
20
+ import { fetchPackage } from '../lib/fetch-package.mjs'
20
21
 
21
22
  const args = process.argv.slice(2)
22
- const options = { dir: process.cwd(), runtime: undefined, skipSmoke: false, keepWorkspace: false, strict: false, json: false }
23
+ const options = { dir: process.cwd(), package: undefined, runtime: undefined, skipSmoke: false, keepWorkspace: false, strict: false, json: false }
23
24
  for (let index = 0; index < args.length; index += 1) {
24
25
  const arg = args[index]
25
26
  if (arg === '--runtime') { options.runtime = args[++index]; continue }
27
+ if (arg === '--package') { options.package = args[++index]; continue }
26
28
  if (arg === '--skip-smoke') { options.skipSmoke = true; continue }
27
29
  if (arg === '--keep-workspace') { options.keepWorkspace = true; continue }
28
30
  if (arg === '--strict') { options.strict = true; continue }
29
31
  if (arg === '--json') { options.json = true; continue }
30
32
  if (arg === '--help' || arg === '-h') {
31
- process.stdout.write('用法: dsh-plugin-check [插件目录] [--runtime <version>] [--skip-smoke] [--keep-workspace] [--strict] [--json]\n')
33
+ process.stdout.write('用法: dsh-plugin-check [插件目录] [--package <name@version>] [--runtime <version>] [--skip-smoke] [--keep-workspace] [--strict] [--json]\n')
32
34
  process.exit(0)
33
35
  }
34
36
  if (arg.startsWith('--')) {
@@ -41,6 +43,16 @@ for (let index = 0; index < args.length; index += 1) {
41
43
  const findings = []
42
44
  const phases = []
43
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
+
44
56
  const manifestResult = checkManifest(options.dir, { runtime: options.runtime })
45
57
  findings.push(...manifestResult.findings)
46
58
  phases.push({ phase: 'manifest', findings: manifestResult.findings })
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: tokens-plugin-check
3
+ name: '@tokensapi/dsh-plugin-check'
@@ -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
+ }
@@ -0,0 +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
+ }
package/lib/npm.mjs ADDED
@@ -0,0 +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
+ }
@@ -0,0 +1,158 @@
1
+ /* ============================================================
2
+ * 已发布包体检(注册表版,零依赖纯函数)
3
+ * ============================================================
4
+ * 对 npm registry 的版本文档跑清单规则,与目录体检(manifest-check)
5
+ * 同一套语义,但不需要文件系统 —— 供 Cowork 插件进程内使用。
6
+ * 冒烟阶段无法在宿主进程执行,完整体检走 CLI 或 Plugin Check
7
+ * workflow。
8
+ * ============================================================ */
9
+
10
+ const FORBIDDEN_LIFECYCLE = ['preinstall', 'install', 'postinstall', 'prepare']
11
+ const isIdentityCore = (name) =>
12
+ name === '@deepseek-ai/cordis' || name.startsWith('@deepseek-ai/cordis-') || name.startsWith('@deepseek-ai/dsh')
13
+
14
+ /* ------------------------- 迷你 SemVer ------------------------- */
15
+ // 只实现体检需要的子集:比较 + 范围判定(空格=且、||=或、^、~、
16
+ // >=、>、<=、<、=、裸版本),预发布参与比较;识别不了的范围返回
17
+ // null 由调用方按"范围不合法"处理。
18
+
19
+ function parseVersion(input) {
20
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(String(input).trim())
21
+ if (!match) return null
22
+ return {
23
+ parts: [Number(match[1]), Number(match[2]), Number(match[3])],
24
+ prerelease: match[4] === undefined ? [] : match[4].split('.'),
25
+ }
26
+ }
27
+
28
+ function compareVersions(a, b) {
29
+ for (let index = 0; index < 3; index += 1) {
30
+ if (a.parts[index] !== b.parts[index]) return a.parts[index] < b.parts[index] ? -1 : 1
31
+ }
32
+ if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
33
+ if (a.prerelease.length === 0) return 1
34
+ if (b.prerelease.length === 0) return -1
35
+ const length = Math.max(a.prerelease.length, b.prerelease.length)
36
+ for (let index = 0; index < length; index += 1) {
37
+ const left = a.prerelease[index]
38
+ const right = b.prerelease[index]
39
+ if (left === undefined) return -1
40
+ if (right === undefined) return 1
41
+ const leftNumeric = /^\d+$/u.test(left)
42
+ const rightNumeric = /^\d+$/u.test(right)
43
+ if (leftNumeric && rightNumeric) {
44
+ if (Number(left) !== Number(right)) return Number(left) < Number(right) ? -1 : 1
45
+ } else if (leftNumeric !== rightNumeric) {
46
+ return leftNumeric ? -1 : 1
47
+ } else if (left !== right) {
48
+ return left < right ? -1 : 1
49
+ }
50
+ }
51
+ return 0
52
+ }
53
+
54
+ function comparatorHolds(version, operator, boundary) {
55
+ const cmp = compareVersions(version, boundary)
56
+ if (operator === '>') return cmp > 0
57
+ if (operator === '>=') return cmp >= 0
58
+ if (operator === '<') return cmp < 0
59
+ if (operator === '<=') return cmp <= 0
60
+ return cmp === 0
61
+ }
62
+
63
+ export function versionSatisfies(versionInput, rangeInput) {
64
+ const version = parseVersion(versionInput)
65
+ if (version === null) return null
66
+ const alternatives = String(rangeInput).split('||')
67
+ let recognized = false
68
+ for (const alternative of alternatives) {
69
+ const comparators = alternative.trim().split(/\s+/u).filter(Boolean)
70
+ if (comparators.length === 0) continue
71
+ let holds = true
72
+ let valid = true
73
+ for (const comparator of comparators) {
74
+ if (comparator === '*' || comparator === 'x') continue
75
+ const match = /^(>=|<=|>|<|=|\^|~)?(.+)$/u.exec(comparator)
76
+ const boundary = parseVersion(match[2])
77
+ if (boundary === null) { valid = false; break }
78
+ const operator = match[1] ?? '='
79
+ if (operator === '^') {
80
+ const upper = boundary.parts[0] > 0
81
+ ? { parts: [boundary.parts[0] + 1, 0, 0], prerelease: ['0'] }
82
+ : boundary.parts[1] > 0
83
+ ? { parts: [0, boundary.parts[1] + 1, 0], prerelease: ['0'] }
84
+ : { parts: [0, 0, boundary.parts[2] + 1], prerelease: ['0'] }
85
+ holds = holds && comparatorHolds(version, '>=', boundary) && comparatorHolds(version, '<', upper)
86
+ } else if (operator === '~') {
87
+ const upper = { parts: [boundary.parts[0], boundary.parts[1] + 1, 0], prerelease: ['0'] }
88
+ holds = holds && comparatorHolds(version, '>=', boundary) && comparatorHolds(version, '<', upper)
89
+ } else {
90
+ holds = holds && comparatorHolds(version, operator, boundary)
91
+ }
92
+ if (!holds) break
93
+ }
94
+ if (!valid) continue
95
+ recognized = true
96
+ if (holds) return true
97
+ }
98
+ return recognized ? false : null
99
+ }
100
+
101
+ /* --------------------------- 规则 --------------------------- */
102
+
103
+ /**
104
+ * 对一份 npm 版本清单跑体检规则。
105
+ * @param {object} manifest - registry 版本文档(scripts/dependencies/
106
+ * peerDependencies/dsh/engines/license/version)。
107
+ * @param {string} runtime - 目标 DSH 运行时版本。
108
+ * @returns {Array<{level: 'error'|'warning', rule: string, message: string}>}
109
+ */
110
+ export function checkPublishedManifest(manifest, runtime) {
111
+ const findings = []
112
+ const error = (rule, message) => findings.push({ level: 'error', rule, message })
113
+ const warning = (rule, message) => findings.push({ level: 'warning', rule, message })
114
+
115
+ const scripts = manifest.scripts ?? {}
116
+ for (const script of FORBIDDEN_LIFECYCLE) {
117
+ if (typeof scripts[script] === 'string') {
118
+ error('M2-lifecycle', `禁止 ${script} 生命周期脚本(安装即执行任意代码);构建请改用 prepack`)
119
+ }
120
+ }
121
+
122
+ for (const name of Object.keys(manifest.dependencies ?? {})) {
123
+ if (isIdentityCore(name)) {
124
+ error('M3-core-peer', `${name} 出现在 dependencies;核心运行时必须是 peerDependencies,否则双内核实例会让 Cowork 启动失败`)
125
+ }
126
+ }
127
+
128
+ const bundle = manifest.dsh?.bundle ?? {}
129
+ if (typeof bundle.patch !== 'string' || bundle.patch === '') {
130
+ error('M4-bundle', 'dsh.bundle.patch 缺失;宿主靠它把插件挂进加载树')
131
+ }
132
+
133
+ const engine = typeof manifest.dsh?.engine === 'string' ? manifest.dsh.engine : undefined
134
+ if (engine === undefined) {
135
+ warning('M6-engine', 'dsh.engine 未声明(目标运行时 SemVer 范围);该字段将成为上架必填')
136
+ } else {
137
+ const satisfied = versionSatisfies(runtime, engine)
138
+ if (satisfied === null) error('M6-engine', `dsh.engine 不是可识别的 SemVer 范围: ${engine}`)
139
+ else if (!satisfied) error('M6-engine', `dsh.engine "${engine}" 不覆盖当前运行时 ${runtime}`)
140
+ }
141
+
142
+ for (const [name, range] of Object.entries(manifest.peerDependencies ?? {})) {
143
+ if (!name.startsWith('@deepseek-ai/dsh')) continue
144
+ const satisfied = versionSatisfies(runtime, String(range))
145
+ if (satisfied === null) error('M7-peer-range', `peer ${name} 的范围不可识别: ${range}`)
146
+ else if (!satisfied) warning('M7-peer-range', `peer ${name}@"${range}" 不含当前运行时 ${runtime};宿主升级后可能运行时断裂`)
147
+ }
148
+
149
+ if (manifest.engines?.node === undefined) warning('M8-node-engines', 'engines.node 未声明;宿主运行在 Node 22+')
150
+ if (manifest.license === undefined) warning('M9-license', 'license 未声明')
151
+
152
+ const parsed = parseVersion(typeof manifest.version === 'string' ? manifest.version : '')
153
+ if (parsed !== null && parsed.prerelease.length > 0) {
154
+ warning('M1-manifest', `version ${manifest.version} 是预发布版;市场上架要求精确稳定版`)
155
+ }
156
+
157
+ return findings
158
+ }
@@ -16,6 +16,7 @@ import { spawnSync } from 'node:child_process'
16
16
  import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
17
17
  import { tmpdir } from 'node:os'
18
18
  import { join, resolve } from 'node:path'
19
+ import { runNpm } from './npm.mjs'
19
20
 
20
21
  const RUNNER_TIMEOUT_MS = 90_000
21
22
  const INSTALL_TIMEOUT_MS = 300_000
@@ -76,15 +77,6 @@ writeFileSync(resultPath, JSON.stringify(results))
76
77
  process.exit(0)
77
78
  `
78
79
 
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
80
 
89
81
  /**
90
82
  * 对插件目录执行隔离冒烟。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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",
3
+ "version": "0.3.0",
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": {
7
7
  "dsh-plugin-check": "bin/dsh-plugin-check.mjs"
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "lib",
12
+ "cordis.patch.yml",
12
13
  "README.md"
13
14
  ],
14
15
  "scripts": {
@@ -26,5 +27,19 @@
26
27
  "type": "git",
27
28
  "url": "https://github.com/TokensAPI/TokensCowork.git",
28
29
  "directory": "plugin-check"
30
+ },
31
+ "main": "lib/cowork-plugin.mjs",
32
+ "exports": {
33
+ ".": "./lib/cowork-plugin.mjs"
34
+ },
35
+ "peerDependencies": {
36
+ "@deepseek-ai/cordis": "4.0.1 || 4.0.2",
37
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.8 || 0.1.3-alpha.1"
38
+ },
39
+ "dsh": {
40
+ "engine": ">=0.1.0-rc.8 <0.2.0",
41
+ "bundle": {
42
+ "patch": "./cordis.patch.yml"
43
+ }
29
44
  }
30
45
  }