@bushizhanghan/dsh-publish-gate 0.1.3 → 0.1.5

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
@@ -29,7 +29,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
29
29
  | `publish_gate` | 全量门禁:静态检查 + 网络检查,返回 `safeToPublish` |
30
30
  | `publish_gate_patch` | 只查 `cordis.patch.yml`——「插件装了但工具没出现」的第一个怀疑对象 |
31
31
  | `publish_gate_market` | 校验市场条目 YAML(url/name/category/description 双语) |
32
- | `publish_gate_next_version` | 当前版本、是否已被 registry 占用、建议 bump 到多少 |
32
+ | `publish_gate_next_version` | 当前版本、是否已被 registry 占用、建议 bump 到多少(本地比 npm 旧时从 npm 的 latest 往上加,不会推荐一个已被占用的版本) |
33
33
  | `publish_gate_doctor` | 扫整个目录,按 blocker 数排序,看哪个插件还不能发 |
34
34
  | `publish_gate_commands` | 按顺序列出发布命令,并标出这台机器已经走到哪一步 |
35
35
 
@@ -47,14 +47,15 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
47
47
  | `no-exports` | 缺 `exports["."]`,宿主解析不到入口 |
48
48
  | `client-without-export` | 声明了 client 平台却没导出 `./client`,宿主半边在、侧栏永不出现 |
49
49
  | `not-esm` | 没 `"type": "module"`,`import` 在加载时炸 |
50
- | `insert-wrong-indent` | **insert 行没在 `- insert:` 底下**——顶层 `- id:` 是对已有条目的 patch,不是挂载 |
50
+ | `no-insert-row` | patch 里只有 override,永远不挂载(页面报的仍然是这条——顶层 `- id:` 是 patch 不是挂载) |
51
+ | `patch-not-mount` | info:这些顶层 `- id:` 是在覆盖已有条目,合法,但**不是**挂载 |
51
52
  | `insert-missing-name` / `insert-name-mismatch` | 挂载名缺失或与 package.json 不一致 |
52
- | `no-insert-row` | patch 里只有 override,永远不挂载 |
53
53
  | `no-patch-file` | 有 `lib/index.js` 但没有 `cordis.patch.yml` |
54
54
  | `no-name-export` / `no-apply-export` | 入口协议不完整 |
55
55
  | `require-in-esm` | ESM 里 `require()` → `ERR_AMBIGUOUS_MODULE_SYNTAX`,报错不带行号 |
56
56
  | `no-license` | 缺 LICENSE |
57
57
  | `version-taken` | 这个版本已经在 registry 上,再发是 403 |
58
+ | `registry-unreachable` | registry 回了 500/429 —— 版本检查**没跑**,所以别把它当干净 |
58
59
 
59
60
  ### 值得在发布前知道的(warn / info)
60
61
  `unscoped-name`(granular token 的 403)、`repo-missing`、`patch-name-unresolvable`(junction 放错层级)、`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`,以及市场条目的 `market-*` 全系列。
@@ -69,9 +70,10 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
69
70
  fail with a parse error that never names the BOM.
70
71
  ✗ files does not include "lib" [files-omits-lib]
71
72
  The published tarball will contain no code.
72
- ✗ insert row "page-monitor" (line 173) is not inside an "- insert:" sequence [insert-wrong-indent]
73
- A top-level `- id:` row is a patch on an existing entry, not a mount. The
74
- loader reports one stderr warning and boots without your plugin.
73
+ ✗ cordis.patch.yml has no insert row [no-insert-row]
74
+ A patch with only overrides never mounts your plugin. This file has 1
75
+ top-level `- id:` row — those patch entries that already exist in the tree,
76
+ they do not add one.
75
77
  ✗ lib/index.js calls require() inside an ESM module [require-in-esm]
76
78
  4 blocker(s) — fix before publishing
77
79
  ```
@@ -88,7 +90,7 @@ dsh plugin --profile desktop add @bushizhanghan/dsh-publish-gate
88
90
  ## 开发
89
91
 
90
92
  ```sh
