dsh-rule-engine 0.5.17 → 0.6.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.
@@ -1,329 +1,339 @@
1
- // release-plugin.mjs —— DSH 插件全渠道一键发布(npm + git + GitHub Release)
2
- // 用法:node release-plugin.mjs <插件名|插件目录> [版本号] [--sync-profile]
3
- // - 插件名从 scripts/plugins.json 清单解析(规则 26 ⑤);或直接给目录路径
4
- // - 版本号省略时自动 patch+1(如 1.4.7 -> 1.4.8)
5
- // - 自动同步 README 徽章 version-X.Y.Z
6
- // - --sync-profile:发布成功后自动更新 profiles/<profile>/pnpm-workspace.yaml 豁免名单
7
- // (备份原文件)+ 运行全量装配审计(写 .dsh 需以 danger-full-access 运行本脚本)
8
- // 前置:npm 已认证、gh 已认证、git 已配置。
9
- // 网络:2026-09-03 实测修正——直连 github.com/npm registry 已不可靠(443 超时/连接重置);
10
- // 需代理环境请设 DSH_RELEASE_PROXY=http://127.0.0.1:7890(本机 Clash 7890)。未设时脚本仍尝试直连。
11
- // 步骤:版本 bump -> 测试 -> npm pack -> npm publish -> [publish 后 dist-tags 校验(B1,2026-09-03)]
12
- // -> git commit+push -> gh release(带 tgz asset)
13
- // 安全:token 经环境变量注入,不在命令文本/日志中打印
14
- import { execSync } from "node:child_process";
15
- import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, statSync } from "node:fs";
16
- import { dirname, join, relative, resolve } from "node:path";
17
- import { fileURLToPath } from "node:url";
18
-
19
- const PROXY = process.env.DSH_RELEASE_PROXY || ""; // 默认直连;需要代理时显式设置
20
- /** 代理前缀(命令文本):空 = 直连 */
21
- const proxyPrefix = () => (PROXY ? `set HTTPS_PROXY=${PROXY}&& set HTTP_PROXY=${PROXY}&& ` : "");
22
- const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
23
- const WORKSPACE = join(SCRIPT_DIR, "..");
24
-
25
- function run(cmd, opts = {}) {
26
- console.log(`> ${cmd.slice(0, 120)}${cmd.length > 120 ? "..." : ""}`);
27
- return execSync(cmd, { stdio: "inherit", encoding: "utf8", shell: true, ...opts });
28
- }
29
- function quiet(cmd) {
30
- try {
31
- return execSync(cmd, { encoding: "utf8", shell: true, stdio: "pipe" }).toString().trim();
32
- } catch {
33
- return "";
34
- }
35
- }
36
- function fail(msg) {
37
- console.error(`\n[发布中止] ${msg}`);
38
- process.exit(1);
39
- }
40
-
41
- // ── 0. 解析目标(清单名 或 目录路径)────────────────────────────
42
- const args = process.argv.slice(2);
43
- const syncProfile = args.includes("--sync-profile");
44
- const dryRun = args.includes("--dry-run"); // 2026-08-29:发布前预览(只推导+打印,不改文件)
45
- const target = args.find((a) => !a.startsWith("--")) || "";
46
- let manifest = null;
47
- let entry = null;
48
- let dir = null;
49
- try {
50
- manifest = JSON.parse(readFileSync(join(SCRIPT_DIR, "plugins.json"), "utf8"));
51
- entry = manifest.plugins.find((p) => p.name === target) || null;
52
- } catch {
53
- // 清单缺失时仅支持目录路径
54
- }
55
- if (entry) {
56
- dir = resolve(WORKSPACE, entry.dir);
57
- } else if (target) {
58
- dir = resolve(target);
59
- } else {
60
- fail("用法:node release-plugin.mjs <插件名|插件目录> [版本号] [--sync-profile]");
61
- }
62
- if (!existsSync(join(dir, "package.json"))) fail(`目录不存在或不是插件包:${dir}`);
63
-
64
- // ── 0. 读取包信息 ────────────────────────────────────────────────
65
- const pkgPath = join(dir, "package.json");
66
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
67
- const name = pkg.name;
68
- const oldVer = pkg.version;
69
- // 版本号 = 非 -- 开头的参数(排除目标名本身),位置任意
70
- // 2026-08-29 修复(0.5.11-pre 体系):oldVer 为 -pre/-rc 等预发布后缀时——
71
- // ① 发布语义 = 剥后缀发正式版(0.5.11-pre → 发布 0.5.11,不是 +1);
72
- // ② 自动推导不再 split(".") 直接 Number("11-pre" 会得 NaN——旧 bug)。
73
- // 显式传参优先;且显式传参如果是 -pre 形态(意外),本发布不剥(警告留痕)。
74
- const nextVer = args.find((a) => !a.startsWith("--") && a !== target) || (() => {
75
- // 预发布后缀(-pre/-rc)→ 剥后缀发正式版(0.5.11-pre → 0.5.11)
76
- if (/-(?:pre|rc|beta|alpha)(?:[.\d]*)$/i.test(oldVer)) return oldVer.replace(/-.*$/, "");
77
- // 纯数字 → +1(历史行为)
78
- const [maj, min, pat] = oldVer.split(".").map(Number);
79
- if (Number.isNaN(pat)) fail(`无法解析版本号:${oldVer}`);
80
- return `${maj}.${min}.${pat + 1}`;
81
- })();
82
- const repoOverride = entry?.repo || null;
83
- console.log(`\n=== 发布 ${name}: ${oldVer} -> ${nextVer} ===\n`);
84
-
85
- // ── 0.5 本地配套包版本一致性(防止 host/client 版本错位)──────────
86
- function checkDependencyPair() {
87
- if (!entry?.dependsOn || !manifest) return;
88
- for (const depName of entry.dependsOn) {
89
- const depEntry = manifest.plugins.find((p) => p.name === depName);
90
- const depDir = depEntry ? resolve(WORKSPACE, depEntry.dir) : null;
91
- if (!depDir || !existsSync(join(depDir, "package.json"))) {
92
- console.log(`(未找到依赖 ${depName} 的本地包,跳过版本一致性检查)`);
93
- continue;
94
- }
95
- const depPkg = JSON.parse(readFileSync(join(depDir, "package.json"), "utf8"));
96
- const range = pkg.dependencies?.[depName];
97
- if (!range) continue;
98
- const m = range.match(/^\^(\d+)\.(\d+)\.(\d+)/);
99
- if (!m) {
100
- console.log(`(依赖 ${depName} 范围 ${range} 不是简单 ^x.y.z,跳过自动校验)`);
101
- continue;
102
- }
103
- const v = depPkg.version.split(".").map(Number);
104
- const ok = v[0] === +m[1] && (v[1] > +m[2] || (v[1] === +m[2] && v[2] >= +m[3]));
105
- if (!ok) fail(`${name} 声明依赖 ${depName}@${range},但本地 ${depName} 是 ${depPkg.version}`);
106
- console.log(`[依赖一致性] ${name} -> ${depName} ${depPkg.version} ✓`);
107
- }
108
- }
109
- checkDependencyPair();
110
-
111
- // ── 1. 前置检查 ──────────────────────────────────────────────────
112
- if (!/^\d+\.\d+\.\d+$/.test(nextVer)) fail(`版本号格式错误:${nextVer}`);
113
- // 2026-09-02 修复:gh auth status 的 "Logged in" 输出在 stderr(新版 gh),stdout 可能为空空
114
- // → 2>&1 合并 stderr,避免已认证被误判"gh 未认证"(实测 jilian-dsh keyring 已登录却判失败)
115
- if (!quiet("gh auth status 2>&1").includes("Logged in")) fail("gh 未认证");
116
- const whoami = quiet("npm whoami 2>&1");
117
- if (!whoami) fail("npm 未认证(npm whoami 失败)");
118
-
119
- // ── 1.5 DSH 兼容预检(发布前防“升级后插件不兼容”)──────────────
120
- const compatScript = join(SCRIPT_DIR, "check-dsh-compat.mjs");
121
- if (existsSync(compatScript)) {
122
- console.log("\n=== DSH 兼容预检 ===");
123
- run(`node "${compatScript}" "${dir}"`);
124
- }
125
-
126
- // ── 1.6 profile dump-config 冒烟(确认 DSH 可导出当前 profile 配置)──
127
- function findDshBin() {
128
- const candidates = [process.env.DSH_BIN, "D:/npm-global/dsh.cmd", "D:/npm-global/dsh", "dsh"].filter(Boolean);
129
- for (const c of candidates) {
130
- if (quiet(`where ${c}`)) return c;
131
- }
132
- return "";
133
- }
134
- const dshCmd = findDshBin();
135
- if (dshCmd) {
136
- const dump = quiet(`"${dshCmd}" --profile ${manifest?.profile || "web"} --dump-config`);
137
- if (dump && !/error|Error|ENOENT/i.test(dump)) {
138
- console.log("(profile dump-config 冒烟通过:DSH 配置可正常导出)");
139
- } else {
140
- console.log("(profile dump-config 冒烟未通过:请人工确认 DSH 可用后发布)");
141
- }
142
- } else {
143
- console.log("(未找到 dsh 命令,跳过 profile dump-config 冒烟)");
144
- }
145
-
146
- // ── 2. 版本 bump(package.json + README 徽章)────────────────────
147
- if (dryRun) {
148
- console.log(`[DRY-RUN] 不修改任何文件。`);
149
- console.log(`[DRY-RUN] oldVer=${oldVer} nextVer=${nextVer}`);
150
- const rd = join(dir, "README.md");
151
- if (existsSync(rd)) {
152
- const t = readFileSync(rd, "utf8");
153
- const oldBadgeVer = `version-${oldVer}`;
154
- const oldBadgeUrl = `version-${oldVer.replace(/-/g, "--")}`;
155
- const nextUrl = `version-${nextVer}`;
156
- const replaced = t.replaceAll(oldBadgeVer, nextUrl).replaceAll(oldBadgeUrl, nextUrl);
157
- console.log(`[DRY-RUN] README 徽章将变为: ${replaced.match(/version-[^\s]+/)?.[0] || "(未命中,检查徽章形态)"}`);
158
- }
159
- process.exit(0);
160
- }
161
- writeFileSync(pkgPath, readFileSync(pkgPath, "utf8").replace(`"version": "${oldVer}"`, `"version": "${nextVer}"`));
162
- for (const readme of ["README.md", "README.en.md"]) {
163
- const rp = join(dir, readme);
164
- if (existsSync(rp)) {
165
- const text = readFileSync(rp, "utf8");
166
- // 2026-08-29 修复:徽章 URL 编码把 `0.5.11-pre` 转成 `0.5.11--pre`(`-` 被 shields 双横线),
167
- // 旧正则 `version-0.5.11-pre` 匹配不到 `version-0.5.11--pre` → 徽章漏改(㉙ 机器根因之一)。
168
- // replaceAll 用字面串(非正则),无需转义。
169
- const oldBadgeVer = `version-${oldVer}`; // JSON 形态:version-0.5.11-pre
170
- const oldBadgeUrl = `version-${oldVer.replace(/-/g, "--")}`; // URL 形态:version-0.5.11--pre
171
- const nextUrl = `version-${nextVer}`;
172
- const replaced = text
173
- .replaceAll(oldBadgeVer, nextUrl)
174
- .replaceAll(oldBadgeUrl, nextUrl);
175
- if (replaced !== text) writeFileSync(rp, replaced);
176
- }
177
- }
178
- console.log("版本已 bump(package.json + README 徽章)");
179
-
180
- // B1(2026-09-03):README 版本表行自动插入(阶段 C 人脑核对已失效的自动化;幂等:已存在 nextVer 行则跳过)
181
- // 位置:版本表(| 版本 | 日期 | 要点 |)表头后第一数据行之前(最新在上);内容用调用方 notes 参数或 git log 最近提交信息
182
- const notes = process.argv.includes("--notes") ? process.argv[process.argv.indexOf("--notes") + 1] : "";
183
- for (const readme of ["README.md"]) {
184
- const rd = join(dir, readme);
185
- if (!existsSync(rd)) continue;
186
- let rtxt = readFileSync(rd, "utf8");
187
- const tableHeader = "| 版本 | 日期 | 要点 |";
188
- const hi = rtxt.indexOf(tableHeader);
189
- if (hi < 0 || rtxt.includes(`| **${nextVer}** |`)) continue;
190
- const today = new Date().toLocaleDateString("en-CA"); // 本地时区 YYYY-MM-DD
191
- const summary = notes || (quiet(`cd /d "${dir}" && git log -1 --pretty=%s`).slice(0, 90) || "(待补变更摘要)");
192
- const nl = rtxt.indexOf("\n", hi);
193
- if (nl < 0) continue;
194
- const row = `| **${nextVer}** | ${today} | ${summary.replace(/\|/g, "\\|")} |`;
195
- rtxt = rtxt.slice(0, nl + 1) + row + "\n" + rtxt.slice(nl + 1);
196
- writeFileSync(rd, rtxt, "utf8");
197
- console.log(`[B1] ${readme} 版本表已插入 ${nextVer} 行(摘要来源:${notes ? "notes 参数" : "git log"});请核对内容`);
198
- }
199
-
200
- // ── 3. 测试(规则 23:发布前运行时验证)─────────────────────────
201
- if (pkg.scripts && pkg.scripts.test) {
202
- console.log("\n=== 运行测试 ===");
203
- run(`cd /d "${dir}" && npm test --prefix "${dir}"`);
204
- } else {
205
- console.log("(无 test 脚本,跳过)");
206
- }
207
-
208
- // ── 4. pack + publish ────────────────────────────────────────────
209
- console.log("\n=== npm pack ===");
210
- run(`cd /d "${dir}" && npm pack --pack-destination .`);
211
- const tgz = `${name}-${nextVer}.tgz`;
212
- if (!existsSync(join(dir, tgz))) fail(`打包产物缺失:${tgz}`);
213
-
214
- console.log("\n=== npm publish ===");
215
- run(`cd /d "${dir}" && ${proxyPrefix()}npm publish ${tgz}`);
216
-
217
- // B1(2026-09-03):publish 后校验 registry dist-tags0.5.16 事故:publish 自报成功但 latest 未切;
218
- // 脚本此前只跑 publish 不校验发布态——三通道验证铁律:npm 通道以 dist-tags 为准,self-report 不算数)
219
- console.log("\n=== npm 发布态校验(dist-tags)===");
220
- const latestActual = quiet(`cd /d "${dir}" && ${proxyPrefix()}npm view ${name} dist-tags.latest`);
221
- if (latestActual !== nextVer) {
222
- fail(`npm 发布态异常:dist-tags.latest=${latestActual || "(为空)"},预期 ${nextVer}——请人工核查(可能 staged/缓存,见踩坑 117);勿继续 git/Release 通道`);
223
- }
224
- console.log(`dist-tags.latest=${latestActual} ✓`);
225
-
226
- // ── 5. git commit + push(token 经环境变量注入 URL,命令文本不含密钥明文)─────
227
- console.log("\n=== git commit + push ===");
228
- // 2026-08-25 修复 pushUrl 双重拼接:origin 可能是 ssh(git@github.com:)、https(https://github.com/)
229
- // 或裸路径(user/repo)三种形态——统一归一化为 "owner/repo" 路径再拼 token URL(此前 https origin 未剥前缀
230
- // 导致 "https://github.com/https://github.com/..." 双重 URL,push 404)
231
- const repo = (repoOverride || quiet(`cd /d "${dir}" && git remote get-url origin`))
232
- .replace(/^git@github\.com:/, "")
233
- .replace(/^https?:\/\/github\.com\//, "")
234
- .replace(/\.git$/, "");
235
- const token = quiet("gh auth token");
236
- if (!token) fail("无法获取 gh token");
237
- // v3.72 同款通道:token 拼进 HTTPS URL 直推(避开沙箱下 msys 凭据管道的 EPERM)
238
- const pushUrl = `https://jilian-dsh:${token}@github.com/${repo}.git`;
239
- // 2026-08-31 修复:repoRoot 实测 git 仓库根(插件目录可能只是子目录,如 rules-manager 在 oss 仓库内);
240
- // stageSpec = 插件目录相对仓库根路径(防 git add -A 误带仓库内无关改动/未跟踪物)
241
- const repoRoot = quiet(`cd /d "${dir}" && git rev-parse --show-toplevel`).trim();
242
- const relDir = relative(repoRoot, dir).split(/[\\/]/).join("/");
243
- const stageSpec = relDir && relDir !== "." ? relDir : "";
244
- // 2026-08-29 防漏机检(0.5.10 git 欠账事故:git add -u 只更新已跟踪文件,未跟踪新文件
245
- // (npm pack 按目录打包、天然包含)会漏进 git 提交 git npm 内容不一致)。
246
- // 现在:提交前检测未跟踪文件并打印名单,改用 add -A 一并提交(.gitignore 已排除杂质)。
247
- const untrackedList = quiet(`cd /d "${repoRoot}" && git status --porcelain -- ${stageSpec}`)
248
- .split("\n").filter((l) => l.startsWith("??")).map((l) => l.slice(3));
249
- if (untrackedList.length > 0) {
250
- console.log(`(未跟踪文件 ${untrackedList.length} 个将一并提交:${untrackedList.slice(0, 6).join(", ")}${untrackedList.length > 6 ? " 等" : ""})`);
251
- }
252
- // 2026-09-02:提交物个人标识/本机路径扫描(git 提交侧门禁;与 publish-aptitude-check.mjs PERSONAL_RE 同源)
253
- // 教训 8105d3e:logs/.analysis-tmp 手册草稿曾随 add -A 进入公开库——npm 侧有门禁,git 侧此前没有。
254
- const PERSONAL_RE = /jilian|季涟|D:\\DeepSeek|D:\/DeepSeek|@qq\.com|@163\.com|@outlook\.com|私人注释|个人标识/;
255
- function scanStagedPersonal(repoRoot, stageSpec) {
256
- const files = quiet(`cd /d "${repoRoot}" && git diff --cached --name-only -- ${stageSpec || "."}`)
257
- .split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
258
- const bad = [];
259
- for (const f of files) {
260
- const p = join(repoRoot, f);
261
- if (!existsSync(p) || statSync(p).size > 2 * 1024 * 1024) continue;
262
- const text = readFileSync(p, "utf8");
263
- // 豁免:LICENSE/版权行=MIT 许可要求版权声明(作者署名是公开出版物必要内容,非泄露)
264
- if (/^LICENSE/i.test(path.basename(f))) continue;
265
- if (/Copyright\s*\(c\)/i.test(text)) continue;
266
- if (PERSONAL_RE.test(text)) bad.push(f);
267
- }
268
- if (bad.length) fail(`提交物含个人标识/本机路径(PERSONAL_RE 命中 ${bad.length} 个文件):${bad.slice(0, 4).join(", ")};请脱敏后重试`);
269
- }
270
- run(`cd /d "${repoRoot}" && ${proxyPrefix()}git add -A -- ${stageSpec}`);
271
- scanStagedPersonal(repoRoot, stageSpec);
272
- run(`cd /d "${repoRoot}" && git -c core.autocrlf=false commit -m "release: ${name} v${nextVer}" || exit 0`);
273
- run(`cd /d "${repoRoot}" && ${proxyPrefix()}git push "${pushUrl}" HEAD`);
274
-
275
- // ── 6. GitHub Release(带正式 tgz asset,规则 26;清单标记 skipRelease 的包不建)─────
276
- if (entry?.skipRelease) {
277
- console.log("(清单标记 skipRelease,跳过 GitHub Release)");
278
- } else {
279
- console.log("\n=== GitHub Release ===");
280
- const notesFile = join(process.env.TEMP || ".", `notes-${name}-${nextVer}.md`);
281
- writeFileSync(notesFile, `## v${nextVer}\n\nRelease generated by scripts/release-plugin.mjs\n`);
282
- // 必须在插件目录(git 仓库)内运行:gh release 内部会做 git 检查
283
- run(`cd /d "${dir}" && ${proxyPrefix()}gh release create v${nextVer} "${join(dir, tgz)}" --title "${name} v${nextVer}" --notes-file "${notesFile}"`);
284
- }
285
-
286
- // ── 7. 本机 profile 同步(--sync-profile:豁免名单 + 装配审计;需 danger-full-access 运行)──
287
- if (syncProfile) {
288
- console.log("\n=== 本机 profile 同步 ===");
289
- let manifest = null;
290
- try {
291
- manifest = JSON.parse(readFileSync(join(SCRIPT_DIR, "plugins.json"), "utf8"));
292
- } catch {
293
- manifest = { profile: "web" };
294
- }
295
- const profileDir = resolve(WORKSPACE, "..", ".dsh", "profiles", manifest.profile || "web");
296
- const wsYaml = join(profileDir, "pnpm-workspace.yaml");
297
- if (existsSync(wsYaml)) {
298
- const raw = readFileSync(wsYaml, "utf8");
299
- const lineRe = new RegExp(`^( - ${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}@[^\\n]*)$`, "m");
300
- const m = raw.match(lineRe);
301
- if (m && !m[1].includes(nextVer)) {
302
- const bakDir = join(profileDir, "..", ".backups");
303
- mkdirSync(bakDir, { recursive: true });
304
- const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
305
- const bak = join(bakDir, `pnpm-workspace-${stamp}.yaml`);
306
- copyFileSync(wsYaml, bak);
307
- const updated = raw.replace(lineRe, `${m[1]} || ${nextVer}`);
308
- writeFileSync(wsYaml, updated);
309
- console.log(`豁免名单已追加 ${name}@${nextVer}(备份 ${bak})`);
310
- } else if (!m) {
311
- console.log(`(豁免名单无 ${name} 条目,跳过)`);
312
- } else {
313
- console.log(`(豁免名单已含 ${nextVer},跳过)`);
314
- }
315
- }
316
- // 全量装配审计(规则 27)
317
- const auditScript = join(SCRIPT_DIR, "..", "projects", "oss", "dsh-rule-engine", "scripts", "audit-mount-consistency.mjs");
318
- if (existsSync(auditScript)) {
319
- const out = quiet(`node "${auditScript}" --profile ${manifest.profile || "web"}`);
320
- const pass = /MOUNT CONSISTENT/.test(out);
321
- console.log(out.split("\n").filter((l) => /RESULT|DUPLICATES|MOUNT|summary/.test(l)).join("\n"));
322
- if (!pass) fail("装配审计未通过(MOUNT CONSISTENT 未出现),请先处理再重启 DSH");
323
- console.log("装配审计 MOUNT CONSISTENT ✓");
324
- }
325
- }
326
-
327
- console.log(`\n=== 发布完成:${name} v${nextVer} ===`);
328
- console.log(`npm: ${name}@${nextVer}`);
329
- console.log(`release: https://github.com/${repo}/releases/tag/v${nextVer}`);
1
+ // release-plugin.mjs —— DSH 插件全渠道一键发布(npm + git + GitHub Release)
2
+ // 用法:node release-plugin.mjs <插件名|插件目录> [版本号] [--sync-profile]
3
+ // - 插件名从 scripts/plugins.json 清单解析(规则 26 ⑤);或直接给目录路径
4
+ // - 版本号省略时自动 patch+1(如 1.4.7 -> 1.4.8)
5
+ // - 自动同步 README 徽章 version-X.Y.Z
6
+ // - --sync-profile:发布成功后自动更新 profiles/<profile>/pnpm-workspace.yaml 豁免名单
7
+ // (备份原文件)+ 运行全量装配审计(写 .dsh 需以 danger-full-access 运行本脚本)
8
+ // 前置:npm 已认证、gh 已认证、git 已配置。
9
+ // 网络:2026-09-03 实测修正——直连 github.com/npm registry 已不可靠(443 超时/连接重置);
10
+ // 需代理环境请设 DSH_RELEASE_PROXY=http://127.0.0.1:7890(本机 Clash 7890)。未设时脚本仍尝试直连。
11
+ // 步骤:版本 bump -> 测试 -> npm pack -> npm publish -> [publish 后 dist-tags 校验(B1,2026-09-03)]
12
+ // -> git commit+push -> gh release(带 tgz asset)
13
+ // 安全:token 经环境变量注入,不在命令文本/日志中打印
14
+ import { execSync } from "node:child_process";
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, statSync } from "node:fs";
16
+ import { dirname, join, relative, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const PROXY = process.env.DSH_RELEASE_PROXY || ""; // 默认直连;需要代理时显式设置
20
+ /** 代理前缀(命令文本):空 = 直连 */
21
+ const proxyPrefix = () => (PROXY ? `set HTTPS_PROXY=${PROXY}&& set HTTP_PROXY=${PROXY}&& ` : "");
22
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
23
+ const WORKSPACE = join(SCRIPT_DIR, "..");
24
+
25
+ function run(cmd, opts = {}) {
26
+ console.log(`> ${cmd.slice(0, 120)}${cmd.length > 120 ? "..." : ""}`);
27
+ return execSync(cmd, { stdio: "inherit", encoding: "utf8", shell: true, ...opts });
28
+ }
29
+ function quiet(cmd) {
30
+ try {
31
+ return execSync(cmd, { encoding: "utf8", shell: true, stdio: "pipe" }).toString().trim();
32
+ } catch {
33
+ return "";
34
+ }
35
+ }
36
+ function fail(msg) {
37
+ console.error(`\n[发布中止] ${msg}`);
38
+ process.exit(1);
39
+ }
40
+
41
+ // ── 0. 解析目标(清单名 或 目录路径)────────────────────────────
42
+ const args = process.argv.slice(2);
43
+ const syncProfile = args.includes("--sync-profile");
44
+ const dryRun = args.includes("--dry-run"); // 2026-08-29:发布前预览(只推导+打印,不改文件)
45
+ const target = args.find((a) => !a.startsWith("--")) || "";
46
+ let manifest = null;
47
+ let entry = null;
48
+ let dir = null;
49
+ try {
50
+ manifest = JSON.parse(readFileSync(join(SCRIPT_DIR, "plugins.json"), "utf8"));
51
+ entry = manifest.plugins.find((p) => p.name === target) || null;
52
+ } catch {
53
+ // 清单缺失时仅支持目录路径
54
+ }
55
+ if (entry) {
56
+ dir = resolve(WORKSPACE, entry.dir);
57
+ } else if (target) {
58
+ dir = resolve(target);
59
+ } else {
60
+ fail("用法:node release-plugin.mjs <插件名|插件目录> [版本号] [--sync-profile]");
61
+ }
62
+ if (!existsSync(join(dir, "package.json"))) fail(`目录不存在或不是插件包:${dir}`);
63
+
64
+ // ── 0. 读取包信息 ────────────────────────────────────────────────
65
+ const pkgPath = join(dir, "package.json");
66
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
67
+ const name = pkg.name;
68
+ const oldVer = pkg.version;
69
+ // 版本号 = 非 -- 开头的参数(排除目标名本身),位置任意
70
+ // 2026-08-29 修复(0.5.11-pre 体系):oldVer 为 -pre/-rc 等预发布后缀时——
71
+ // ① 发布语义 = 剥后缀发正式版(0.5.11-pre → 发布 0.5.11,不是 +1);
72
+ // ② 自动推导不再 split(".") 直接 Number("11-pre" 会得 NaN——旧 bug)。
73
+ // 显式传参优先;且显式传参如果是 -pre 形态(意外),本发布不剥(警告留痕)。
74
+ const nextVer = args.find((a) => !a.startsWith("--") && a !== target) || (() => {
75
+ // 预发布后缀(-pre/-rc)→ 剥后缀发正式版(0.5.11-pre → 0.5.11)
76
+ if (/-(?:pre|rc|beta|alpha)(?:[.\d]*)$/i.test(oldVer)) return oldVer.replace(/-.*$/, "");
77
+ // 纯数字 → +1(历史行为)
78
+ const [maj, min, pat] = oldVer.split(".").map(Number);
79
+ if (Number.isNaN(pat)) fail(`无法解析版本号:${oldVer}`);
80
+ return `${maj}.${min}.${pat + 1}`;
81
+ })();
82
+ const repoOverride = entry?.repo || null;
83
+ console.log(`\n=== 发布 ${name}: ${oldVer} -> ${nextVer} ===\n`);
84
+
85
+ // ── 0.5 本地配套包版本一致性(防止 host/client 版本错位)──────────
86
+ function checkDependencyPair() {
87
+ if (!entry?.dependsOn || !manifest) return;
88
+ for (const depName of entry.dependsOn) {
89
+ const depEntry = manifest.plugins.find((p) => p.name === depName);
90
+ const depDir = depEntry ? resolve(WORKSPACE, depEntry.dir) : null;
91
+ if (!depDir || !existsSync(join(depDir, "package.json"))) {
92
+ console.log(`(未找到依赖 ${depName} 的本地包,跳过版本一致性检查)`);
93
+ continue;
94
+ }
95
+ const depPkg = JSON.parse(readFileSync(join(depDir, "package.json"), "utf8"));
96
+ const range = pkg.dependencies?.[depName];
97
+ if (!range) continue;
98
+ const m = range.match(/^\^(\d+)\.(\d+)\.(\d+)/);
99
+ if (!m) {
100
+ console.log(`(依赖 ${depName} 范围 ${range} 不是简单 ^x.y.z,跳过自动校验)`);
101
+ continue;
102
+ }
103
+ const v = depPkg.version.split(".").map(Number);
104
+ const ok = v[0] === +m[1] && (v[1] > +m[2] || (v[1] === +m[2] && v[2] >= +m[3]));
105
+ if (!ok) fail(`${name} 声明依赖 ${depName}@${range},但本地 ${depName} 是 ${depPkg.version}`);
106
+ console.log(`[依赖一致性] ${name} -> ${depName} ${depPkg.version} ✓`);
107
+ }
108
+ }
109
+ checkDependencyPair();
110
+
111
+ // ── 1. 前置检查 ──────────────────────────────────────────────────
112
+ if (!/^\d+\.\d+\.\d+$/.test(nextVer)) fail(`版本号格式错误:${nextVer}`);
113
+ // 2026-09-02 修复:gh auth status 的 "Logged in" 输出在 stderr(新版 gh),stdout 可能为空空
114
+ // → 2>&1 合并 stderr,避免已认证被误判"gh 未认证"(实测 keyring 已登录却判失败)
115
+ if (!quiet("gh auth status 2>&1").includes("Logged in")) fail("gh 未认证");
116
+ const whoami = quiet("npm whoami 2>&1");
117
+ if (!whoami) fail("npm 未认证(npm whoami 失败)");
118
+
119
+ // ── 1.5 DSH 兼容预检(发布前防“升级后插件不兼容”)──────────────
120
+ const compatScript = join(SCRIPT_DIR, "check-dsh-compat.mjs");
121
+ if (existsSync(compatScript)) {
122
+ console.log("\n=== DSH 兼容预检 ===");
123
+ run(`node "${compatScript}" "${dir}"`);
124
+ }
125
+
126
+ // ── 1.6 profile dump-config 冒烟(确认 DSH 可导出当前 profile 配置)──
127
+ function findDshBin() {
128
+ const candidates = [process.env.DSH_BIN, "D:/example/global-npm/dsh.cmd", "D:/example/global-npm/dsh", "dsh"].filter(Boolean);
129
+ for (const c of candidates) {
130
+ if (quiet(`where ${c}`)) return c;
131
+ }
132
+ return "";
133
+ }
134
+ const dshCmd = findDshBin();
135
+ if (dshCmd) {
136
+ const dump = quiet(`"${dshCmd}" --profile ${manifest?.profile || "web"} --dump-config`);
137
+ if (dump && !/error|Error|ENOENT/i.test(dump)) {
138
+ console.log("(profile dump-config 冒烟通过:DSH 配置可正常导出)");
139
+ } else {
140
+ console.log("(profile dump-config 冒烟未通过:请人工确认 DSH 可用后发布)");
141
+ }
142
+ } else {
143
+ console.log("(未找到 dsh 命令,跳过 profile dump-config 冒烟)");
144
+ }
145
+
146
+ // ── 2. 版本 bump(package.json + README 徽章)────────────────────
147
+ if (dryRun) {
148
+ // 发布门禁 B1/B2(阶段 B,02 v1.4):dry-run 同样先过门禁(失败即中止,不进入授权/发布)
149
+ console.log("\n=== 发布门禁 B1(README 版本四性)===");
150
+ run(`cd /d "${dir}" && node scripts/readme-version-check.mjs`);
151
+ console.log("\n=== 发布门禁 B2(lib/ 本机痕迹扫描)===");
152
+ run(`cd /d "${dir}" && node scripts/local-residue-scan.mjs`);
153
+ console.log("(发布门禁 B1/B2 通过)");
154
+ console.log(`[DRY-RUN] 不修改任何文件。`);
155
+ console.log(`[DRY-RUN] oldVer=${oldVer} nextVer=${nextVer}`);
156
+ const rd = join(dir, "README.md");
157
+ if (existsSync(rd)) {
158
+ const t = readFileSync(rd, "utf8");
159
+ const oldBadgeVer = `version-${oldVer}`;
160
+ const oldBadgeUrl = `version-${oldVer.replace(/-/g, "--")}`;
161
+ const nextUrl = `version-${nextVer}`;
162
+ const replaced = t.replaceAll(oldBadgeVer, nextUrl).replaceAll(oldBadgeUrl, nextUrl);
163
+ console.log(`[DRY-RUN] README 徽章将变为: ${replaced.match(/version-[^\s]+/)?.[0] || "(未命中,检查徽章形态)"}`);
164
+ }
165
+ process.exit(0);
166
+ }
167
+ writeFileSync(pkgPath, readFileSync(pkgPath, "utf8").replace(`"version": "${oldVer}"`, `"version": "${nextVer}"`));
168
+ for (const readme of ["README.md", "README.en.md"]) {
169
+ const rp = join(dir, readme);
170
+ if (existsSync(rp)) {
171
+ const text = readFileSync(rp, "utf8");
172
+ // 2026-08-29 修复:徽章 URL 编码把 `0.5.11-pre` 转成 `0.5.11--pre`(`-` 被 shields 双横线),
173
+ // 旧正则 `version-0.5.11-pre` 匹配不到 `version-0.5.11--pre` → 徽章漏改(㉙ 机器根因之一)。
174
+ // replaceAll 用字面串(非正则),无需转义。
175
+ const oldBadgeVer = `version-${oldVer}`; // JSON 形态:version-0.5.11-pre
176
+ const oldBadgeUrl = `version-${oldVer.replace(/-/g, "--")}`; // URL 形态:version-0.5.11--pre
177
+ const nextUrl = `version-${nextVer}`;
178
+ const replaced = text
179
+ .replaceAll(oldBadgeVer, nextUrl)
180
+ .replaceAll(oldBadgeUrl, nextUrl);
181
+ if (replaced !== text) writeFileSync(rp, replaced);
182
+ }
183
+ }
184
+ console.log("版本已 bump(package.json + README 徽章)");
185
+
186
+ // B1(2026-09-03):README 版本表行自动插入(阶段 C 人脑核对已失效的自动化;幂等:已存在 nextVer 行则跳过)
187
+ // 位置:版本表(| 版本 | 日期 | 要点 |)表头后第一数据行之前(最新在上);内容用调用方 notes 参数或 git log 最近提交信息
188
+ const notes = process.argv.includes("--notes") ? process.argv[process.argv.indexOf("--notes") + 1] : "";
189
+ for (const readme of ["README.md"]) {
190
+ const rd = join(dir, readme);
191
+ if (!existsSync(rd)) continue;
192
+ let rtxt = readFileSync(rd, "utf8");
193
+ const tableHeader = "| 版本 | 日期 | 要点 |";
194
+ const hi = rtxt.indexOf(tableHeader);
195
+ if (hi < 0 || rtxt.includes(`| **${nextVer}** |`)) continue;
196
+ const today = new Date().toLocaleDateString("en-CA"); // 本地时区 YYYY-MM-DD
197
+ const summary = notes || (quiet(`cd /d "${dir}" && git log -1 --pretty=%s`).slice(0, 90) || "(待补变更摘要)");
198
+ const nl = rtxt.indexOf("\n", hi);
199
+ if (nl < 0) continue;
200
+ const row = `| **${nextVer}** | ${today} | ${summary.replace(/\|/g, "\\|")} |`;
201
+ rtxt = rtxt.slice(0, nl + 1) + row + "\n" + rtxt.slice(nl + 1);
202
+ writeFileSync(rd, rtxt, "utf8");
203
+ console.log(`[B1] ${readme} 版本表已插入 ${nextVer} 行(摘要来源:${notes ? "notes 参数" : "git log"});请核对内容`);
204
+ }
205
+
206
+ // ── 3. 测试(规则 23:发布前运行时验证)─────────────────────────
207
+ if (pkg.scripts && pkg.scripts.test) {
208
+ console.log("\n=== 运行测试 ===");
209
+ run(`cd /d "${dir}" && npm test --prefix "${dir}"`);
210
+ } else {
211
+ console.log("(无 test 脚本,跳过)");
212
+ }
213
+
214
+ // ── 3.5 发布门禁 B1/B2(阶段 B,02 v1.4:测试全过后、进入授权/发布前;失败即中止)──
215
+ console.log("\n=== 发布门禁 B1(README 版本四性)===");
216
+ run(`cd /d "${dir}" && node scripts/readme-version-check.mjs`);
217
+ console.log("\n=== 发布门禁 B2lib/ 本机痕迹扫描)===");
218
+ run(`cd /d "${dir}" && node scripts/local-residue-scan.mjs`);
219
+
220
+ // ── 4. pack + publish ────────────────────────────────────────────
221
+ console.log("\n=== npm pack ===");
222
+ run(`cd /d "${dir}" && npm pack --pack-destination .`);
223
+ const tgz = `${name}-${nextVer}.tgz`;
224
+ if (!existsSync(join(dir, tgz))) fail(`打包产物缺失:${tgz}`);
225
+
226
+ console.log("\n=== npm publish ===");
227
+ run(`cd /d "${dir}" && ${proxyPrefix()}npm publish ${tgz}`);
228
+
229
+ // B1(2026-09-03):publish 后校验 registry dist-tags(0.5.16 事故:publish 自报成功但 latest 未切;
230
+ // 脚本此前只跑 publish 不校验发布态——三通道验证铁律:npm 通道以 dist-tags 为准,self-report 不算数)
231
+ // B1 v2(2026-09-03 同日晚):registry 传播以分钟计,publish 后立即校验曾误报中止(0.5.17 实弹)→
232
+ // 轮询重试 60s(6×10s);仍不符才中止,并按"版本是否已落盘"给出人工修正路径
233
+ console.log("\n=== npm 发布态校验(dist-tags,轮询最多 60s)===");
234
+ function sleepMs(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
235
+ const queryTags = () => quiet(`cd /d "${dir}" && ${proxyPrefix()}npm view ${name} dist-tags.latest`);
236
+ const queryVer = () => quiet(`cd /d "${dir}" && ${proxyPrefix()}npm view ${name}@${nextVer} version`);
237
+ let latestActual = "";
238
+ for (let i = 0; i < 6; i++) {
239
+ latestActual = queryTags();
240
+ if (latestActual === nextVer) break;
241
+ if (i < 5) { console.log(` [B1] 等待 registry 传播(${i + 1}/5,latest=${latestActual || "(空)"})…`); sleepMs(10000); }
242
+ }
243
+ if (latestActual !== nextVer) {
244
+ const verExists = queryVer() === nextVer;
245
+ fail(`npm 发布态异常:dist-tags.latest=${latestActual || "(为空)"},预期 ${nextVer}——${verExists ? `版本已存在但 latest 未跟:可执行 npm dist-tag add ${name}@${nextVer} latest 修正` : "版本未查询到:可能 staged/缓存(见踩坑 117)"};人工核查前勿继续 git/Release 通道`);
246
+ }
247
+ console.log(`dist-tags.latest=${latestActual} ✓`);
248
+
249
+ // ── 5. git commit + push(token 经环境变量注入 URL,命令文本不含密钥明文)─────
250
+ console.log("\n=== git commit + push ===");
251
+ // 2026-08-25 修复 pushUrl 双重拼接:origin 可能是 ssh(git@github.com:)、https(https://github.com/)
252
+ // 或裸路径(user/repo)三种形态——统一归一化为 "owner/repo" 路径再拼 token URL(此前 https origin 未剥前缀
253
+ // 导致 "https://github.com/https://github.com/..." 双重 URL,push 404)
254
+ const repo = (repoOverride || quiet(`cd /d "${dir}" && git remote get-url origin`))
255
+ .replace(/^git@github\.com:/, "")
256
+ .replace(/^https?:\/\/github\.com\//, "")
257
+ .replace(/\.git$/, "");
258
+ const token = quiet("gh auth token");
259
+ if (!token) fail("无法获取 gh token");
260
+ // v3.72 同款通道:token 拼进 HTTPS URL 直推(避开沙箱下 msys 凭据管道的 EPERM)
261
+ // 0.6.0:用户名从 repo 归属提取(不再硬编码——发布物不含本机账号)
262
+ const repoOwner = repo.split("/")[0];
263
+ if (!repoOwner) fail("无法解析仓库 owner(repo=" + repo + ")");
264
+ const pushUrl = `https://${repoOwner}:${token}@github.com/${repo}.git`;
265
+ // 2026-08-31 修复:repoRoot 实测 git 仓库根(插件目录可能只是子目录,如 rules-manager 在 oss 仓库内);
266
+ // stageSpec = 插件目录相对仓库根路径(防 git add -A 误带仓库内无关改动/未跟踪物)
267
+ const repoRoot = quiet(`cd /d "${dir}" && git rev-parse --show-toplevel`).trim();
268
+ const relDir = relative(repoRoot, dir).split(/[\\/]/).join("/");
269
+ const stageSpec = relDir && relDir !== "." ? relDir : "";
270
+ // 2026-08-29 防漏机检(0.5.10 git 欠账事故:git add -u 只更新已跟踪文件,未跟踪新文件
271
+ // (npm pack 按目录打包、天然包含)会漏进 git 提交 → git 与 npm 内容不一致)。
272
+ // 现在:提交前检测未跟踪文件并打印名单,改用 add -A 一并提交(.gitignore 已排除杂质)。
273
+ const untrackedList = quiet(`cd /d "${repoRoot}" && git status --porcelain -- ${stageSpec}`)
274
+ .split("\n").filter((l) => l.startsWith("??")).map((l) => l.slice(3));
275
+ if (untrackedList.length > 0) {
276
+ console.log(`(未跟踪文件 ${untrackedList.length} 个将一并提交:${untrackedList.slice(0, 6).join(", ")}${untrackedList.length > 6 ? " 等" : ""})`);
277
+ }
278
+ // 2026-09-04:提交物本机路径/个人标识检查已本机化(release-gate.mjs → scan-real-paths.mjs
279
+ // 存在性判据 + 本机词表);本文件不再内置扫描词表(发布物零个人化字符串),
280
+ // 由发布方在发布前调用本机 gate 一次完成。教训 8105d3e 由本机 gate 兜底。
281
+ run(`cd /d "${repoRoot}" && ${proxyPrefix()}git add -A -- ${stageSpec}`);
282
+ run(`cd /d "${repoRoot}" && git -c core.autocrlf=false commit -m "release: ${name} v${nextVer}" || exit 0`);
283
+ run(`cd /d "${repoRoot}" && ${proxyPrefix()}git push "${pushUrl}" HEAD`);
284
+
285
+ // ── 6. GitHub Release(带正式 tgz asset,规则 26;清单标记 skipRelease 的包不建)─────
286
+ if (entry?.skipRelease) {
287
+ console.log("(清单标记 skipRelease,跳过 GitHub Release)");
288
+ } else {
289
+ console.log("\n=== GitHub Release ===");
290
+ const notesFile = join(process.env.TEMP || ".", `notes-${name}-${nextVer}.md`);
291
+ writeFileSync(notesFile, `## v${nextVer}\n\nRelease generated by scripts/release-plugin.mjs\n`);
292
+ // 必须在插件目录(git 仓库)内运行:gh release 内部会做 git 检查
293
+ run(`cd /d "${dir}" && ${proxyPrefix()}gh release create v${nextVer} "${join(dir, tgz)}" --title "${name} v${nextVer}" --notes-file "${notesFile}"`);
294
+ }
295
+
296
+ // ── 7. 本机 profile 同步(--sync-profile:豁免名单 + 装配审计;需 danger-full-access 运行)──
297
+ if (syncProfile) {
298
+ console.log("\n=== 本机 profile 同步 ===");
299
+ let manifest = null;
300
+ try {
301
+ manifest = JSON.parse(readFileSync(join(SCRIPT_DIR, "plugins.json"), "utf8"));
302
+ } catch {
303
+ manifest = { profile: "web" };
304
+ }
305
+ const profileDir = resolve(WORKSPACE, "..", ".dsh", "profiles", manifest.profile || "web");
306
+ const wsYaml = join(profileDir, "pnpm-workspace.yaml");
307
+ if (existsSync(wsYaml)) {
308
+ const raw = readFileSync(wsYaml, "utf8");
309
+ const lineRe = new RegExp(`^( - ${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}@[^\\n]*)$`, "m");
310
+ const m = raw.match(lineRe);
311
+ if (m && !m[1].includes(nextVer)) {
312
+ const bakDir = join(profileDir, "..", ".backups");
313
+ mkdirSync(bakDir, { recursive: true });
314
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
315
+ const bak = join(bakDir, `pnpm-workspace-${stamp}.yaml`);
316
+ copyFileSync(wsYaml, bak);
317
+ const updated = raw.replace(lineRe, `${m[1]} || ${nextVer}`);
318
+ writeFileSync(wsYaml, updated);
319
+ console.log(`豁免名单已追加 ${name}@${nextVer}(备份 ${bak})`);
320
+ } else if (!m) {
321
+ console.log(`(豁免名单无 ${name} 条目,跳过)`);
322
+ } else {
323
+ console.log(`(豁免名单已含 ${nextVer},跳过)`);
324
+ }
325
+ }
326
+ // 全量装配审计(规则 27)
327
+ const auditScript = join(SCRIPT_DIR, "..", "projects", "oss", "dsh-rule-engine", "scripts", "audit-mount-consistency.mjs");
328
+ if (existsSync(auditScript)) {
329
+ const out = quiet(`node "${auditScript}" --profile ${manifest.profile || "web"}`);
330
+ const pass = /MOUNT CONSISTENT/.test(out);
331
+ console.log(out.split("\n").filter((l) => /RESULT|DUPLICATES|MOUNT|summary/.test(l)).join("\n"));
332
+ if (!pass) fail("装配审计未通过(MOUNT CONSISTENT 未出现),请先处理再重启 DSH");
333
+ console.log("装配审计 MOUNT CONSISTENT ✓");
334
+ }
335
+ }
336
+
337
+ console.log(`\n=== 发布完成:${name} v${nextVer} ===`);
338
+ console.log(`npm: ${name}@${nextVer}`);
339
+ console.log(`release: https://github.com/${repo}/releases/tag/v${nextVer}`);