@tokensapi/dsh-plugin-check 0.3.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 -3
- package/lib/cowork-plugin.mjs +78 -78
- 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 -158
- package/lib/smoke-check.mjs +174 -174
- package/package.json +44 -45
package/lib/manifest-check.mjs
CHANGED
|
@@ -1,144 +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
|
-
}
|
|
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
|
+
}
|
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
|
+
}
|
package/lib/patch-rows.mjs
CHANGED
|
@@ -1,86 +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
|
-
}
|
|
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
|
+
}
|