91
- node --test # 19 个单测(含 4 个真实插件作 fixture)+ 16 项集成测试
93
+ node --test # 26 个测试(含 4 个真实插件作 fixture)
92
94
  ```
93
95
 
94
96
  ## License
package/lib/engine.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // unscoped npm name that returned E403, a git push that failed on the network
7
7
  // while the REST API worked. All of them are silent until the moment they cost
8
8
  // you a publish, so they are all checkable before you spend it.
9
- import { existsSync, readFileSync, statSync } from 'node:fs'
9
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
10
10
  import { join, resolve } from 'node:path'
11
11
  import { homedir } from 'node:os'
12
12
 
@@ -64,6 +64,9 @@ export function checkPackageJson(dir) {
64
64
  }
65
65
 
66
66
  for (const field of REQUIRED) {
67
+ // `main` is optional when the package resolves through exports["."]:
68
+ // an exports-only manifest is the modern shape and loads fine.
69
+ if (field === 'main' && json.exports?.['.']) continue
67
70
  if (json[field] === undefined || json[field] === null || json[field] === '') {
68
71
  out.push(F('missing-field', 'error', `package.json is missing "${field}"`, `npm warns but publishes; consumers then cannot resolve it.`, `Add "${field}".`))
69
72
  }
@@ -100,9 +103,15 @@ export function checkPackageJson(dir) {
100
103
  )
101
104
  }
102
105
 
103
- // files — the classic: lib/ omitted, so the published tarball has no code
106
+ // files — the classic: lib/ omitted, so the published tarball has no code.
107
+ // "lib/", "./lib" and "lib/**/*.js" all pack lib/, so match the entry rather
108
+ // than the exact string "lib".
104
109
  if (Array.isArray(json.files)) {
105
- if (!json.files.includes('lib')) {
110
+ const packsLib = json.files.some((f) => {
111
+ const norm = String(f).replace(/^\.\//, '').replace(/\/+$/, '')
112
+ return norm === 'lib' || norm === 'lib/**/*' || norm.startsWith('lib/')
113
+ })
114
+ if (!packsLib) {
106
115
  out.push(
107
116
  F(
108
117
  'files-omits-lib',
@@ -201,6 +210,7 @@ export function checkPatchYml(dir) {
201
210
 
202
211
  const lines = text.split('\n')
203
212
  const insertRows = []
213
+ const patchRows = []
204
214
  let inInsert = false
205
215
  let insertIndent = 0
206
216
  for (let i = 0; i < lines.length; i++) {
@@ -215,7 +225,12 @@ export function checkPatchYml(dir) {
215
225
  if (idMatch) {
216
226
  const indent = idMatch[1].length
217
227
  const id = idMatch[2].replace(/^['"]|['"]$/g, '')
218
- insertRows.push({ line: i + 1, indent, id, underInsert: inInsert && indent > insertIndent })
228
+ // A row at column 0 is a patch on an existing entry — the shape every
229
+ // profile patch is made of. Only a row nested inside "- insert:" mounts
230
+ // a package, and calling the patch row a mis-indented insert told users
231
+ // to indent it, which would insert a duplicate of an existing id.
232
+ if (inInsert && indent > insertIndent) insertRows.push({ line: i + 1, indent, id })
233
+ else patchRows.push({ line: i + 1, indent, id })
219
234
  continue
220
235
  }
221
236
  // a top-level key (indent 0, no dash) closes the insert sequence
@@ -228,41 +243,37 @@ export function checkPatchYml(dir) {
228
243
  'no-insert-row',
229
244
  'error',
230
245
  'cordis.patch.yml has no insert row',
231
- 'A patch with only overrides never mounts your plugin.',
246
+ `A patch with only overrides never mounts your plugin.${patchRows.length > 0 ? ` This file has ${patchRows.length} top-level \`- id:\` row(s) — those patch entries that already exist in the tree, they do not add one.` : ''}`,
232
247
  'Add:\n- insert:\n - id: <your-plugin-id>\n name: "<package-name>"',
233
248
  ),
234
249
  )
235
250
  return out
236
251
  }
237
252
 
238
- for (const row of insertRows) {
239
- if (!row.underInsert) {
240
- // the exact bug that kept page-monitor and weapp-loop from loading:
241
- // an insert row written at column 0 becomes a top-level patch entry,
242
- // which the loader applies to an id that is not in the tree.
243
- out.push(
244
- F(
245
- 'insert-wrong-indent',
246
- 'error',
247
- `insert row "${row.id}" (line ${row.line}) is not inside an "- insert:" sequence`,
248
- 'A top-level `- id:` row is a patch on an existing entry, not a mount. The loader reports one stderr warning and boots without your plugin — installed, loaded, and silent.',
249
- 'Indent it under `- insert:`:\n- insert:\n - id: <id>\n name: "<pkg>"',
250
- ),
251
- )
252
- }
253
+ // Patching is legitimate (that is how a profile overrides a model or a
254
+ // theme), but it must not be mistaken for a mount.
255
+ if (patchRows.length > 0) {
256
+ out.push(
257
+ F(
258
+ 'patch-not-mount',
259
+ 'info',
260
+ `${patchRows.length} top-level \`- id:\` row(s) patch existing entries (line ${patchRows.map((r) => r.line).join(', ')})`,
261
+ 'These rows override an entry that is already in the tree. They are not mounts: if you meant to add this package, it needs an "- insert:" row.',
262
+ 'Nothing to do if you are overriding. If not, move the row under "- insert:".',
263
+ ),
264
+ )
253
265
  }
