@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 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 # 83 个引擎单测
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) continue
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.6.0",
4
- "description": "Pre-publish gate for DSH plugins in five layers: manifest, dependencies, artifact, runtime, browser half — plus traceability (can you point at the bytes you shipped?). 发布前门禁:清单、依赖、产物、运行时、浏览器半边,外加可追溯性。",
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": {