@dsh-plugins/dsh-llm-hub 1.5.2 → 1.5.4
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 +68 -0
- package/lib/index.js +31 -6
- package/package.json +2 -1
- package/scripts/prepublish-gate.mjs +263 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,74 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## [1.5.4] - 2026-09-27
|
|
6
|
+
|
|
7
|
+
### 🛡️ 智能路由接管的两处加固(1.5.0 的补丁)
|
|
8
|
+
|
|
9
|
+
1.5.0 让 `smart` 模式用 `agent/request` waterfall 真实改写请求的 provider/model。
|
|
10
|
+
两处会在真机上把会话打挂的边界,这一版补上:
|
|
11
|
+
|
|
12
|
+
#### 未注册 / 未探测到的 provider 不当路由目标
|
|
13
|
+
|
|
14
|
+
- **病灶**:`stateFor` 把「不认识这个 provider」与「探测结果是 unknown」都折成
|
|
15
|
+
`unknown`,而 `unknown` 在裁决里是**可用**的(首次路由需要它兜底)。于是配置里
|
|
16
|
+
写错一个 provider 名(或该 provider 还没注册)时,接管会把请求指向一个不存在的
|
|
17
|
+
adapter,请求以 `NO_ADAPTER` 直接失败 —— 比不接管更糟。
|
|
18
|
+
- **修法**:新增 `routableStateFor` —— 探测表里查不到的 provider 一律视为
|
|
19
|
+
`unavailable`,候选被跳过、路由保持 fail-open(不动原配置)。手动模式下的建议
|
|
20
|
+
列表仍用旧的 `stateFor`(那里 unknown 是诚实的信息,不是路由目标)。
|
|
21
|
+
- **验证**:`test/host-routing-groups.test.mjs` 覆盖组解析与裁决;`node --test`
|
|
22
|
+
120 项全过。
|
|
23
|
+
|
|
24
|
+
#### 首次请求预热可用性探测
|
|
25
|
+
|
|
26
|
+
- **病灶**:生产环境里可用性探测此前只由客户端触发(打开设置页 / 下拉重探)。
|
|
27
|
+
开机后如果没人开设置页,探测表是空的 → smart 模式全部候选被判 `unavailable`
|
|
28
|
+
→ 路由长期静默不生效,表现为「开了智能模式但一直在用原模型」。
|
|
29
|
+
- **修法**:`agent/request` 里若 `baseVerdicts` 为空,后台热一把
|
|
30
|
+
`ensureAvailabilityFresh()`(不阻塞当次请求),这次仍 fail-open 走原配置。
|
|
31
|
+
|
|
32
|
+
**没做**:没有发布任何新功能,也没有改配置格式 —— `groups` / `mode` / `activeGroup`
|
|
33
|
+
与切组入口(composer 路由 chip)与 1.5.0–1.5.3 完全一致。
|
|
34
|
+
|
|
35
|
+
## [1.5.3] - 2026-09-26
|
|
36
|
+
|
|
37
|
+
### 🛂 工程纪律对齐
|
|
38
|
+
|
|
39
|
+
#### 发版脚本 + tarball 收齐
|
|
40
|
+
|
|
41
|
+
- `scripts/prepublish-gate.mjs` 列入 `package.json#files`,随 tarball 出厂(1.5.2 时漏配)。
|
|
42
|
+
- 这条本身没功能改动,单纯是「门禁脚本要在 tarball 里」的一致性修补。
|
|
43
|
+
|
|
44
|
+
#### 作者身份纠正(修 1.5.0/1.5.1/1.5.2 的提交)
|
|
45
|
+
|
|
46
|
+
**根因**:1.5.0/1.5.1/1.5.2 三次发布的提交者不是 DSH 数字成员身份,而是套用了主人 paylinker 邮箱、漏了 `Agent:` trailer、commit type 用了 `release:`(不在 conventional 列表)。违反:
|
|
47
|
+
|
|
48
|
+
- `commit_convention §1`:数字成员用自己的 D1 email,禁止套主人邮箱。
|
|
49
|
+
- `commit_convention §1.5`:本机 CLI 提交必须带 `Agent: cli:<id>` trailer。
|
|
50
|
+
- `commit_convention §2`:commit type 必须是 `feat / fix / refactor / chore / docs / style / test / ci / perf`。
|
|
51
|
+
- `external-cli-layering`:GitHub 操作应该走 L1 `gh` CLI 而不是 L3 `curl`。
|
|
52
|
+
|
|
53
|
+
**为什么 1.5.0/1.5.1/1.5.2 不改**:tag 已发布、npm tarball 不可变、GH Release body 已建,回滚会
|
|
54
|
+
让那一批点升级的用户装不到。本版只**纠正未来**:
|
|
55
|
+
|
|
56
|
+
- 1.5.3 起 commit author 署 `DSH <dsh@webkubor.online>`(D1 上的 agent 身份)。
|
|
57
|
+
- trailer:`Agent: cli:claude-code`。
|
|
58
|
+
- commit type 用 conventional 写法 `chore(release):`。
|
|
59
|
+
- GH 操作走 `gh` CLI。
|
|
60
|
+
|
|
61
|
+
**为什么这次保留 `dsh-llm-hub` 的工作流**:1.5.0/1.5.1/1.5.2 的 commit 内容、CHANGELOG、assets/ 都是
|
|
62
|
+
对的,错的只是元数据(author / trailer / type)。元数据修一遍不影响业务,下次升级自然拉到
|
|
63
|
+
1.5.3 的内容。
|
|
64
|
+
|
|
65
|
+
**没有破坏性变更**,没有功能改动。
|
|
66
|
+
|
|
67
|
+
升级:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
dsh plugin --profile web update @dsh-plugins/dsh-llm-hub@^1.5.3
|
|
71
|
+
```
|
|
72
|
+
|
|
5
73
|
## [1.5.2] - 2026-09-26
|
|
6
74
|
|
|
7
75
|
### 🔧 发版门禁 + README 推广区
|
package/lib/index.js
CHANGED
|
@@ -169,13 +169,18 @@ const WARNING_CHECK_PATH = '/api/dsh-llm-hub/warning/check'
|
|
|
169
169
|
/**
|
|
170
170
|
* 智能路由建议:把当前可用性 + 用户声明的 fallback 顺序折成「该用哪个」。
|
|
171
171
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
172
|
+
* 1.5.0 起语义升级:这条路由仍然只**建议**(composer 的 `conversation.input.model`
|
|
173
|
+
* 是 single + user-controlled slot,插件不碰它);真正的自动接管走
|
|
174
|
+
* `agent/request` waterfall(见本文件「智能路由组 + 模式接管」段)—— manual 模式
|
|
175
|
+
* 下这里就是全部,smart 模式下这里变成「读一眼路由现状」的视图。
|
|
175
176
|
*
|
|
176
|
-
*
|
|
177
|
+
* 请求体:当前已激活的 provider/model(如 'modelgo' / 'gpt-4o'),可选 `group`
|
|
178
|
+
* 指定组;返回:
|
|
177
179
|
* {
|
|
178
180
|
* ok: true,
|
|
181
|
+
* mode: 'manual'|'smart',
|
|
182
|
+
* activeGroup: 'company',
|
|
183
|
+
* groups: [{ id, label, candidateCount }, ...],
|
|
179
184
|
* active: { provider, model, state: 'available'|'unknown'|'unavailable' },
|
|
180
185
|
* candidates: [{ provider, model, state, ... }, ...], // 主 → fallback → 备用
|
|
181
186
|
* recommendation: { provider, model } | null, // 第一个 available 的
|
|
@@ -2473,6 +2478,23 @@ export function apply(ctx) {
|
|
|
2473
2478
|
return 'unknown'
|
|
2474
2479
|
}
|
|
2475
2480
|
|
|
2481
|
+
/**
|
|
2482
|
+
* 路由目标专线的可用性:**探测表里没有的 provider 视为不可用**。
|
|
2483
|
+
*
|
|
2484
|
+
* `stateFor` 把「不认识这个 provider」和「探测结果是 unknown」都折成 unknown,
|
|
2485
|
+
* 后者可以路由,前者路由过去只会撞 NO_ADAPTER —— 请求直接失败。所以智能路由
|
|
2486
|
+
* 用这一版:配置里写错 provider 名 / 该 provider 还没注册时,候选被跳过,
|
|
2487
|
+
* 路由保持 fail-open(不动原配置),而不是把会话打挂。
|
|
2488
|
+
* @param provider - provider id。
|
|
2489
|
+
* @returns 'available' | 'unknown' | 'unavailable'。
|
|
2490
|
+
*/
|
|
2491
|
+
const routableStateFor = (provider) => {
|
|
2492
|
+
for (const verdict of baseVerdicts.values()) {
|
|
2493
|
+
if (verdict.provider === provider) return verdict.state
|
|
2494
|
+
}
|
|
2495
|
+
return 'unavailable'
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2476
2498
|
/**
|
|
2477
2499
|
* 路由解析:给客户端返回「该用哪个 + 原因」(1.5.0 起按组解析)。
|
|
2478
2500
|
* @param active - 客户端 POST 进来的当前已激活 (provider, model)。
|
|
@@ -2572,7 +2594,7 @@ export function apply(ctx) {
|
|
|
2572
2594
|
...(group.primary !== null ? [group.primary] : []),
|
|
2573
2595
|
...group.fallbacks
|
|
2574
2596
|
]
|
|
2575
|
-
const winner = pickSmartRoute(candidates, (provider) =>
|
|
2597
|
+
const winner = pickSmartRoute(candidates, (provider) => routableStateFor(provider), null)
|
|
2576
2598
|
return {
|
|
2577
2599
|
id: group.id,
|
|
2578
2600
|
label: group.label,
|
|
@@ -2688,13 +2710,16 @@ export function apply(ctx) {
|
|
|
2688
2710
|
if (config === null || typeof config !== 'object') return config
|
|
2689
2711
|
const state = routingGroupsOf()
|
|
2690
2712
|
if (state.mode !== 'smart' || state.groups.length === 0) return config
|
|
2713
|
+
// 探测表空 = 还没人探过。后台热一把(不阻塞这次请求),这次照 fail-open 走原配
|
|
2714
|
+
// —— 否则「刚开机就发请求」会让智能路由一直等到用户开设置页才生效。
|
|
2715
|
+
if (baseVerdicts.size === 0) ensureAvailabilityFresh()
|
|
2691
2716
|
const group = state.groups.find((candidate) => candidate.id === state.activeGroup) ?? state.groups[0]
|
|
2692
2717
|
const candidates = [
|
|
2693
2718
|
...(group.primary !== null ? [group.primary] : []),
|
|
2694
2719
|
...group.fallbacks
|
|
2695
2720
|
]
|
|
2696
2721
|
const active = { provider: config.provider, model: config.model }
|
|
2697
|
-
const decision = pickSmartRoute(candidates, (provider) =>
|
|
2722
|
+
const decision = pickSmartRoute(candidates, (provider) => routableStateFor(provider), active)
|
|
2698
2723
|
lastSmartDecision.at = Date.now()
|
|
2699
2724
|
lastSmartDecision.group = group.id
|
|
2700
2725
|
lastSmartDecision.from = active
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plugins/dsh-llm-hub",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.4",
|
|
4
4
|
"description": "DSH 模型管理层:网关可达性探测、模型目录拉取、DeepSeek + MiniMax/智谱/Moonshot/StepFun 余额与配额、本月用量统计、余额预警、Provider 健康看板,再把本机装好的 codex / claude / agy 注册成子代理(装了才出现)。零运行时依赖,不改动 DSH 安装。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"assets",
|
|
78
78
|
"cordis.patch.yml",
|
|
79
79
|
"lib",
|
|
80
|
+
"scripts",
|
|
80
81
|
"types"
|
|
81
82
|
]
|
|
82
83
|
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* prepublish-gate —— 发版前的机械门禁(四条契约)
|
|
4
|
+
*
|
|
5
|
+
* 🔴 这个文件是**拷贝**,真源在 CortexOS:
|
|
6
|
+
* CortexOS/scripts/release-gate/prepublish-gate.mjs
|
|
7
|
+
* 同步检查:`node $CORTEXOS_ROOT/scripts/check-release-gate-sync.mjs`
|
|
8
|
+
* 改逻辑请改真源,然后跑同步脚本把各仓拷一遍 —— 不要在各仓就地改。
|
|
9
|
+
*
|
|
10
|
+
* 为什么是门禁而不是文档:2026-09-26 一天里给 dsh-llm-hub 发了 1.5.0/1.5.1/1.5.2/1.5.3
|
|
11
|
+
* 四个版本号,用户可感知的变化只有 1.5.0 一个;同一轮还漏了 bloom-theme 的 assets
|
|
12
|
+
* (README 引用 11 个相对路径截图、tarball 里 0 个 → npm 页面上全是坏图;那 11 张合计
|
|
13
|
+
* 10MB,正确修法是换成图床直链而不是把 10MB 塞进每个安装包)。共同点是
|
|
14
|
+
* 「约定没做成门禁」,所以把判据做成能跑的:
|
|
15
|
+
*
|
|
16
|
+
* 契约 1 产物完整性 —— package.json 必须在;`files` 里每个字面量条目至少命中一个文件;
|
|
17
|
+
* main / types / exports / dsh 引用的相对路径文件必须存在。
|
|
18
|
+
* 契约 2 图片引用 —— README 里以**相对路径**引用的 assets/* 必须在 tarball 里。
|
|
19
|
+
* 否则 npm 页面上是坏图(bloom-theme 就是这么坏的)。
|
|
20
|
+
* 只认 `](assets/…)` 与 `src="assets/…"`,不认 URL 里的 assets/
|
|
21
|
+
* (raw.githubusercontent 那种在 npm 上照样能显示,不算坏图)。
|
|
22
|
+
* 契约 3 CHANGELOG —— 存在 CHANGELOG.md 时,必含 `## [<当前 version>]` 段。
|
|
23
|
+
* 契约 4 发版语义 —— 运行期载荷必须与上一个已发布版本**不同**。
|
|
24
|
+
* 见 docs/rules/release-semantics.md;ALLOW_METADATA_ONLY=1 是逃生舱。
|
|
25
|
+
*
|
|
26
|
+
* 任一条挂了 → exit 1 + stderr 指明错在哪、怎么修。
|
|
27
|
+
*/
|
|
28
|
+
import { readFileSync, mkdtempSync, rmSync, readdirSync, statSync, existsSync } from 'node:fs'
|
|
29
|
+
import { execFileSync } from 'node:child_process'
|
|
30
|
+
import { dirname, join, relative } from 'node:path'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
import { tmpdir } from 'node:os'
|
|
33
|
+
import { createHash } from 'node:crypto'
|
|
34
|
+
|
|
35
|
+
const GATE_VERSION = 1
|
|
36
|
+
|
|
37
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
38
|
+
const pkgRoot = dirname(here)
|
|
39
|
+
|
|
40
|
+
const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'))
|
|
41
|
+
const version = pkg.version
|
|
42
|
+
const errors = []
|
|
43
|
+
|
|
44
|
+
// ── 0. 打包清单 ────────────────────────────────────────────────────────────
|
|
45
|
+
let packFiles
|
|
46
|
+
try {
|
|
47
|
+
const out = execFileSync('npm', ['pack', '--dry-run', '--json'], {
|
|
48
|
+
cwd: pkgRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit']
|
|
49
|
+
})
|
|
50
|
+
packFiles = JSON.parse(out)[0].files.map(f => f.path)
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error('[prepublish-gate] npm pack --dry-run 失败:', err.message)
|
|
53
|
+
process.exit(2)
|
|
54
|
+
}
|
|
55
|
+
const inPack = (p) => packFiles.includes(p)
|
|
56
|
+
|
|
57
|
+
// ── 1. 产物完整性 ──────────────────────────────────────────────────────────
|
|
58
|
+
if (!inPack('package.json')) errors.push('tarball 里没有 package.json')
|
|
59
|
+
|
|
60
|
+
// files[] 里的字面量条目(不含 glob)必须命中至少一个文件
|
|
61
|
+
for (const entry of pkg.files ?? []) {
|
|
62
|
+
if (/[*?[\]{}!]/.test(entry)) continue // glob 跳过
|
|
63
|
+
const e = entry.replace(/\/$/, '')
|
|
64
|
+
const hit = packFiles.some(f => f === e || f.startsWith(e + '/'))
|
|
65
|
+
if (!hit) {
|
|
66
|
+
errors.push(`package.json#files 声明了 '${entry}',但 tarball 里一个文件都没有\n → 条目过期或文件被删/被 ignore`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// main / types / module / bin / exports / dsh.bundle 引用的路径必须在 tarball 里。
|
|
71
|
+
// 只走这几个字段,因为 package.json 里别的字符串不是路径 —— 典型陷阱:
|
|
72
|
+
// `dsh.client.platform: "web"` 和 `dsh.client.inject: ["@deepseek-ai/..."]`
|
|
73
|
+
// 一旦被当成路径就会误报。
|
|
74
|
+
const entryPaths = new Set()
|
|
75
|
+
const addPath = (v) => {
|
|
76
|
+
if (typeof v !== 'string' || v === '') return
|
|
77
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(v) || v.startsWith('/')) return // URL / 绝对路径
|
|
78
|
+
entryPaths.add(v.replace(/^\.\//, ''))
|
|
79
|
+
}
|
|
80
|
+
const walkStrings = (v) => {
|
|
81
|
+
if (typeof v === 'string') addPath(v)
|
|
82
|
+
else if (v && typeof v === 'object') for (const x of Object.values(v)) walkStrings(x)
|
|
83
|
+
}
|
|
84
|
+
for (const field of ['main', 'types', 'module', 'bin', 'exports']) walkStrings(pkg[field])
|
|
85
|
+
if (pkg.dsh && typeof pkg.dsh === 'object') walkStrings(pkg.dsh.bundle)
|
|
86
|
+
|
|
87
|
+
for (const p of entryPaths) {
|
|
88
|
+
if (!inPack(p)) {
|
|
89
|
+
errors.push(`package.json 的入口字段引用了 '${p}',但 tarball 里没有\n → 装包的人会在 require/import 时直接失败`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── 2. README 里相对引用的 assets/* 必须在 tarball 里 ──────────────────────
|
|
94
|
+
const readmes = ['README.md', 'README.en.md', 'README.zh.md', 'README.zh-CN.md']
|
|
95
|
+
.filter(f => existsSync(join(pkgRoot, f)))
|
|
96
|
+
|
|
97
|
+
const referencedAssets = new Set()
|
|
98
|
+
for (const f of readmes) {
|
|
99
|
+
const text = readFileSync(join(pkgRoot, f), 'utf8')
|
|
100
|
+
// 只认相对引用:](assets/x) 与 src="assets/x"。URL 里的 assets/ 不算。
|
|
101
|
+
for (const m of text.matchAll(/\]\((assets\/[^)\s]+)\)/g)) referencedAssets.add(m[1])
|
|
102
|
+
for (const m of text.matchAll(/src=["'](assets\/[^"']+)["']/g)) referencedAssets.add(m[1])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const missingAssets = [...referencedAssets].filter(a => existsSync(join(pkgRoot, a)) && !inPack(a))
|
|
106
|
+
if (missingAssets.length > 0) {
|
|
107
|
+
errors.push(
|
|
108
|
+
`README 引用了 ${missingAssets.length} 个 assets 文件,但 tarball 里没有 —— npm 页面上会是坏图:\n` +
|
|
109
|
+
missingAssets.slice(0, 5).map(a => ` · ${a}`).join('\n') +
|
|
110
|
+
(missingAssets.length > 5 ? `\n …(共 ${missingAssets.length} 个)` : '') +
|
|
111
|
+
`\n → 二选一:(a) 把 'assets' 加进 package.json 的 'files'(图小就用这个);` +
|
|
112
|
+
`\n (b) 把 README 改成图床绝对 URL 并确认真能打开(图大就用这个,别把十几 MB 塞进每个安装包)`
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── 3. CHANGELOG 必须有当前版本段 ──────────────────────────────────────────
|
|
117
|
+
// 标题写法各仓不一(`## [1.5.3]` / `## 3.7.0 (2026-09-16)` /
|
|
118
|
+
// `## [0.14.0](compare-link) (2026-09-25)` / `## v0.14.0`),
|
|
119
|
+
// 只认「二级标题 + 版本号本身」,不强制 keep-a-changelog 的方括号。
|
|
120
|
+
// 版本号后用负向前瞻收尾,避免 0.14.0 命中 0.14.01 这种前缀。
|
|
121
|
+
const changelogPath = join(pkgRoot, 'CHANGELOG.md')
|
|
122
|
+
if (existsSync(changelogPath)) {
|
|
123
|
+
const text = readFileSync(changelogPath, 'utf8')
|
|
124
|
+
const esc = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
125
|
+
const heading = new RegExp(`^##\\s*\\[?v?${esc}(?![\\d.])`, 'm')
|
|
126
|
+
if (!heading.test(text)) {
|
|
127
|
+
errors.push(
|
|
128
|
+
`CHANGELOG.md 缺少当前版本(${version})的二级标题段\n` +
|
|
129
|
+
` → 发版正文(GitHub Release / npm 页)直接取这一段;缺了会出现「npm 上了 / Release 没建」的漂移`
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── 4. 发版语义:运行期载荷必须与上一已发布版本不同 ────────────────────────
|
|
135
|
+
//
|
|
136
|
+
// 「运行期载荷」= tarball 里**除了文档/元数据/开发工具之外**的每一个文件,
|
|
137
|
+
// 外加 package.json 的运行期字段。判据要能机械执行 —— 不用「CHANGELOG 里有没有
|
|
138
|
+
// feat/fix 关键字」这种启发式(自省段落里出现一个「修」字就会误放行)。
|
|
139
|
+
//
|
|
140
|
+
// 详见 docs/rules/release-semantics.md。
|
|
141
|
+
const EXCLUDE = [
|
|
142
|
+
/^readme(\.[^/]*)?$/i,
|
|
143
|
+
/^changelog(\.[^/]*)?$/i,
|
|
144
|
+
/^(license|notice|contributing)(\.[^/]*)?$/i,
|
|
145
|
+
/^docs\//i,
|
|
146
|
+
/^assets\//i,
|
|
147
|
+
/^scripts\//i,
|
|
148
|
+
/^\.github\//i,
|
|
149
|
+
/^\.githooks\//i,
|
|
150
|
+
/^tsconfig[^/]*\.json$/i,
|
|
151
|
+
/^(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/i,
|
|
152
|
+
/^(screenshots\.json|llms\.txt|dev_notes[^/]*)$/i,
|
|
153
|
+
/^(\.npmignore|\.gitignore|\.gitattributes)$/i
|
|
154
|
+
]
|
|
155
|
+
const isRuntime = (p) => !EXCLUDE.some(re => re.test(p))
|
|
156
|
+
|
|
157
|
+
const RUNTIME_PKG_FIELDS = [
|
|
158
|
+
'name', 'type', 'main', 'module', 'types', 'exports', 'bin', 'dsh', 'files',
|
|
159
|
+
'dependencies', 'peerDependencies', 'optionalDependencies', 'engines'
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
/** 运行期载荷指纹: 排序后的 "路径:哈希" 列表。 */
|
|
163
|
+
function runtimeFingerprint(root, list) {
|
|
164
|
+
const entries = []
|
|
165
|
+
for (const rel of list) {
|
|
166
|
+
if (!isRuntime(rel)) continue
|
|
167
|
+
if (rel === 'package.json') continue
|
|
168
|
+
try {
|
|
169
|
+
entries.push([rel, createHash('sha256').update(readFileSync(join(root, rel))).digest('hex')])
|
|
170
|
+
} catch { /* 目录项或读不到,跳过 */ }
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
174
|
+
const runtimePkg = {}
|
|
175
|
+
for (const f of RUNTIME_PKG_FIELDS) {
|
|
176
|
+
if (manifest[f] !== undefined) runtimePkg[f] = manifest[f]
|
|
177
|
+
}
|
|
178
|
+
entries.push(['package.json(运行期字段)', createHash('sha256').update(JSON.stringify(runtimePkg)).digest('hex')])
|
|
179
|
+
} catch { /* 读不到是别处的问题 */ }
|
|
180
|
+
return entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (process.env.ALLOW_METADATA_ONLY === '1') {
|
|
184
|
+
console.warn('[prepublish-gate] ⚠️ ALLOW_METADATA_ONLY=1 —— 跳过契约 4(纯文档/元数据版本)')
|
|
185
|
+
} else {
|
|
186
|
+
try {
|
|
187
|
+
const raw = execFileSync('npm', ['view', pkg.name, 'versions', '--json'], {
|
|
188
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
|
|
189
|
+
})
|
|
190
|
+
const parsed = JSON.parse(raw)
|
|
191
|
+
const list = Array.isArray(parsed) ? parsed : [parsed]
|
|
192
|
+
const previous = list.filter(v => v !== version).pop()
|
|
193
|
+
|
|
194
|
+
if (previous === undefined) {
|
|
195
|
+
console.log(`[prepublish-gate] · 契约 4: ${pkg.name} 尚无更早版本, 跳过载荷比对`)
|
|
196
|
+
} else {
|
|
197
|
+
const tarballUrl = execFileSync('npm', ['view', `${pkg.name}@${previous}`, 'dist.tarball'], {
|
|
198
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
|
|
199
|
+
}).trim()
|
|
200
|
+
const stage = mkdtempSync(join(tmpdir(), 'prepublish-gate-'))
|
|
201
|
+
try {
|
|
202
|
+
const tgz = join(stage, 'prev.tgz')
|
|
203
|
+
const res = await fetch(tarballUrl)
|
|
204
|
+
if (!res.ok) throw new Error(`下载 ${previous} tarball 失败: HTTP ${res.status}`)
|
|
205
|
+
const { writeFileSync } = await import('node:fs')
|
|
206
|
+
writeFileSync(tgz, Buffer.from(await res.arrayBuffer()))
|
|
207
|
+
execFileSync('tar', ['-xzf', tgz, '-C', stage], { stdio: 'ignore' })
|
|
208
|
+
const prevRoot = join(stage, 'package')
|
|
209
|
+
|
|
210
|
+
const before = runtimeFingerprint(prevRoot, collectAllFiles(prevRoot))
|
|
211
|
+
const after = runtimeFingerprint(pkgRoot, packFiles)
|
|
212
|
+
if (JSON.stringify(before) === JSON.stringify(after)) {
|
|
213
|
+
errors.push(
|
|
214
|
+
`运行期载荷与上一个已发布版本 ${previous} **完全相同** —— 这个 semver 号没有承载任何用户可感知的变化\n` +
|
|
215
|
+
` → 本次只动了: ${packFiles.filter(p => !isRuntime(p)).slice(0, 6).join(', ') || '(仅 package.json 非运行期字段)'}\n` +
|
|
216
|
+
` → semver 是对用户的契约: 流程修补 / 元数据修正 / 自省笔记不该占版本号(docs/rules/release-semantics.md)。\n` +
|
|
217
|
+
` 确实要发纯文档版时显式设 ALLOW_METADATA_ONLY=1`
|
|
218
|
+
)
|
|
219
|
+
} else {
|
|
220
|
+
console.log(`[prepublish-gate] · 契约 4: 运行期载荷相对 ${previous} 有变化(${before.length} 项比对)`)
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
rmSync(stage, { recursive: true, force: true })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// 网络/registry 不可用时不阻断发版:门禁不该因为一次查询失败就卡住真实发布。
|
|
228
|
+
console.warn(`[prepublish-gate] ⚠️ 契约 4 未能执行(不阻断): ${err.message}`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 递归收集目录下所有文件(相对路径),用于对上一版本解包目录做指纹。 */
|
|
233
|
+
function collectAllFiles(root) {
|
|
234
|
+
const out = []
|
|
235
|
+
const walk = (rel) => {
|
|
236
|
+
const abs = rel ? join(root, rel) : root
|
|
237
|
+
let st
|
|
238
|
+
try { st = statSync(abs) } catch { return }
|
|
239
|
+
if (st.isDirectory()) {
|
|
240
|
+
for (const e of readdirSync(abs)) walk(rel ? join(rel, e) : e)
|
|
241
|
+
} else {
|
|
242
|
+
out.push(rel)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
walk('')
|
|
246
|
+
return out
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── 报告 ──────────────────────────────────────────────────────────────────
|
|
250
|
+
if (errors.length > 0) {
|
|
251
|
+
console.error(`\n[prepublish-gate] ${errors.length} 条契约不满足, 拒绝发版:\n`)
|
|
252
|
+
for (const e of errors) {
|
|
253
|
+
console.error(' ✖', e)
|
|
254
|
+
console.error('')
|
|
255
|
+
}
|
|
256
|
+
console.error('[prepublish-gate] exit 1 → npm publish 已中止。修完再推。')
|
|
257
|
+
process.exit(1)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log(`[prepublish-gate] ✓ ${pkg.name}@${version} 四条契约通过(gate v${GATE_VERSION})`)
|
|
261
|
+
console.log(`[prepublish-gate] · tarball ${packFiles.length} 个文件;运行期载荷 ${packFiles.filter(isRuntime).length} 个`)
|
|
262
|
+
if (referencedAssets.size > 0) console.log(`[prepublish-gate] · README 引用的 ${referencedAssets.size} 个 assets 均在包内`)
|
|
263
|
+
console.log(`[prepublish-gate] · CHANGELOG 含当前版本段`)
|