254
266
 
255
267
  // every insert row must carry a name that matches package.json
256
- for (let i = 0; i < lines.length; i++) {
257
- const m = /^(\s*)-\s*id:\s*(.+?)\s*$/.exec(lines[i])
258
- if (!m || m[1].length <= 2) continue
268
+ for (const row of insertRows) {
269
+ const i = row.line - 1
259
270
  const nameMatch = /^\s*name:\s*(.+?)\s*$/.exec(lines[i + 1] || '')
260
271
  if (!nameMatch) {
261
272
  out.push(
262
273
  F(
263
274
  'insert-missing-name',
264
275
  'error',
265
- `insert row on line ${i + 1} has no name: field`,
276
+ `insert row on line ${row.line} has no name: field`,
266
277
  'The loader mounts by package name; without it the row resolves to nothing.',
267
278
  `Add the next line: name: "${name || '<package-name>'}"`,
268
279
  ),
@@ -379,12 +390,28 @@ export function checkNpmState(dir, { token, registry = 'https://registry.npmjs.o
379
390
  }
380
391
 
381
392
  /** Version-collision check: the same version already on the registry. */
382
- export async function checkVersionTaken(name, version, { registry = 'https://registry.npmjs.org' } = {}) {
393
+ export async function checkVersionTaken(name, version, { registry = 'https://registry.npmjs.org', timeoutMs = 8000 } = {}) {
383
394
  const out = []
384
395
  try {
385
- const res = await fetch(`${registry.replace(/\/$/, '')}/${name.replace('/', '%2f')}`, { headers: { accept: 'application/vnd.npm.install-v1+json' } })
396
+ const res = await fetch(`${registry.replace(/\/$/, '')}/${name.replace('/', '%2f')}`, {
397
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
398
+ signal: AbortSignal.timeout(timeoutMs),
399
+ })
386
400
  if (res.status === 404) return out // never published: fine
387
- if (!res.ok) return out
401
+ if (!res.ok) {
402
+ // A 500 or a rate limit is not proof the version is free. Saying nothing
403
+ // here made the gate report "clean" for a check that never ran.
404
+ out.push(
405
+ F(
406
+ 'registry-unreachable',
407
+ 'warn',
408
+ `could not read ${name} from the registry (HTTP ${res.status})`,
409
+ 'The version-taken check did not run, so this is NOT a clean version check. npm itself will still refuse a duplicate on publish.',
410
+ 'Re-run the gate later, or check https://www.npmjs.com/package/' + name + '?activeTab=versions by hand.',
411
+ ),
412
+ )
413
+ return out
414
+ }
388
415
  const json = await res.json()
389
416
  if (json?.versions?.[version]) {
390
417
  out.push(
@@ -406,6 +433,33 @@ export function bumpPatch(v) {
406
433
  return m ? `${m[1]}.${m[2]}.${Number(m[3]) + 1}` : v
407
434
  }
408
435
 
436
+ /** -1 / 0 / 1 by semver-ish comparison of the numeric part. */
437
+ export function compareVersions(a, b) {
438
+ const pa = /^(\d+)\.(\d+)\.(\d+)/.exec(String(a))?.slice(1).map(Number) ?? [0, 0, 0]
439
+ const pb = /^(\d+)\.(\d+)\.(\d+)/.exec(String(b))?.slice(1).map(Number) ?? [0, 0, 0]
440
+ for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] > pb[i] ? 1 : -1
441
+ return 0
442
+ }
443
+
444
+ /**
445
+ * The registry's `dist-tags.latest`, or null when it could not be read.
446
+ * next_version needs this: a local checkout that is BEHIND npm would otherwise
447
+ * be told to publish a version that is already taken.
448
+ */
449
+ export async function latestPublished(name, { registry = 'https://registry.npmjs.org', timeoutMs = 8000 } = {}) {
450
+ try {
451
+ const res = await fetch(`${registry.replace(/\/$/, '')}/${name.replace('/', '%2f')}`, {
452
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
453
+ signal: AbortSignal.timeout(timeoutMs),
454
+ })
455
+ if (!res.ok) return null
456
+ const json = await res.json()
457
+ return json?.['dist-tags']?.latest ?? null
458
+ } catch {
459
+ return null
460
+ }
461
+ }
462
+
409
463
  export function checkGitState(dir) {
410
464
  const out = []
411
465
  const gitDir = join(dir, '.git')
@@ -489,8 +543,17 @@ export function checkTests(dir) {
489
543
  out.push(F('no-tests', 'warn', 'no test/ directory', 'A plugin with no tests ships rules nobody has run; the market does not require tests, but reviewers notice.', 'Add node --test tests.'))
490
544
  return out
491
545
  }
492
- const hasTest = ['engine.test.mjs', 'integration.test.mjs'].some((f) => existsSync(join(testDir, f)))
493
- if (!hasTest) out.push(F('no-test-files', 'warn', 'test/ has no *.test.mjs', 'Add at least an engine test so the rules are exercised.', 'Add test/engine.test.mjs.'))
546
+ // Any test file counts: naming is the author's business, and demanding
547
+ // engine.test.mjs specifically told authors to rename working tests.
548
+ const testFiles = (() => {
549
+ try {
550
+ return readdirSync(testDir).filter((f) => /\.(test|spec)\.(mjs|js|cjs)$/.test(f))
551
+ } catch {
552
+ return []
553
+ }
554
+ })()
555
+ const hasTest = testFiles.length > 0
556
+ if (!hasTest) out.push(F('no-test-files', 'warn', 'test/ has no *.test.mjs or *.spec.mjs', 'Add at least an engine test so the rules are exercised.', 'Add test/engine.test.mjs.'))
494
557
  const pkgJson = (() => {
495
558
  const t = safeText(join(dir, 'package.json'))
496
559
  try {
@@ -529,7 +592,7 @@ const RULE_ORDER = [
529
592
  'missing-field', 'not-esm', 'files-omits-lib', 'files-omits-patch', 'no-exports', 'client-without-export',
530
593
  'no-patch-file', 'no-insert-row', 'insert-wrong-indent', 'insert-missing-name', 'insert-name-mismatch',
531
594
  'patch-name-unresolvable', 'no-name-export', 'no-apply-export', 'no-inject', 'require-in-esm',
532
- 'no-license', 'license-mismatch', 'version-taken', 'market-empty', 'market-no-url', 'market-url-not-github',
595
+ 'no-license', 'license-mismatch', 'version-taken', 'registry-unreachable', 'market-empty', 'market-no-url', 'market-url-not-github',
533
596
  'market-no-name', 'market-name-shape', 'market-no-category', 'market-bad-category', 'market-no-desc',
534
597
  'market-no-en', 'market-no-zh', 'unscoped-name', 'no-git', 'no-readme', 'readme-no-install', 'readme-no-profile',
535
598
  'no-tests', 'no-test-files', 'no-test-script', 'no-engines', 'bad-repo-url', 'no-gitignore',
package/lib/index.js CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  checkPatchYml,
19
19
  checkTests,
20
20
  checkVersionTaken,
21
+ compareVersions,
22
+ latestPublished,
21
23
  renderReport,
22
24
  runStaticChecks,
23
25
  } from './engine.js'
@@ -168,23 +170,36 @@ export function apply(ctx, config = {}) {
168
170
  if (!dir) return { ok: false, error: 'no plugin directory given' }
169
171
  const json = readJson(join(dir, 'package.json'))
170
172
  if (!json?.version) return { ok: false, error: 'no version in package.json' }
171
- const taken = (await checkVersionTaken(json.name, json.version, { registry: cfg.registry })).map((f) => f.title)
172
- const [a, b, c] = /^(\d+)\.(\d+)\.(\d+)/.exec(json.version)?.slice(1).map(Number) ?? [0, 0, 0]
173
+ const taken = (await checkVersionTaken(json.name, json.version, { registry: cfg.registry, timeoutMs: cfg.networkTimeoutMs })).map((f) => f.title)
174
+ const latest = await latestPublished(json.name, { registry: cfg.registry, timeoutMs: cfg.networkTimeoutMs })
175
+ // A checkout that is behind npm must bump from npm's latest, or the
176
+ // "suggested" version is one that is already published.
177
+ const behind = latest && compareVersions(latest, json.version) > 0 ? latest : null
178
+ const base = behind ?? json.version
179
+ const [a, b, c] = /^(\d+)\.(\d+)\.(\d+)/.exec(base)?.slice(1).map(Number) ?? [0, 0, 0]
173
180
  const bump = args.bump || 'patch'
174
181
  const suggested =
175
182
  bump === 'major' ? `${a + 1}.0.0` : bump === 'minor' ? `${a}.${b + 1}.0` : `${a}.${b}.${c + 1}`
176
- return { ok: true, current: json.version, taken, suggested, suggestedFor: bump }
183
+ return {
184
+ ok: true,
185
+ current: json.version,
186
+ latest,
187
+ behind,
188
+ taken,
189
+ suggested,
190
+ suggestedFor: bump,
191
+ note: behind ? `package.json is behind the registry (${behind} is live); the suggestion bumps from there.` : undefined,
192
+ }
177
193
  },
178
194
  },
179
195
  {
180
196
  name: 'publish_gate_doctor',
181
197
  description:
182
- 'Run the gate over every plugin in a directory tree (e.g. a workspace holding several plugin checkouts) and rank them by blocker count, so you can see which one is not ready to ship before you start a publish session. Returns {ok, plugins:[{dir,blockers,warns,notes,top}], totals}.',
198
+ 'Run the gate over every plugin in a directory tree (e.g. a workspace holding several plugin checkouts) and rank them by blocker count, so you can see which one is not ready to ship before you start a publish session. Static checks only — no registry or GitHub calls, so it is fast and works offline. Returns {ok, plugins:[{dir,blockers,warns,notes,top}], totals}.',
183
199
  parameters: {
184
200
  type: 'object',
185
201
  properties: {
186
202
  root: { type: 'string', description: 'Directory containing plugin checkouts.' },
187
- skipNetwork: { type: 'boolean', description: 'Skip registry/GitHub checks (default true for a multi-plugin sweep).' },
188
203
  },
189
204
  },
190
205
  async execute(args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bushizhanghan/dsh-publish-gate",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Helps DSH plugin AUTHORS publish their plugins: a one-command gate that turns every silent failure in the publish path into a deterministic check — package.json completeness, BOM, YAML/JSON parseability, junction and patch insert correctness, npm scope permissions, GitHub repo and token state, market entry format, LICENSE/README, test suite. 帮助插件作者一键发布:写完插件后跑一遍门禁,把发布链路上的每个静默坑变成确定性检查,然后 GitHub + npm + 市场一键三连。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",