@bushizhanghan/dsh-publish-gate 0.6.0 → 0.7.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/CHANGELOG.md +9 -0
- package/README.md +5 -2
- package/lib/engine.js +151 -6
- package/lib/index.js +2 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
- `tool-output-invalid` (error): a tool result its own `output.schema` rejects. The host validates every
|
|
6
|
+
result and throws `ToolOutputError`, so the tool is unusable from the first call. Only checked when the
|
|
7
|
+
caller names the tool in `call`, because calling a tool can launch applications or write files.
|
|
8
|
+
- `npm-not-logged-in` (error) / `npm-scope-mismatch` (warn): ask npm who it is before spending a publish.
|
|
9
|
+
ENEEDAUTH and the 403 that names neither the token nor the scope both become a message instead of a loss.
|
|
10
|
+
- `readme-wrong-package` (error): the README install line names a different package — a README copied from
|
|
11
|
+
another plugin. Whoever follows it installs something else, and nothing fails because that package exists.
|
|
3
12
|
## 0.6.0
|
|
4
13
|
|
|
5
14
|
- New rule `tool-undeclared-param`: a tool whose `execute` reads `args.x` that
|
package/README.md
CHANGED
|
@@ -68,9 +68,11 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
68
68
|
| `version-taken` | 这个版本已经在 registry 上,再发是 403 |
|
|
69
69
|
| `version-behind-latest` | 本地版本比 registry 上的 `latest` **旧**。npm 会照收,但 `latest` 继续指向新版本——包上传了,按名字装的人永远拿不到,且没有任何报错 |
|
|
70
70
|
| `registry-unreachable` | registry 回了 500/429 —— 版本检查**没跑**,所以别把它当干净 |
|
|
71
|
+
| `npm-not-logged-in` | 这台机器上 npm 没有可用登录(`npm whoami` 报 ENEEDAUTH)。`npm publish` 会在 tarball 打好之后才失败 |
|
|
72
|
+
| `readme-wrong-package` | README 的安装命令写的是**另一个包名**(从别的插件抄过来没改)。照着装的人装到别的插件上,而且不会报错——那个包真的存在 |
|
|
71
73
|
|
|
72
74
|
### 值得在发布前知道的(warn / info)
|
|
73
|
-
`unscoped-name`(granular token 的 403)、`repo-missing`、`patch-name-unresolvable`(junction 放错层级)、`inject-unused-service`(`inject` 里写了个拼错的服务名,cordis 一直等它,插件静默不激活)、`no-publish-config-access`(scoped 包没写 `publishConfig.access`,只是提醒,因为命令里本来就带 `--access public`)、`publish-config-registry`(`publishConfig.registry` 指到内网 registry,版本发到别人装不到的地方)、`no-readme` / `readme-no-install` / `readme-no-profile`、`license-mismatch`、`no-tests` / `no-test-files` / `no-test-script`、`no-engines`、`bad-repo-url`、`no-git` / `no-gitignore`、`readme-tool-undocumented`(README 没提这个工具)/ `readme-test-count`(README 写的测试数和真实数不一致),以及市场条目的 `market-*` 全系列。
|
|
75
|
+
`npm-scope-mismatch`(登录的账号不是包 scope 的所有者,PUT 会 403,而错误里既不说 token 也不说 scope——我为一个 403 花了一小时)、`unscoped-name`(granular token 的 403)、`repo-missing`、`patch-name-unresolvable`(junction 放错层级)、`inject-unused-service`(`inject` 里写了个拼错的服务名,cordis 一直等它,插件静默不激活)、`no-publish-config-access`(scoped 包没写 `publishConfig.access`,只是提醒,因为命令里本来就带 `--access public`)、`publish-config-registry`(`publishConfig.registry` 指到内网 registry,版本发到别人装不到的地方)、`no-readme` / `readme-no-install` / `readme-no-profile`、`license-mismatch`、`no-tests` / `no-test-files` / `no-test-script`、`no-engines`、`bad-repo-url`、`no-git` / `no-gitignore`、`readme-tool-undocumented`(README 没提这个工具)/ `readme-test-count`(README 写的测试数和真实数不一致),以及市场条目的 `market-*` 全系列。
|
|
74
76
|
|
|
75
77
|
### 产物层:npm 到底会发出什么
|
|
76
78
|
|
|
@@ -103,6 +105,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
103
105
|
| `tool-bad-card-kind` | `card: 'generic'` 的 `kind` 不在宿主认识的那一组里(`other` / `execute` / `search` / `read` / `edit` / `delete` / `fetch`)。注册不报错,卡片渲染坏掉——我五个插件一起踩过 |
|
|
104
106
|
| `tool-bad-parameter-type` | `parameters` 里某个 `type` 写错(`"strin"`)。宿主把 `parameters` 渲染进 prompt、但**从不校验**调用值,所以这个 typo 只是悄悄把类型提示弄丢 |
|
|
105
107
|
| `tool-undeclared-param` | `execute` 读了 `args.x`,但 `parameters.properties` 里没有 `x`。模型只能传 schema 声明过的参数,所以这个值在真实调用里永远是 `undefined`——代码在,永远不跑,也不报错。`args.x`、`args['x']`、`const { x } = args` 三种写法都认 |
|
|
108
|
+
| `tool-output-invalid` | 工具返回的东西**自己的 output schema 不收**。宿主每个结果都拿 schema 校验,不匹配就抛 `ToolOutputError`——工具从第一次调用起就不可用,而报错只给一个 JSON 路径,不给是哪行代码返回的。只有点名 `call` 真的调了才检查。这个插件自己踩过:schema 收紧成 `additionalProperties: false`,但有一条错误分支还在返回 `{ ok, error }` |
|
|
106
109
|
|
|
107
110
|
### 浏览器半边:侧栏面板到底会不会出现
|
|
108
111
|
|
|
@@ -211,7 +214,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
|
|
|
211
214
|
## 开发
|
|
212
215
|
|
|
213
216
|
```sh
|
|
214
|
-
node --test #
|
|
217
|
+
node --test # 88 个引擎单测
|
|
215
218
|
node test/integration.test.mjs # 26 条集成断言(9 个真实插件作 fixture)
|
|
216
219
|
```
|
|
217
220
|
|
package/lib/engine.js
CHANGED
|
@@ -517,6 +517,33 @@ export function checkDocs(dir) {
|
|
|
517
517
|
if (text && !/dsh plugin .*add .*--profile/.test(text) && !/dsh plugin --profile \S+ add/.test(text)) {
|
|
518
518
|
out.push(F('readme-no-profile', 'info', 'README install line omits --profile', 'Omitting it installs into the default profile, which is not what every user wants.', 'Show `dsh plugin --profile desktop add <pkg>`.'))
|
|
519
519
|
}
|
|
520
|
+
// A README copied from another plugin keeps the old package name, and the
|
|
521
|
+
// install line then installs something else entirely — with no error, since
|
|
522
|
+
// that package really does exist.
|
|
523
|
+
const pkg = readPkgJson(dir)
|
|
524
|
+
if (text && pkg?.name) {
|
|
525
|
+
const specs = [...text.matchAll(/dsh plugin\b[^\n]*?\badd\s+(?:--profile\s+\S+\s+)?(\S+)/g)]
|
|
526
|
+
.map((m) => m[1].replace(/[`'"]/g, '').trim())
|
|
527
|
+
.filter((s) => s && !s.startsWith('-'))
|
|
528
|
+
const wrong = [...new Set(specs)].filter((s) => {
|
|
529
|
+
if (s === pkg.name || s.startsWith('github:') || s.includes('://')) return false
|
|
530
|
+
// A different install FORM is not a copy-paste slip: an unscoped name in
|
|
531
|
+
// a scoped package's README is usually just shorthand for a dependency.
|
|
532
|
+
if (s.startsWith('@') !== pkg.name.startsWith('@')) return false
|
|
533
|
+
return /^@[\w.-]+\/[\w.-]+$|^[\w.-]+$/.test(s)
|
|
534
|
+
})
|
|
535
|
+
for (const spec of wrong) {
|
|
536
|
+
out.push(
|
|
537
|
+
F(
|
|
538
|
+
'readme-wrong-package',
|
|
539
|
+
'error',
|
|
540
|
+
`README install line says "${spec}" but the package is "${pkg.name}"`,
|
|
541
|
+
'A README copied from another plugin keeps the old name. Whoever follows it installs a different package — and nothing fails, because that package exists too.',
|
|
542
|
+
`Change the install line to ${pkg.name}.`,
|
|
543
|
+
),
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
520
547
|
}
|
|
521
548
|
if (!existsSync(join(dir, 'LICENSE'))) {
|
|
522
549
|
out.push(F('no-license', 'error', 'LICENSE is missing', 'npm warns on publish and the market listing shows an unknown license.', 'Add a LICENSE file (MIT is the ecosystem norm).'))
|
|
@@ -533,6 +560,66 @@ export function checkDocs(dir) {
|
|
|
533
560
|
// npm / GitHub / market state
|
|
534
561
|
// =====================================================================
|
|
535
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Who npm thinks you are on this machine.
|
|
565
|
+
*
|
|
566
|
+
* A publish fails at the very last step with ENEEDAUTH or a 403 that names
|
|
567
|
+
* neither the token nor the scope. Asking first turns that into a message
|
|
568
|
+
* instead of a spent version number.
|
|
569
|
+
*/
|
|
570
|
+
export async function checkPublishIdentity(dir, { timeoutMs = 15000, whoami } = {}) {
|
|
571
|
+
const out = []
|
|
572
|
+
let who = whoami
|
|
573
|
+
if (who === undefined) {
|
|
574
|
+
try {
|
|
575
|
+
const isWin = process.platform === 'win32'
|
|
576
|
+
const cmd = isWin ? process.env.ComSpec || 'cmd.exe' : 'npm'
|
|
577
|
+
const args = isWin ? ['/d', '/s', '/c', 'npm whoami'] : ['whoami']
|
|
578
|
+
const { stdout } = await runFile(cmd, args, { timeout: timeoutMs, windowsHide: true })
|
|
579
|
+
who = stdout.trim() || null
|
|
580
|
+
} catch (error) {
|
|
581
|
+
const text = String(error?.stderr || error?.message || error)
|
|
582
|
+
if (/ENEEDAUTH|E401|not logged in|need auth/i.test(text)) {
|
|
583
|
+
out.push(
|
|
584
|
+
F(
|
|
585
|
+
'npm-not-logged-in',
|
|
586
|
+
'error',
|
|
587
|
+
'npm has no usable login on this machine',
|
|
588
|
+
'`npm publish` will fail with ENEEDAUTH after the tarball has been built. A granular token that lacks publish rights fails later still, with a 403 that names neither the token nor the scope.',
|
|
589
|
+
'Run `npm login`, or put a publish-capable token in ~/.npmrc.',
|
|
590
|
+
),
|
|
591
|
+
)
|
|
592
|
+
} else {
|
|
593
|
+
out.push(
|
|
594
|
+
F(
|
|
595
|
+
'npm-whoami-unavailable',
|
|
596
|
+
'info',
|
|
597
|
+
'could not ask npm who is logged in',
|
|
598
|
+
'The publish-identity check did not run, so nothing is claimed about it.',
|
|
599
|
+
'Make sure npm is on PATH and re-run.',
|
|
600
|
+
),
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
return out
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const pkg = readPkgJson(dir)
|
|
608
|
+
const scope = /^@([^/]+)\//.exec(pkg?.name || '')?.[1]
|
|
609
|
+
if (who && scope && who !== scope) {
|
|
610
|
+
out.push(
|
|
611
|
+
F(
|
|
612
|
+
'npm-scope-mismatch',
|
|
613
|
+
'warn',
|
|
614
|
+
`logged in as "${who}" but the package is under @${scope}`,
|
|
615
|
+
`Publishing under a scope needs a token that owns that scope. A granular token scoped to another owner answers 403 on the PUT — the error names neither the token nor the scope, which is why this takes an hour to diagnose.`,
|
|
616
|
+
`Log in as ${scope}, or mint a token with publish rights for @${scope}.`,
|
|
617
|
+
),
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
return out
|
|
621
|
+
}
|
|
622
|
+
|
|
536
623
|
export function checkNpmState(dir) {
|
|
537
624
|
const out = []
|
|
538
625
|
const json = readPkgJson(dir)
|
|
@@ -1110,6 +1197,7 @@ ctx.tools = {
|
|
|
1110
1197
|
name: typeof def?.name === 'string' ? def.name : null,
|
|
1111
1198
|
hasExecute: typeof def?.execute === 'function',
|
|
1112
1199
|
hasOutputSchema: Boolean(def && def.output && def.output.schema),
|
|
1200
|
+
schema: (def && def.output && def.output.schema) || null,
|
|
1113
1201
|
cardKind: card,
|
|
1114
1202
|
undeclaredParams: undeclaredArgs(def),
|
|
1115
1203
|
run: typeof def?.execute === 'function' ? def.execute : null,
|
|
@@ -1117,6 +1205,50 @@ ctx.tools = {
|
|
|
1117
1205
|
walkParams(def?.parameters, typeof def?.name === 'string' ? def.name : '(unnamed)')
|
|
1118
1206
|
},
|
|
1119
1207
|
}
|
|
1208
|
+
// The host validates every tool result against output.schema and throws
|
|
1209
|
+
// ToolOutputError when it does not match — the tool is then unusable, and the
|
|
1210
|
+
// failure names a JSON path rather than the code that produced it. This gate
|
|
1211
|
+
// shipped exactly that bug: a permissive schema was tightened to
|
|
1212
|
+
// additionalProperties:false while one error path still returned { ok, error }.
|
|
1213
|
+
const validateValue = (schema, value, path) => {
|
|
1214
|
+
if (!schema || typeof schema !== 'object') return null
|
|
1215
|
+
const at = path || '$'
|
|
1216
|
+
if (typeof schema.type === 'string' && schema.type !== 'json') {
|
|
1217
|
+
const t = schema.type
|
|
1218
|
+
const ok = t === 'array' ? Array.isArray(value)
|
|
1219
|
+
: t === 'null' ? value === null
|
|
1220
|
+
: t === 'integer' ? Number.isInteger(value)
|
|
1221
|
+
: t === 'object' ? (value !== null && typeof value === 'object' && !Array.isArray(value))
|
|
1222
|
+
: typeof value === t
|
|
1223
|
+
if (!ok) return at + ' must be ' + t + ', got ' + (value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value)
|
|
1224
|
+
}
|
|
1225
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((e) => JSON.stringify(e) === JSON.stringify(value))) {
|
|
1226
|
+
return at + ' is not one of ' + JSON.stringify(schema.enum)
|
|
1227
|
+
}
|
|
1228
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
1229
|
+
for (const key of Array.isArray(schema.required) ? schema.required : []) {
|
|
1230
|
+
if (!(key in value)) return at + '.' + key + ' is required but missing'
|
|
1231
|
+
}
|
|
1232
|
+
if (schema.additionalProperties === false && schema.properties && typeof schema.properties === 'object') {
|
|
1233
|
+
const extra = Object.keys(value).filter((k) => !(k in schema.properties))
|
|
1234
|
+
if (extra.length) return at + ' has key(s) the schema does not declare: ' + extra.join(', ')
|
|
1235
|
+
}
|
|
1236
|
+
if (schema.properties && typeof schema.properties === 'object') {
|
|
1237
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
1238
|
+
if (!(key in value)) continue
|
|
1239
|
+
const bad = validateValue(sub, value[key], at + '.' + key)
|
|
1240
|
+
if (bad) return bad
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
if (Array.isArray(value) && schema.items) {
|
|
1245
|
+
for (let i = 0; i < value.length; i++) {
|
|
1246
|
+
const bad = validateValue(schema.items, value[i], at + '[' + i + ']')
|
|
1247
|
+
if (bad) return bad
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
return null
|
|
1251
|
+
}
|
|
1120
1252
|
const emit = (payload) => process.stdout.write('\\n@@PG@@' + JSON.stringify(payload) + '\\n')
|
|
1121
1253
|
let stage = 'import'
|
|
1122
1254
|
try {
|
|
@@ -1134,11 +1266,11 @@ try {
|
|
|
1134
1266
|
for (const entry of registered) {
|
|
1135
1267
|
if (!wanted.has(entry.name) || !entry.run) continue
|
|
1136
1268
|
try {
|
|
1137
|
-
await Promise.race([
|
|
1269
|
+
const value = await Promise.race([
|
|
1138
1270
|
Promise.resolve(entry.run({}, { signal: undefined })),
|
|
1139
1271
|
new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), budget)),
|
|
1140
1272
|
])
|
|
1141
|
-
calls.push({ name: entry.name, ok: true })
|
|
1273
|
+
calls.push({ name: entry.name, ok: true, invalid: validateValue(entry.schema, value) })
|
|
1142
1274
|
} catch (error) {
|
|
1143
1275
|
const message = String((error && error.message) || error)
|
|
1144
1276
|
// A slow tool is not a broken tool: of_env shells out to WSL and takes
|
|
@@ -1211,7 +1343,20 @@ export async function checkToolRegistrations(dir, opts = {}) {
|
|
|
1211
1343
|
|
|
1212
1344
|
const seen = new Set()
|
|
1213
1345
|
for (const c of Array.isArray(payload.calls) ? payload.calls : []) {
|
|
1214
|
-
if (c.ok)
|
|
1346
|
+
if (c.ok) {
|
|
1347
|
+
if (c.invalid) {
|
|
1348
|
+
out.push(
|
|
1349
|
+
F(
|
|
1350
|
+
'tool-output-invalid',
|
|
1351
|
+
'error',
|
|
1352
|
+
`tool "${c.name}" returns a value its own output schema rejects: ${c.invalid}`,
|
|
1353
|
+
'The host validates every result against output.schema and throws ToolOutputError on a mismatch. The tool is unusable from the first call, and the error names a JSON path rather than the line that produced it.',
|
|
1354
|
+
'Either widen the schema or change what execute returns so the two agree.',
|
|
1355
|
+
),
|
|
1356
|
+
)
|
|
1357
|
+
}
|
|
1358
|
+
continue
|
|
1359
|
+
}
|
|
1215
1360
|
if (c.timedOut) {
|
|
1216
1361
|
out.push(
|
|
1217
1362
|
F(
|
|
@@ -1959,14 +2104,14 @@ const RULE_ORDER = [
|
|
|
1959
2104
|
'inject-missing-tools', 'inject-missing-web', 'inject-missing-webServer', 'inject-missing-timer',
|
|
1960
2105
|
'inject-unused-service',
|
|
1961
2106
|
'tool-missing-name', 'tool-duplicate-name', 'tool-missing-execute', 'tool-present-call-throws',
|
|
1962
|
-
'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type', 'tool-undeclared-param', 'tool-call-throws', 'tool-call-slow',
|
|
2107
|
+
'tool-bad-card-kind', 'tool-missing-output-schema', 'tool-none-registered', 'tool-bad-parameter-type', 'tool-undeclared-param', 'tool-call-throws', 'tool-output-invalid', 'tool-call-slow',
|
|
1963
2108
|
'lib-debugger', 'lib-console-log', 'lib-hardcoded-path', 'lib-url-pathname', 'node-too-old', 'stale-build', 'hand-edited-build',
|
|
1964
2109
|
'undeclared-import', 'dep-in-dev-only', 'unused-dependency',
|
|
1965
2110
|
'pack-omits-lib', 'pack-omits-patch', 'pack-omits-client', 'pack-oversize', 'pack-omits-readme', 'pack-omits-license', 'pack-ships-secret',
|
|
1966
2111
|
'pack-unexpected-dir', 'pack-script-build', 'pack-unavailable',
|
|
1967
|
-
'no-license', 'license-mismatch', 'version-taken', 'version-behind-latest', 'registry-unreachable', 'publish-not-served',
|
|
2112
|
+
'no-license', 'license-mismatch', 'version-taken', 'version-behind-latest', 'npm-not-logged-in', 'npm-scope-mismatch', 'npm-whoami-unavailable', 'registry-unreachable', 'publish-not-served',
|
|
1968
2113
|
'publish-served-mismatch', 'publish-served', 'market-empty', 'market-no-url', 'market-url-not-github',
|
|
1969
|
-
'market-no-name', 'market-name-shape', 'market-no-category', 'market-bad-category', 'market-no-desc', 'market-no-en', 'market-no-zh', 'unscoped-name', 'no-git', 'no-readme', 'readme-no-install', 'readme-no-profile',
|
|
2114
|
+
'market-no-name', 'market-name-shape', 'market-no-category', 'market-bad-category', 'market-no-desc', 'market-no-en', 'market-no-zh', 'unscoped-name', 'no-git', 'no-readme', 'readme-no-install', 'readme-no-profile', 'readme-wrong-package',
|
|
1970
2115
|
'market-no-repo', 'market-unavailable', 'market-duplicate-entry', 'market-npm-name-collision', 'market-repo-too-new',
|
|
1971
2116
|
'git-uncommitted', 'git-tag-missing', 'git-tag-elsewhere',
|
|
1972
2117
|
'readme-tool-undocumented', 'readme-test-count',
|
package/lib/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
checkTests,
|
|
23
23
|
checkToolRegistrations,
|
|
24
24
|
checkClientRuntime,
|
|
25
|
+
checkPublishIdentity,
|
|
25
26
|
checkVersionTaken,
|
|
26
27
|
compareVersions,
|
|
27
28
|
latestPublished,
|
|
@@ -64,6 +65,7 @@ export function apply(ctx, config = {}) {
|
|
|
64
65
|
if (pkgJson?.name && pkgJson?.version) {
|
|
65
66
|
findings.push(...(await checkVersionTaken(pkgJson.name, pkgJson.version, { registry: cfg.registry })))
|
|
66
67
|
}
|
|
68
|
+
findings.push(...(await checkPublishIdentity(dir, { timeoutMs: cfg.networkTimeoutMs })))
|
|
67
69
|
// is the GitHub repo there?
|
|
68
70
|
if (pkgJson?.repository) {
|
|
69
71
|
const url = repoUrl(pkgJson.repository)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bushizhanghan/dsh-publish-gate",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pre-publish gate for DSH plugins
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Pre-publish gate for DSH plugins: manifest, dependencies, artifact, runtime, browser half, traceability and market readiness. 发布前的七层门禁。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|