@bushizhanghan/dsh-publish-gate 0.3.1 → 0.3.3
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 +3 -3
- package/lib/engine.js +92 -2
- package/lib/index.js +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
30
30
|
| 工具 | 作用 |
|
|
31
31
|
|---|---|
|
|
32
32
|
| `publish_gate` | 全量门禁:清单 + 产物 + 运行时 + 浏览器半边 + 网络,返回 `safeToPublish`(`skipPack` / `skipTools` / `skipClient` / `skipNetwork` 可逐层跳过) |
|
|
33
|
-
| `publish_gate_tools` | 只跑运行时层:在子进程里 `apply(ctx)`,看注册了哪些工具、有没有缺 `execute`、重名、`presentCall` 抛错、`card` 用了宿主不认识的值 |
|
|
33
|
+
| `publish_gate_tools` | 只跑运行时层:在子进程里 `apply(ctx)`,看注册了哪些工具、有没有缺 `execute`、重名、`presentCall` 抛错、`card` 用了宿主不认识的值 | 传 `call: ["工具名"]` 可以**真的调一次**(空参数):注册永远不会执行 `execute`,所以「一调就抛 ReferenceError」这类 bug 只有这一条能看见。默认不调——有些工具会开程序或写文件,你点名才算同意。 |
|
|
34
34
|
| `publish_gate_client` | 只跑浏览器半边:在子进程里假造 `window.__ModuleLoader__`,看 bundle 会不会加载、`apply` 会不会抛、到底往哪个服务注册了东西、`inject` 有没有漏 |
|
|
35
35
|
| `publish_gate_verify` | 发后校验:`npm pack --dry-run` 的 shasum 和 registry 上的 `dist.shasum` 逐字节比,比版本号猜准 |
|
|
36
36
|
| `publish_gate_patch` | 只查 `cordis.patch.yml`——「插件装了但工具没出现」的第一个怀疑对象 |
|
|
@@ -135,7 +135,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
135
135
|
|
|
136
136
|
### 代码卫生
|
|
137
137
|
|
|
138
|
-
`lib-debugger`(error)、`lib-console-log`、`lib-hardcoded-path`(`C:\Users\...` 硬编码)、`lib-url-pathname`(`new URL(...).pathname` 在中文路径上会百分号编码,得用 `fileURLToPath
|
|
138
|
+
`lib-debugger`(error)、`lib-console-log`、`lib-hardcoded-path`(`C:\Users\...` 硬编码)、`lib-url-pathname`(`new URL(...).pathname` 在中文路径上会百分号编码,得用 `fileURLToPath`)、`node-too-old`(`engines.node` 写着 `>=18`,`lib/` 里却用了 `Object.groupBy`(21)或 `Promise.withResolvers`(22)。npm 对 `engines` 只是警告,照样装上,然后在 import 那一刻抛——用户看到的是「插件坏了」,不是版本不匹配)。字符串和注释里的同名文字不算。
|
|
139
139
|
|
|
140
140
|
### 构建产物:`src/` 和 `lib/` 谁更新
|
|
141
141
|
|
|
@@ -183,7 +183,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
183
183
|
## 开发
|
|
184
184
|
|
|
185
185
|
```sh
|
|
186
|
-
node --test #
|
|
186
|
+
node --test # 66 个引擎单测
|
|
187
187
|
node test/integration.test.mjs # 26 条集成断言(9 个真实插件作 fixture)
|
|
188
188
|
```
|
|
189
189
|
|
package/lib/engine.js
CHANGED
|
@@ -1062,6 +1062,7 @@ ctx.tools = {
|
|
|
1062
1062
|
hasExecute: typeof def?.execute === 'function',
|
|
1063
1063
|
hasOutputSchema: Boolean(def && def.output && def.output.schema),
|
|
1064
1064
|
cardKind: card,
|
|
1065
|
+
run: typeof def?.execute === 'function' ? def.execute : null,
|
|
1065
1066
|
})
|
|
1066
1067
|
walkParams(def?.parameters, typeof def?.name === 'string' ? def.name : '(unnamed)')
|
|
1067
1068
|
},
|
|
@@ -1073,7 +1074,25 @@ try {
|
|
|
1073
1074
|
stage = 'apply'
|
|
1074
1075
|
if (typeof mod.apply !== 'function') throw new Error('lib/index.js does not export apply')
|
|
1075
1076
|
await mod.apply(ctx, {})
|
|
1076
|
-
|
|
1077
|
+
// Calling a tool is opt-in: some tools launch applications or write files, so
|
|
1078
|
+
// the caller names the ones it considers safe. A tool that throws here is a
|
|
1079
|
+
// bug the author cannot see any other way - registration never runs execute.
|
|
1080
|
+
stage = 'call'
|
|
1081
|
+
const wanted = new Set(JSON.parse(process.env.PG_CALLS || '[]'))
|
|
1082
|
+
const calls = []
|
|
1083
|
+
for (const entry of registered) {
|
|
1084
|
+
if (!wanted.has(entry.name) || !entry.run) continue
|
|
1085
|
+
try {
|
|
1086
|
+
await Promise.race([
|
|
1087
|
+
Promise.resolve(entry.run({}, { signal: undefined })),
|
|
1088
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timed out after 8s')), 8000)),
|
|
1089
|
+
])
|
|
1090
|
+
calls.push({ name: entry.name, ok: true })
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
calls.push({ name: entry.name, ok: false, error: String((error && error.message) || error) })
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
emit({ ok: true, tools: registered.map(({ run, ...rest }) => rest), badParamTypes, calls })
|
|
1077
1096
|
} catch (error) {
|
|
1078
1097
|
emit({ ok: false, stage, error: String((error && error.message) || error) })
|
|
1079
1098
|
}
|
|
@@ -1088,6 +1107,7 @@ export async function checkToolRegistrations(dir, opts = {}) {
|
|
|
1088
1107
|
timeout: opts.timeoutMs ?? 20_000,
|
|
1089
1108
|
maxBuffer: 4 * 1024 * 1024,
|
|
1090
1109
|
windowsHide: true,
|
|
1110
|
+
env: { ...process.env, PG_CALLS: JSON.stringify(Array.isArray(opts.calls) ? opts.calls : []) },
|
|
1091
1111
|
})
|
|
1092
1112
|
const line = String(stdout).split('\n').filter((l) => l.startsWith('@@PG@@')).pop()
|
|
1093
1113
|
if (!line) throw new Error('the child printed no result')
|
|
@@ -1132,6 +1152,18 @@ export async function checkToolRegistrations(dir, opts = {}) {
|
|
|
1132
1152
|
}
|
|
1133
1153
|
|
|
1134
1154
|
const seen = new Set()
|
|
1155
|
+
for (const c of Array.isArray(payload.calls) ? payload.calls : []) {
|
|
1156
|
+
if (c.ok) continue
|
|
1157
|
+
out.push(
|
|
1158
|
+
F(
|
|
1159
|
+
'tool-call-throws',
|
|
1160
|
+
'error',
|
|
1161
|
+
`tool "${c.name}" throws when called with no arguments`,
|
|
1162
|
+
`${c.error}. Registration never runs execute, so nothing else in this gate can see it — the consumer hits it on the first call.`,
|
|
1163
|
+
'Guard the missing input, or accept that the tool is unusable without arguments and say so in its description.',
|
|
1164
|
+
),
|
|
1165
|
+
)
|
|
1166
|
+
}
|
|
1135
1167
|
for (const t of tools) {
|
|
1136
1168
|
const label = t.name || '(unnamed)'
|
|
1137
1169
|
if (!t.name) out.push(F('tool-missing-name', 'error', 'a registered tool has no name', 'The host cannot address a tool without a name.', 'Give it a name.'))
|
|
@@ -1202,6 +1234,63 @@ export function checkLibHygiene(dir) {
|
|
|
1202
1234
|
return out
|
|
1203
1235
|
}
|
|
1204
1236
|
|
|
1237
|
+
/**
|
|
1238
|
+
* Features that need a newer Node than the manifest promises.
|
|
1239
|
+
*
|
|
1240
|
+
* `engines.node` is only advisory — npm warns and installs anyway — so a plugin
|
|
1241
|
+
* that says `>=20` and uses `Object.groupBy` (21) breaks on exactly the Node it
|
|
1242
|
+
* advertised. Each entry is [name, regex, minimum major], newest evidence only.
|
|
1243
|
+
*/
|
|
1244
|
+
const NODE_FEATURES = [
|
|
1245
|
+
['Object.groupBy / Map.groupBy', /\b(?:Object|Map)\.groupBy\b/, 21],
|
|
1246
|
+
['Array.fromAsync', /\bArray\.fromAsync\b/, 22],
|
|
1247
|
+
['Promise.withResolvers', /\bPromise\.withResolvers\b/, 22],
|
|
1248
|
+
['import.meta.dirname', /\bimport\.meta\.dirname\b/, 21],
|
|
1249
|
+
['Array.prototype.toSorted / toReversed / toSpliced / with', /\.(?:toSorted|toReversed|toSpliced)\(|\.with\(/, 20],
|
|
1250
|
+
['structuredClone', /\bstructuredClone\s*\(/, 17],
|
|
1251
|
+
]
|
|
1252
|
+
|
|
1253
|
+
/** `>=22.1.0`, `^20`, `>=18 <21` -> the first major it allows. */
|
|
1254
|
+
const minNodeMajor = (range) => {
|
|
1255
|
+
if (typeof range !== 'string') return null
|
|
1256
|
+
const m = /(\d+)/.exec(range)
|
|
1257
|
+
return m ? Number(m[1]) : null
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
export function checkNodeVersion(dir) {
|
|
1261
|
+
const declared = minNodeMajor(readPkgJson(dir)?.engines?.node)
|
|
1262
|
+
if (declared === null) return []
|
|
1263
|
+
const out = []
|
|
1264
|
+
const seen = new Set()
|
|
1265
|
+
for (const file of walkFiles(join(dir, 'lib'), /\.(?:js|mjs|cjs)$/)) {
|
|
1266
|
+
const text = safeText(file)
|
|
1267
|
+
if (text === null) continue
|
|
1268
|
+
// Strings are stripped first: a tool description that names a feature is
|
|
1269
|
+
// prose, not a call (the same false positive require-in-esm had).
|
|
1270
|
+
const code = text
|
|
1271
|
+
.split('\n')
|
|
1272
|
+
.map(stripComment)
|
|
1273
|
+
.join('\n')
|
|
1274
|
+
.replace(/'(?:[^'\\\n]|\\.)*'/g, "''")
|
|
1275
|
+
.replace(/"(?:[^"\\\n]|\\.)*"/g, '""')
|
|
1276
|
+
.replace(/`(?:[^`\\]|\\.)*`/g, '``')
|
|
1277
|
+
for (const [name, re, needs] of NODE_FEATURES) {
|
|
1278
|
+
if (needs <= declared || seen.has(name) || !re.test(code)) continue
|
|
1279
|
+
seen.add(name)
|
|
1280
|
+
out.push(
|
|
1281
|
+
F(
|
|
1282
|
+
'node-too-old',
|
|
1283
|
+
'warn',
|
|
1284
|
+
`engines.node says ${JSON.stringify(readPkgJson(dir).engines.node)} but lib/ uses ${name} (needs >=${needs})`,
|
|
1285
|
+
'npm only warns about engines, so it installs on the older Node and throws at import time — the consumer sees a broken plugin, not a version mismatch.',
|
|
1286
|
+
`Raise engines.node to >=${needs}, or stop using ${name}.`,
|
|
1287
|
+
),
|
|
1288
|
+
)
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
return out
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1205
1294
|
const INJECT_HINTS = [
|
|
1206
1295
|
['tools', /ctx\.tools\b/],
|
|
1207
1296
|
['web', /ctx\.web\b/],
|
|
@@ -1564,6 +1653,7 @@ export function runStaticChecks(dir) {
|
|
|
1564
1653
|
...checkInjectCoverage(dir),
|
|
1565
1654
|
...checkClientHalf(dir),
|
|
1566
1655
|
...checkLibHygiene(dir),
|
|
1656
|
+
...checkNodeVersion(dir),
|
|
1567
1657
|
...checkBuildFreshness(dir),
|
|
1568
1658
|
...checkDocs(dir),
|
|
1569
1659
|
...checkTests(dir),
|
|
@@ -1586,7 +1676,7 @@ const RULE_ORDER = [
|
|
|
1586
1676
|
'inject-unused-service',
|
|
1587
1677
|
'tool-missing-name', 'tool-duplicate-name', 'tool-missing-execute', 'tool-present-call-throws',
|
|
1588
1678
|
'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type',
|
|
1589
|
-
'lib-debugger', 'lib-console-log', 'lib-hardcoded-path', 'lib-url-pathname', 'stale-build', 'hand-edited-build',
|
|
1679
|
+
'lib-debugger', 'lib-console-log', 'lib-hardcoded-path', 'lib-url-pathname', 'node-too-old', 'stale-build', 'hand-edited-build',
|
|
1590
1680
|
'pack-omits-lib', 'pack-omits-patch', 'pack-omits-client', 'pack-oversize', 'pack-omits-readme', 'pack-omits-license', 'pack-ships-secret',
|
|
1591
1681
|
'pack-unexpected-dir', 'pack-script-build', 'pack-unavailable',
|
|
1592
1682
|
'no-license', 'license-mismatch', 'version-taken', 'registry-unreachable', 'publish-not-served',
|
package/lib/index.js
CHANGED
|
@@ -136,12 +136,18 @@ export function apply(ctx, config = {}) {
|
|
|
136
136
|
properties: {
|
|
137
137
|
dir: { type: 'string', description: 'Plugin directory.' },
|
|
138
138
|
timeoutMs: { type: 'number', description: 'Kill the child after this long (default 20000).' },
|
|
139
|
+
call: {
|
|
140
|
+
type: 'array',
|
|
141
|
+
items: { type: 'string' },
|
|
142
|
+
description:
|
|
143
|
+
'Tool names to actually call with empty arguments, e.g. ["page_watch_list"]. Off by default: some tools open applications or write files, so you name the ones you consider safe. A tool that throws on {} is a bug no other check can see, because registration never runs execute.',
|
|
144
|
+
},
|
|
139
145
|
},
|
|
140
146
|
},
|
|
141
147
|
async execute(args) {
|
|
142
148
|
const dir = resolveDir(args.dir)
|
|
143
149
|
if (!dir) return { ok: false, error: 'no plugin directory given' }
|
|
144
|
-
const { findings, tools } = await checkToolRegistrations(dir, { timeoutMs: args.timeoutMs })
|
|
150
|
+
const { findings, tools } = await checkToolRegistrations(dir, { timeoutMs: args.timeoutMs, calls: args.call })
|
|
145
151
|
return { ok: true, tools, findings, report: renderReport(findings, { title: `runtime gate · ${dir}` }) }
|
|
146
152
|
},
|
|
147
153
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bushizhanghan/dsh-publish-gate",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Helps DSH plugin AUTHORS publish their plugins: a gate that turns every silent failure in the publish path into a deterministic check. Manifest layer — package.json completeness, BOM, YAML/JSON parseability, entry paths that point at no file, a patch the manifest never declares, patch insert correctness, a build whose lib/ is older than src/, inject services that are missing or misspelled, npm scope permissions, GitHub repo and token state, market entry format, LICENSE/README, tests. Artifact layer — runs `npm pack --dry-run` and reads what npm will really ship: code missing from the tarball, an unpacked patch, oversize packages, dev directories. Runtime layer — imports the entry in an isolated child process, calls apply(ctx), and reports tools that never register, missing execute, duplicate names, throwing presentCall and card kinds the host does not render. Browser half — loads the client bundle against a fake window.__ModuleLoader__ and reports a bundle that never loads, returns no apply, throws, registers nothing, or uses a service its own inject array omits, because a panel that never appears looks like a UI bug, not a packaging one. Then GitHub + npm + market, and a post-publish shasum check that proves the registry holds these exact bytes. 帮助插件作者一键发布:写完插件后跑一遍门禁(清单 / 产物 / 运行时 / 浏览器半边),把发布链路上的每个静默坑变成确定性检查,然后 GitHub + npm + 市场一键三连。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|