@noob-stupid/dsh-plugin-console 0.5.23 → 0.5.24

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
@@ -252,6 +252,25 @@ Requires: DSH ≥ 0.1.0-rc.6 (web profile, with `dsh-client-modules` / `dsh-host
252
252
  > sources** all support primary→backup plus self-hosted mirrors (internal Gitea/GitLab, local
253
253
  > `file://` bare repos).
254
254
 
255
+ #### Download-install channels and dependency forms
256
+
257
+ Which channel gets used depends on **what the package actually is** — not on a setting:
258
+
259
+ | Channel | Target | When it is used | Written in the manifest as | Needs plugin-src |
260
+ |---|---|---|---|---|
261
+ | ① pnpm (registry) | npm registry | **if the package is on the registry, this is the main channel** | `<name>: <version>` | no |
262
+ | ② curl tarball (registry) | npm registry tarball | **races in parallel** with ① (takes over when pnpm is blocked/stuck) | same as above | no |
263
+ | ③ GitHub Release asset | prebuilt tgz of an upstream release | when **both registry sources 404** | `link:<absolute path>` | yes |
264
+ | ④ git clone | repository source | no npm package / no release asset | `link:…` | yes |
265
+ | ⑤ archive (tar.gz) | codeload / mirrors | when the git protocol cannot move any bytes | `link:…` | yes |
266
+
267
+ In one sentence: **has an npm package → ①/②, write a version, keep the lock clean; no npm package →
268
+ fall back to ③/④/⑤, materialize into `~/.dsh/plugin-src/<pkg>` and write `link:`.**
269
+
270
+ Environment reminder: an accelerator/proxy that rewrites hosts makes the direct sources unreachable
271
+ (see above), and free mirrors are unreliable for the **git protocol** on large packs while
272
+ **archive/raw are usually much faster** — the console already switches by itself.
273
+
255
274
  ### Static index market (plugin & skill tabs)
256
275
 
257
276
  > **Hybrid architecture**: browsing uses the static index (instant, zero GitHub API calls),
package/README.zh.md CHANGED
@@ -192,6 +192,24 @@ git clone https://github.com/Noob-stupid/dsh-plugin-hub /tmp/dsh-plugin-console
192
192
  > - 想让某条通道优先,去「软件源」里调整顺序:**Git 源**、**archive 源**、**索引源** 都支持主→备与
193
193
  > 自建镜像(内网 Gitea/GitLab、本地 `file://` 裸仓库均可)。
194
194
 
195
+ #### 下载安装通道与依赖形态
196
+
197
+ 走哪条通道,取决于**这个包到底是什么**,而不是某个开关:
198
+
199
+ | 通道 | 目标 | 何时使用 | 清单里写成 | 需要 plugin-src |
200
+ |---|---|---|---|---|
201
+ | ① pnpm(registry) | npm registry | **registry 上有该包就走它**(主通道) | `<name>: <version>` | 否 |
202
+ | ② curl tarball(registry) | npm registry tarball | 与 ① **并行竞速**(pnpm 被拦/卡住时顶上) | 同上 | 否 |
203
+ | ③ GitHub Release 资产 | 上游 release 的预构建 tgz | registry **双源 404** 时 | `link:<绝对路径>` | 是 |
204
+ | ④ git 克隆 | 仓库源码 | 无 npm 包 / 无 release 资产 | `link:…` | 是 |
205
+ | ⑤ archive(tar.gz) | codeload / 镜像 | git 协议拉不动时 | `link:…` | 是 |
206
+
207
+ 一句话结论:**有 npm 包 → 走 ①/②,写版本号、lock 干净;没有 npm 包 → 降级 ③/④/⑤,物化到
208
+ `~/.dsh/plugin-src/<pkg>` 并写 `link:`。**
209
+
210
+ 环境提醒:加速器/代理改 hosts 会让**直连源**失效(见上);免费镜像的 **git 协议**对大 pack 不可靠,
211
+ 而 **archive/raw 通常更快** —— 控制台已按这两条自行切换,不用手动换源。
212
+
195
213
  ### 静态索引市场(插件 / 技能双 tab)
196
214
 
197
215
  > **混合架构**:浏览走静态索引(秒开、零 API 调用),搜索走实时通道(GitHub 搜索 API / 多源并行)——
package/lib/client.js CHANGED
@@ -121,7 +121,14 @@ window.__ModuleLoader__.load({
121
121
  actionOk: "执行成功",
122
122
  actionPartial: "部分成功(清单已改,lock 未对齐)",
123
123
  actionFailed: "执行失败",
124
- actionLongTitle: "控制台按白名单动作在自己的进程里执行(只认 pin-dependency / reconcile-lock 这类内置动作,绝不接受自定义命令字符串);命令仅供展示与复制。",
124
+ actionLongTitle: "控制台按白名单动作在自己的进程里执行(只认 pin-dependency / reconcile-lock / allow-builds 这类内置动作,绝不接受自定义命令字符串);命令仅供展示与复制。",
125
+ // 2026-09-27 加法(选项 2):pnpm 因**构建脚本未获批准**报错时,动作框多出这一个按钮。
126
+ // 短句进面板,长解释(含"它不执行任何脚本/放行后第一次安装会很慢")一律进悬浮 title。
127
+ actionAllowLabel: "允许这些构建脚本",
128
+ actionAllowLong: "只有你点它才会写盘:在 profile 的 pnpm-workspace.yaml 里把**具体包名**补进 allowBuilds(改前先备份 .bak-<时间戳>、写完读回核实、其它字段一字不动)。它本身不下载、不执行任何脚本;放行后**下一次** pnpm 安装才会真正跑这些脚本(二进制要下载、原生模块要编译,本机实测首次约 19 分钟)。",
129
+ actionAllowOk: "已写入放行项",
130
+ actionAllowFailed: "放行失败(文件未改动)",
131
+ actionAllowBackup: "备份",
125
132
  safetyMode: "运行模式",
126
133
  safetyModeManaged: "托管:框架升级与回滚由本控制台负责(默认,行为与之前一致)",
127
134
  safetyModeManagedOn: "已切到托管模式:框架升级与回滚仍由本控制台负责",
@@ -646,7 +653,13 @@ window.__ModuleLoader__.load({
646
653
  actionOk: "Done",
647
654
  actionPartial: "Partly done (manifest updated, lock not aligned)",
648
655
  actionFailed: "Failed",
649
- actionLongTitle: "The console runs a whitelisted action inside its own process (only built-in kinds such as pin-dependency / reconcile-lock; a custom command string is never accepted). The command itself is display/copy only.",
656
+ actionLongTitle: "The console runs a whitelisted action inside its own process (only built-in kinds such as pin-dependency / reconcile-lock / allow-builds; a custom command string is never accepted). The command itself is display/copy only.",
657
+ // 2026-09-27 additive (option 2): shown when pnpm fails because build scripts are not approved.
658
+ actionAllowLabel: "Allow these build scripts",
659
+ actionAllowLong: "Nothing is written until you click this: it adds the exact package names to allowBuilds in the profile's pnpm-workspace.yaml (backup .bak-<timestamp> first, read back and verify, other fields untouched). It downloads and runs nothing itself; only the *next* pnpm install actually runs those scripts (a binary download plus native builds — measured ≈19 minutes the first time on this machine).",
660
+ actionAllowOk: "Approval written",
661
+ actionAllowFailed: "Approval failed (file untouched)",
662
+ actionAllowBackup: "Backup",
650
663
  safetyMode: "Operating mode",
651
664
  safetyModeManaged: "Managed: this console owns framework upgrade and rollback (default, unchanged behaviour)",
652
665
  safetyModeManagedOn: "Switched to managed: framework upgrade and rollback stay with this console",
@@ -5197,20 +5210,32 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
5197
5210
  * 不新增徽章、不新增独立面板 —— 长解释一律进悬浮 title。 */
5198
5211
  function SuggestedActionBox({ job, t }) {
5199
5212
  const [state, setState] = react.useState({ phase: "idle", text: null, level: null, title: null, closed: false });
5213
+ // 2026-09-27 加法(选项 2):第二个显式动作的状态(「允许这些构建脚本」)。
5214
+ // 只在**执行结果**说明"pnpm 因构建脚本未获批准而报错"时才出现(服务端下发 state.nextAction);
5215
+ // 平时一个像素都不多占。
5216
+ const [allow, setAllow] = react.useState({ phase: "idle", text: null, level: null, title: null });
5200
5217
  const action = job === null || job === undefined ? null : (job.suggestedAction ?? null);
5201
5218
  if (action === null || typeof action.command !== "string" || state.closed === true) return null;
5202
5219
  const running = state.phase === "running";
5203
5220
  const label = action.kind === "pin-dependency" ? t("actionPinLabel")
5204
5221
  : action.kind === "reconcile-lock" ? t("actionReconcileLabel")
5205
- : (typeof action.label === "string" && action.label !== "" ? action.label : String(action.kind ?? ""));
5222
+ : action.kind === "allow-builds" ? t("actionAllowLabel")
5223
+ : (typeof action.label === "string" && action.label !== "" ? action.label : String(action.kind ?? ""));
5224
+ const allowAction = state.nextAction !== null && state.nextAction !== undefined && typeof state.nextAction === "object"
5225
+ ? state.nextAction
5226
+ : (action.kind === "allow-builds" ? action : null);
5227
+ const allowRunning = allow.phase === "running";
5228
+ const showAllow = allowAction !== null && typeof allowAction.command === "string" && allow.phase !== "done";
5206
5229
  const run = () => {
5207
5230
  if (running || action.payload === null || typeof action.payload !== "object") return;
5208
- setState({ phase: "running", text: t("actionRunning"), level: null, title: null, closed: false });
5231
+ setState({ phase: "running", text: t("actionRunning"), level: null, title: null, closed: false, nextAction: state.nextAction ?? null });
5209
5232
  callAction(action.payload).then(
5210
5233
  (done) => {
5211
5234
  const verdict = done?.ok === true ? t("actionOk") : done?.partial === true ? t("actionPartial") : t("actionFailed");
5212
5235
  const parts = [verdict];
5213
5236
  if (typeof done?.reason === "string" && done.reason !== "") parts.push(done.reason);
5237
+ // 服务端下发的 note(例如"已钉住,但 pnpm 因构建脚本未获批准而报错")优先展示
5238
+ if (Array.isArray(done?.notes) && done.notes.length > 0 && typeof done.notes[0] === "string") parts.push(done.notes[0]);
5214
5239
  const raw = typeof done?.stderr === "string" && done.stderr.trim() !== "" ? done.stderr.trim() : "";
5215
5240
  if (raw !== "") parts.push(raw.split("\n").slice(-1)[0]);
5216
5241
  setState({
@@ -5219,13 +5244,36 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
5219
5244
  level: done?.ok === true ? null : "warn",
5220
5245
  title: [done?.command, typeof done?.exitCode === "number" ? "exitCode=" + done.exitCode : null, done?.stdout, raw].filter((s) => typeof s === "string" && s !== "").join("\n") || null,
5221
5246
  closed: false,
5247
+ // 只有真的到了"构建脚本未获批准"这一步才把它带出来(老服务端没有这个字段 → null)
5248
+ nextAction: done?.nextAction ?? null,
5222
5249
  });
5223
5250
  },
5224
- (error) => setState({ phase: "error", text: (error !== null && typeof error.message === "string" && error.message !== "" ? error.message : t("actionFailed")), level: "error", title: null, closed: false }),
5251
+ (error) => setState({ phase: "error", text: (error !== null && typeof error.message === "string" && error.message !== "" ? error.message : t("actionFailed")), level: "error", title: null, closed: false, nextAction: state.nextAction ?? null }),
5225
5252
  );
5226
5253
  };
5227
5254
  const copy = () => {
5228
- if (copyToClipboard(action.command)) setState({ phase: state.phase, text: t("actionCopied"), level: null, title: null, closed: false });
5255
+ if (copyToClipboard(action.command)) setState({ ...state, text: t("actionCopied"), level: null, title: null });
5256
+ };
5257
+ // 「允许这些构建脚本」:同样是**结构化动作**(只发 nextAction.payload),服务端自己改文件;
5258
+ // 面板绝不拼命令、也不碰 profile。
5259
+ const runAllow = () => {
5260
+ if (allowRunning || allowAction === null || typeof allowAction.payload !== "object" || allowAction.payload === null) return;
5261
+ setAllow({ phase: "running", text: t("actionRunning"), level: null, title: null });
5262
+ callAction(allowAction.payload).then(
5263
+ (done) => {
5264
+ const verdict = done?.ok === true ? t("actionAllowOk") : t("actionAllowFailed");
5265
+ const parts = [verdict, typeof done?.reason === "string" ? done.reason : null, Array.isArray(done?.notes) ? done.notes[0] : null].filter((s) => typeof s === "string" && s !== "");
5266
+ setAllow({
5267
+ phase: done?.ok === true ? "done" : "error",
5268
+ text: parts.join(" · "),
5269
+ level: done?.ok === true ? null : "warn",
5270
+ title: [done?.file, Array.isArray(done?.added) && done.added.length > 0 ? t("actionAllowLabel") + ":" + done.added.join("、") : null,
5271
+ typeof done?.sha256Before === "string" ? "sha256 " + String(done.sha256Before).slice(0, 12) + " → " + String(done.sha256After ?? "").slice(0, 12) : null,
5272
+ typeof done?.backup === "string" ? t("actionAllowBackup") + ":" + done.backup : null].filter((s) => typeof s === "string" && s !== "").join("\n") || null,
5273
+ });
5274
+ },
5275
+ (error) => setAllow({ phase: "error", text: (error !== null && typeof error.message === "string" && error.message !== "" ? error.message : t("actionAllowFailed")), level: "error", title: null }),
5276
+ );
5229
5277
  };
5230
5278
  return el("div", { className: styles.detail, "data-suggested-action": String(action.kind ?? "") },
5231
5279
  el("div", { className: styles.rowTop },
@@ -5233,7 +5281,7 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
5233
5281
  el("strong", { className: styles.name, title: t("actionLongTitle") }, label),
5234
5282
  el("button", {
5235
5283
  type: "button", className: styles.trashBtn, title: t("closeModal"), "aria-label": t("closeModal"),
5236
- onClick: () => setState({ phase: "idle", text: null, level: null, title: null, closed: true }),
5284
+ onClick: () => setState({ phase: "idle", text: null, level: null, title: null, closed: true, nextAction: null }),
5237
5285
  }, "✕")),
5238
5286
  el("code", {
5239
5287
  className: styles.note,
@@ -5243,12 +5291,23 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
5243
5291
  el("div", { className: styles.rowTop },
5244
5292
  el("button", { type: "button", className: styles.toggle, disabled: running, onClick: run }, running ? t("actionRunning") : t("actionRun")),
5245
5293
  el("button", { type: "button", className: styles.toggle, onClick: copy }, t("actionCopy")),
5294
+ // ★ 选项 2 的第二个动作:只在这个场景出现,文案短句 + 悬浮长解释(中英各一份)
5295
+ showAllow
5296
+ ? el("button", { type: "button", className: styles.toggle, "data-allow-builds": "true", title: t("actionAllowLong"), disabled: allowRunning, onClick: runAllow }, allowRunning ? t("actionRunning") : t("actionAllowLabel"))
5297
+ : null,
5246
5298
  el("span", {
5247
5299
  className: styles.message,
5248
5300
  "data-warn": state.level === "warn" ? "true" : undefined,
5249
5301
  "data-error": state.level === "error" ? "true" : undefined,
5250
5302
  title: state.title === null ? undefined : state.title,
5251
- }, state.text ?? "")));
5303
+ }, state.text ?? "")),
5304
+ allow.text === null ? null : el("div", { className: styles.rowTop },
5305
+ el("span", {
5306
+ className: styles.message,
5307
+ "data-warn": allow.level === "warn" ? "true" : undefined,
5308
+ "data-error": allow.level === "error" ? "true" : undefined,
5309
+ title: allow.title === null ? undefined : allow.title,
5310
+ }, allow.text)));
5252
5311
  }
5253
5312
 
5254
5313
  function PluginSafetyTab({ t }) {
@@ -0,0 +1,330 @@
1
+ // L1 · domain —— allow-builds.js(**只有用户显式点击时**才写盘:最小放行项 + 改前备份 + 写后读回核实)
2
+ //
3
+ // 为什么单开一个模块(2026-09-27 加法 + 改错):
4
+ // pnpm 11 的 `strictDepBuilds` 默认为 true —— 只要 profile 里存在**带安装脚本但未获批准**的依赖,
5
+ // 任何一次 pnpm 安装都会在**装完之后**抛 `ERR_PNPM_IGNORED_BUILDS` 并 exitCode=1,
6
+ // 哪怕这次操作真正想做的事(写清单 + 对齐 lock)已经做完了;用户看到的却是"失败"。
7
+ // 出路必须是**显式的**:用户自己点一下「允许这些构建脚本」,我们才在 profile 的 pnpm-workspace.yaml
8
+ // 里补上**具体包名**的放行项(pnpm 自己的 `approve-builds` 写的就是这个位置)。
9
+ //
10
+ // ⚠️ 安全边界(写死在这里,配套测试 tests/test-allow-builds.mjs 逐条钉死):
11
+ // ① **绝不自动执行**:本模块只被 domain/plugin-actions.js 的 `allow-builds` 动作调用,
12
+ // 而那个动作只由用户点击 -> POST /plugin-console/run-suggested 触发;安装/检测路径永不调用它。
13
+ // ② **绝不下载、绝不执行任何脚本**:本模块只做文本改写(readFile/writeFile/copyFile),
14
+ // 不 spawn 任何进程、不碰 argv、不跑 pnpm。放行只影响"pnpm 下次安装是否还会因未批准而报错";
15
+ // 真正的构建由 pnpm 在**用户之后自己发起**的安装里执行(本机实测首次 ≈19 分钟,note 里如实说清)。
16
+ // ③ **只动最小必要行**:命中一个包就只改/加那一行;其它字段一字不动(写后按行比对核实);
17
+ // 改前先复制 `.bak-<时间戳>`,写后读回核实(含 sha256 前后值)。
18
+ // ④ 认不出形态就不硬来:退回 `strictDepBuilds: false`(需求里点名的兜底),
19
+ // 并在 note 里写清副作用(对所有依赖生效)。
20
+ //
21
+ // 分层:L1 domain —— 不认识 cordis ctx;时间戳可注入(便于离线断言)。
22
+ import { createHash } from 'node:crypto'
23
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+ import { NPM_NAME_RE } from './dep-source.js'
26
+
27
+ const WORKSPACE_FILE = 'pnpm-workspace.yaml'
28
+ const MODULES_FILE = join('node_modules', '.modules.yaml')
29
+ const ALLOW_KEY = 'allowBuilds'
30
+ const ALLOWED_VALUE = 'true'
31
+ const DENIED_VALUE = 'false'
32
+ const ONLY_BUILT_KEY = 'onlyBuiltDependencies'
33
+ const STRICT_KEY = 'strictDepBuilds'
34
+ const IGNORED_KEY = 'ignoredBuilds'
35
+
36
+ function textOf(value) {
37
+ return typeof value === 'string' ? value : String(value ?? '')
38
+ }
39
+ function sha256Of(text) {
40
+ return createHash('sha256').update(textOf(text), 'utf8').digest('hex')
41
+ }
42
+ /** 文件行:统一按 LF 认识(CRLF 也拆得开;写回时统一 LF,符合"LF / 无 BOM"的要求)。 */
43
+ function linesOf(text) {
44
+ return textOf(text).split(/\r?\n/u)
45
+ }
46
+ function unquote(value) {
47
+ return textOf(value).trim().replace(/^['"]|['"]$/gu, '')
48
+ }
49
+ /** 顶层键(容忍 pnpm 写的带引号形态:`"allowBuilds":`)。 */
50
+ function isTopLevelKey(line, key) {
51
+ return new RegExp(`^["']?${key}["']?\\s*:`, 'u').test(line)
52
+ }
53
+ /** 表头行上是否直接写了值(如 `allowBuilds: { ssh2: true }` 这种行内/flow 形态)。
54
+ * 这种形态**不能**再往下面追加块条目(会写出坏 YAML)—— 判为"形态不允许",走 strictDepBuilds 兜底。 */
55
+ function headerHasInlineValue(line, key) {
56
+ const m = textOf(line).match(new RegExp(`^["']?${key}["']?\\s*:(.*)$`, 'u'))
57
+ if (m === null) return false
58
+ const rest = m[1].trim()
59
+ return rest !== '' && !rest.startsWith('#')
60
+ }
61
+ /** 顶层块 = `<key>:` 顶格那行 + 其后所有缩进行(空行/注释算块内,直到下一个顶格实义行)。 */
62
+ function topLevelBlock(lines, key) {
63
+ const headerIndex = lines.findIndex((line) => isTopLevelKey(line, key))
64
+ if (headerIndex < 0) return null
65
+ let endIndex = headerIndex
66
+ for (let i = headerIndex + 1; i < lines.length; i += 1) {
67
+ const line = lines[i]
68
+ if (line.trim() === '' || /^\s*#/u.test(line) || /^\s/u.test(line)) { endIndex = i; continue }
69
+ break
70
+ }
71
+ return { headerIndex, endIndex, body: lines.slice(headerIndex + 1, endIndex + 1) }
72
+ }
73
+ /** 块里的 `name: value` 条目(只认简单标量;认不出返回 null → 调用方走兜底)。 */
74
+ function mapEntries(block) {
75
+ const entries = []
76
+ for (let i = 0; i < block.body.length; i += 1) {
77
+ const raw = block.body[i]
78
+ if (raw.trim() === '' || /^\s*#/u.test(raw)) continue
79
+ const m = raw.match(/^(\s+)([^\s:#][^:]*?)\s*:\s*(.*)$/u)
80
+ if (m === null) return null
81
+ entries.push({ offset: i, indent: m[1], name: unquote(m[2]), value: m[3].trim(), raw })
82
+ }
83
+ return entries
84
+ }
85
+ /** 块里的 `- name` 列表项(同上)。 */
86
+ function listEntries(block) {
87
+ const entries = []
88
+ for (let i = 0; i < block.body.length; i += 1) {
89
+ const raw = block.body[i]
90
+ if (raw.trim() === '' || /^\s*#/u.test(raw)) continue
91
+ const m = raw.match(/^(\s*)-\s+(.+?)\s*$/u)
92
+ if (m === null) return null
93
+ entries.push({ offset: i, indent: m[1], name: unquote(m[2]), raw })
94
+ }
95
+ return entries
96
+ }
97
+ function normalizeNames(packages) {
98
+ return [...new Set((Array.isArray(packages) ? packages : []).map((n) => textOf(n).trim()).filter((n) => n !== ''))]
99
+ }
100
+
101
+ /** 纯函数:从文本读出放行状态。
102
+ * blocked = 还卡在"未决"的包(pnpm 写的占位值,如 `set this to true or false`);
103
+ * allowed = 已明确放行(true)的包。`false` 两边都不算 —— 它本身就是一个已做的决定(不会再报错)。 */
104
+ function readApprovalState(text) {
105
+ const lines = linesOf(text)
106
+ const allow = topLevelBlock(lines, ALLOW_KEY)
107
+ if (allow !== null) {
108
+ if (headerHasInlineValue(lines[allow.headerIndex], ALLOW_KEY)) {
109
+ return { form: ALLOW_KEY, parsed: false, blocked: [], allowed: [], reason: `${ALLOW_KEY} 写成了行内形态(不是逐行映射)` }
110
+ }
111
+ const entries = mapEntries(allow)
112
+ if (entries === null) {
113
+ return { form: ALLOW_KEY, parsed: false, blocked: [], allowed: [], reason: `${ALLOW_KEY} 不是简单的逐行映射` }
114
+ }
115
+ return {
116
+ form: ALLOW_KEY,
117
+ parsed: true,
118
+ blocked: entries.filter((e) => e.value !== ALLOWED_VALUE && e.value !== DENIED_VALUE).map((e) => e.name),
119
+ allowed: entries.filter((e) => e.value === ALLOWED_VALUE).map((e) => e.name),
120
+ reason: null,
121
+ }
122
+ }
123
+ const list = topLevelBlock(lines, ONLY_BUILT_KEY)
124
+ if (list !== null) {
125
+ if (headerHasInlineValue(lines[list.headerIndex], ONLY_BUILT_KEY)) {
126
+ return { form: ONLY_BUILT_KEY, parsed: false, blocked: [], allowed: [], reason: `${ONLY_BUILT_KEY} 写成了行内形态(不是简单列表)` }
127
+ }
128
+ const entries = listEntries(list)
129
+ if (entries === null) {
130
+ return { form: ONLY_BUILT_KEY, parsed: false, blocked: [], allowed: [], reason: `${ONLY_BUILT_KEY} 不是简单列表` }
131
+ }
132
+ return { form: ONLY_BUILT_KEY, parsed: true, blocked: [], allowed: entries.map((e) => e.name), reason: null }
133
+ }
134
+ return { form: null, parsed: true, blocked: [], allowed: [], reason: null }
135
+ }
136
+
137
+ /** 兜底:`strictDepBuilds: false`(形态不允许逐包放行时)。 */
138
+ function fallbackPlan(lines, names, why) {
139
+ const out = [...lines]
140
+ const index = out.findIndex((line) => isTopLevelKey(line, STRICT_KEY))
141
+ if (index >= 0) {
142
+ if (new RegExp(`^["']?${STRICT_KEY}["']?\\s*:\\s*${DENIED_VALUE}\\s*$`, 'u').test(out[index])) {
143
+ return { ok: true, added: [], form: STRICT_KEY, fallback: true, reason: why, changed: false, text: out.join('\n') }
144
+ }
145
+ out[index] = `${STRICT_KEY}: ${DENIED_VALUE}`
146
+ } else {
147
+ while (out.length > 0 && out[out.length - 1].trim() === '') out.pop()
148
+ out.push(`${STRICT_KEY}: ${DENIED_VALUE}`, '')
149
+ }
150
+ return { ok: true, added: names, form: STRICT_KEY, fallback: true, reason: why, changed: true, text: out.join('\n') }
151
+ }
152
+
153
+ /** 纯函数:算出"要写什么"(**不碰盘**)。
154
+ * 返回 { ok, changed, added[], form, fallback, reason, text };changed === false 表示无需改动。 */
155
+ function planApprovalEdit(text, packages) {
156
+ const names = normalizeNames(packages)
157
+ if (names.length === 0) return { ok: false, changed: false, added: [], form: null, fallback: false, reason: '没有给出要放行的包名', text: textOf(text) }
158
+ const bad = names.filter((n) => !NPM_NAME_RE.test(n))
159
+ if (bad.length > 0) return { ok: false, changed: false, added: [], form: null, fallback: false, reason: `不是合法的 npm 包名:${bad.join('、')}`, text: textOf(text) }
160
+ const source = textOf(text)
161
+ const lines = linesOf(source)
162
+ const allow = topLevelBlock(lines, ALLOW_KEY)
163
+ if (allow !== null) {
164
+ if (headerHasInlineValue(lines[allow.headerIndex], ALLOW_KEY)) return fallbackPlan(lines, names, `${ALLOW_KEY} 写成了行内形态`)
165
+ const entries = mapEntries(allow)
166
+ if (entries === null) return fallbackPlan(lines, names, `${ALLOW_KEY} 不是简单的逐行映射`)
167
+ const byName = new Map(entries.map((e) => [e.name, e]))
168
+ const added = names.filter((n) => byName.get(n)?.value !== ALLOWED_VALUE)
169
+ if (added.length === 0) return { ok: true, changed: false, added: [], form: ALLOW_KEY, fallback: false, reason: null, text: source }
170
+ const out = [...lines]
171
+ for (const name of added) {
172
+ const entry = byName.get(name)
173
+ if (entry === undefined) continue
174
+ out[allow.headerIndex + 1 + entry.offset] = `${entry.indent}${entry.name}: ${ALLOWED_VALUE}`
175
+ }
176
+ const fresh = added.filter((n) => !byName.has(n))
177
+ if (fresh.length > 0) {
178
+ const last = entries[entries.length - 1]
179
+ const indent = last === undefined ? ' ' : last.indent
180
+ const at = last === undefined ? allow.headerIndex + 1 : allow.headerIndex + 1 + last.offset + 1
181
+ out.splice(at, 0, ...fresh.map((n) => `${indent}${n}: ${ALLOWED_VALUE}`))
182
+ }
183
+ return { ok: true, changed: true, added, form: ALLOW_KEY, fallback: false, reason: null, text: out.join('\n') }
184
+ }
185
+ const list = topLevelBlock(lines, ONLY_BUILT_KEY)
186
+ if (list !== null) {
187
+ if (headerHasInlineValue(lines[list.headerIndex], ONLY_BUILT_KEY)) return fallbackPlan(lines, names, `${ONLY_BUILT_KEY} 写成了行内形态`)
188
+ const entries = listEntries(list)
189
+ if (entries === null) return fallbackPlan(lines, names, `${ONLY_BUILT_KEY} 不是简单列表`)
190
+ const known = new Set(entries.map((e) => e.name))
191
+ const added = names.filter((n) => !known.has(n))
192
+ if (added.length === 0) return { ok: true, changed: false, added: [], form: ONLY_BUILT_KEY, fallback: false, reason: null, text: source }
193
+ const out = [...lines]
194
+ const last = entries[entries.length - 1]
195
+ const indent = last === undefined ? ' ' : last.indent
196
+ const at = last === undefined ? list.headerIndex + 1 : list.headerIndex + 1 + last.offset + 1
197
+ out.splice(at, 0, ...added.map((n) => `${indent}- ${n}`))
198
+ return { ok: true, changed: true, added, form: ONLY_BUILT_KEY, fallback: false, reason: null, text: out.join('\n') }
199
+ }
200
+ // 两个键都没有:新建一个 allowBuilds 块(pnpm 11 的规范位置;pnpm 自己的 writeSettings 也这么干),
201
+ // 其余内容原样保留。
202
+ const out = [...lines]
203
+ while (out.length > 0 && out[out.length - 1].trim() === '') out.pop()
204
+ out.push(`${ALLOW_KEY}:`, ...names.map((n) => ` ${n}: ${ALLOWED_VALUE}`), '')
205
+ return { ok: true, changed: true, added: names, form: ALLOW_KEY, fallback: false, reason: null, text: out.join('\n') }
206
+ }
207
+
208
+ /** 读该 profile 的 pnpm-workspace.yaml(不存在时 exists=false,仍可走"新建"分支)。 */
209
+ function readWorkspaceApprovals(profileDir) {
210
+ const file = join(textOf(profileDir), WORKSPACE_FILE)
211
+ if (!existsSync(file)) {
212
+ return { file, exists: false, text: '', sha256: null, form: null, parsed: true, blocked: [], allowed: [], reason: null }
213
+ }
214
+ const text = readFileSync(file, 'utf8')
215
+ return { file, exists: true, text, sha256: sha256Of(text), ...readApprovalState(text) }
216
+ }
217
+
218
+ /** 读 pnpm 自己记的"被忽略的构建"(node_modules/.modules.yaml 的 ignoredBuilds,flow 或列表形态都能认)。 */
219
+ function readIgnoredBuilds(profileDir) {
220
+ const file = join(textOf(profileDir), MODULES_FILE)
221
+ if (!existsSync(file)) return []
222
+ let text = ''
223
+ try { text = readFileSync(file, 'utf8') } catch { return [] }
224
+ const flow = text.match(new RegExp(`["']?${IGNORED_KEY}["']?\\s*:\\s*\\[([\\s\\S]*?)\\]`, 'u'))
225
+ const body = flow !== null ? flow[1] : (() => {
226
+ const block = topLevelBlock(linesOf(text), IGNORED_KEY)
227
+ if (block === null) return null
228
+ const entries = listEntries(block)
229
+ return entries === null ? null : entries.map((e) => e.name).join(',')
230
+ })()
231
+ if (body === null || body === '') return []
232
+ const found = []
233
+ for (const part of body.split(',')) {
234
+ const dep = unquote(part).replace(/[[\]{}]/gu, '').trim()
235
+ if (dep === '') continue
236
+ const at = dep.lastIndexOf('@')
237
+ const name = at > 0 ? dep.slice(0, at) : dep
238
+ if (!NPM_NAME_RE.test(name)) continue
239
+ if (!found.includes(name)) found.push(name)
240
+ }
241
+ return found
242
+ }
243
+
244
+ /** 「已放行、但 pnpm 还没真正构建过」= .modules.yaml 里仍有被忽略的构建,且放行项里已经有 true。
245
+ * 安装动作据此选超时预算:真构建可能十几分钟,180 秒的上限会把**成功**的安装杀成"失败"。 */
246
+ function pendingApprovedBuilds(profileDir) {
247
+ const ignored = readIgnoredBuilds(profileDir)
248
+ const state = readWorkspaceApprovals(profileDir)
249
+ let modulesAllowed = []
250
+ try {
251
+ const modules = readFileSync(join(textOf(profileDir), MODULES_FILE), 'utf8')
252
+ const flow = modules.match(new RegExp(`["']?${ALLOW_KEY}["']?\\s*:\\s*\\{([^}]*)\\}`, 'u'))
253
+ if (flow !== null) {
254
+ modulesAllowed = [...flow[1].matchAll(/([^\s,:{}"']+)\s*:\s*true/gu)].map((m) => m[1])
255
+ } else {
256
+ const block = topLevelBlock(linesOf(modules), ALLOW_KEY)
257
+ const entries = block === null ? null : mapEntries(block)
258
+ modulesAllowed = entries === null ? [] : entries.filter((e) => e.value === ALLOWED_VALUE).map((e) => e.name)
259
+ }
260
+ } catch {}
261
+ const approved = [...new Set([...state.allowed, ...modulesAllowed])]
262
+ return { ignored, approved, pending: ignored.length > 0 && approved.length > 0 }
263
+ }
264
+
265
+ /** 时间戳(可注入,便于离线断言):`2026-09-27T16-12-33-123Z`。 */
266
+ function backupStamp(now) {
267
+ const date = typeof now === 'function' ? now() : (now instanceof Date ? now : new Date())
268
+ return date.toISOString().replace(/[:.]/gu, '-')
269
+ }
270
+
271
+ /** **唯一的写盘点**(只由显式动作调用):备份 -> 写最小放行项 -> 读回核实。
272
+ * 返回 { ok, changed, added[], file, form, fallback, sha256Before, sha256After, backup, verified, note, reason }。 */
273
+ function applyAllowBuilds({ profileDir, packages, now = () => new Date() } = {}) {
274
+ const state = readWorkspaceApprovals(profileDir)
275
+ if (state.exists !== true) {
276
+ return { ok: false, changed: false, added: [], file: state.file, reason: `本 profile 里没有 ${WORKSPACE_FILE},拒绝凭空创建` }
277
+ }
278
+ const planned = planApprovalEdit(state.text, packages)
279
+ if (planned.ok !== true) {
280
+ return { ok: false, changed: false, added: [], file: state.file, form: planned.form, fallback: false, reason: planned.reason }
281
+ }
282
+ if (planned.changed !== true) {
283
+ return {
284
+ ok: true, changed: false, added: [], file: state.file, form: planned.form, fallback: planned.fallback === true,
285
+ sha256Before: state.sha256, sha256After: state.sha256, backup: null,
286
+ verified: { targetsAllowed: true, othersIntact: true, sha256Matches: true },
287
+ reason: `无需改动:${planned.form} 里已经是放行状态`,
288
+ note: '这些构建脚本已经是放行状态,本次没有改动任何文件。',
289
+ }
290
+ }
291
+ const before = state.text
292
+ const backup = `${state.file}.bak-${backupStamp(now)}`
293
+ copyFileSync(state.file, backup) // ① 改前备份
294
+ writeFileSync(state.file, planned.text, 'utf8') // ② 写回(LF / 无 BOM:行由 linesOf 拆分后 join('\n'))
295
+ const after = readFileSync(state.file, 'utf8') // ③ 写后读回核实
296
+ const sha256After = sha256Of(after)
297
+ const stateAfter = readApprovalState(after)
298
+ const targetsAllowed = planned.fallback === true
299
+ ? new RegExp(`^["']?${STRICT_KEY}["']?\\s*:\\s*${DENIED_VALUE}\\s*$`, 'mu').test(after)
300
+ : planned.added.every((n) => stateAfter.allowed.includes(n))
301
+ // "其它字段一字未动":原文里**没有**被丢掉的行,必须原样、同序地出现在写回结果里。
302
+ const afterLines = linesOf(after)
303
+ const dropped = new Set(linesOf(before).filter((line) => !afterLines.includes(line)))
304
+ const kept = linesOf(before).filter((line) => !dropped.has(line))
305
+ let cursor = -1
306
+ let othersIntact = true
307
+ for (const line of kept) {
308
+ const at = afterLines.indexOf(line, cursor + 1)
309
+ if (at < 0) { othersIntact = false; break }
310
+ cursor = at
311
+ }
312
+ const verified = { targetsAllowed, othersIntact, sha256Matches: true }
313
+ const note = planned.fallback === true
314
+ ? `该 profile 的 ${WORKSPACE_FILE} 形态不允许逐包放行(${planned.reason ?? '结构无法安全改写'}),已写入 \`${STRICT_KEY}: ${DENIED_VALUE}\`:`
315
+ + 'pnpm 之后**不再因任何**未批准的构建脚本报错(副作用:对所有依赖生效;构建仍然不会被执行)。本动作本身不下载、不执行任何脚本。'
316
+ : `已把 ${planned.added.length} 个包写进 ${WORKSPACE_FILE} 的 ${planned.form}(只加/改这几行,其它字段一字未动)。`
317
+ + '放行后**下一次 pnpm 安装**才会真正执行这些脚本(二进制要下载、原生模块要编译,本机实测首次可能十几分钟);'
318
+ + '本动作本身不下载、不执行任何脚本。'
319
+ return {
320
+ ok: verified.targetsAllowed && verified.othersIntact && verified.sha256Matches,
321
+ changed: true, added: [...planned.added], file: state.file, form: planned.form, fallback: planned.fallback === true,
322
+ sha256Before: state.sha256, sha256After, backup, verified, reason: null, note,
323
+ }
324
+ }
325
+
326
+ export {
327
+ ALLOW_KEY, ALLOWED_VALUE, DENIED_VALUE, MODULES_FILE, ONLY_BUILT_KEY, STRICT_KEY, WORKSPACE_FILE,
328
+ applyAllowBuilds, backupStamp, headerHasInlineValue, linesOf, pendingApprovedBuilds, planApprovalEdit,
329
+ readApprovalState, readIgnoredBuilds, readWorkspaceApprovals, sha256Of,
330
+ }
@@ -168,6 +168,24 @@ function suggestedPinAction({ packageName, version = null, profileDir = null, pr
168
168
  }
169
169
  }
170
170
 
171
+ /** `allow-builds` 的建议动作(2026-09-27 加法,选项 2):pnpm 因**构建脚本未获批准**报错时下发,
172
+ * 面板据此多出一个「允许这些构建脚本」按钮 + 「复制命令」。
173
+ * 安全语义与 pin 动作同一套:`command` 只供展示/复制,执行侧只认 `payload.action` 白名单类型,
174
+ * 传 `command`/`argv` 一律 400;`packages` 只在"恰好一个"时才随 payload 下发(多个 = 放行全部被忽略的),
175
+ * 服务端还会自己读盘核对"这个名字当前确实被忽略",客户端说了不算。 */
176
+ function suggestedAllowBuildsAction({ packages = [], profile = null, reason = null } = {}) {
177
+ const list = Array.isArray(packages) ? packages.filter((p) => typeof p === 'string' && p !== '') : []
178
+ const name = profile ?? null
179
+ return {
180
+ kind: 'allow-builds',
181
+ label: '允许这些构建脚本',
182
+ // 展示用命令 = pnpm 自己的等价命令(**本动作不执行它**:服务端只按白名单形状改配置文件)
183
+ command: 'pnpm approve-builds',
184
+ payload: { action: 'allow-builds', ...(list.length === 1 ? { packageName: list[0] } : {}), profile: name },
185
+ reason,
186
+ }
187
+ }
188
+
171
189
  /** `reconcile-lock` 的建议动作(只重写 pnpm-lock.yaml,argv 由 lockfile-health.repairArgsFor 唯一产出)。 */
172
190
  function suggestedReconcileAction({ profileDir = null, profile = null, reason = null } = {}) {
173
191
  const name = profile ?? profileNameOf(profileDir)
@@ -180,4 +198,4 @@ function suggestedReconcileAction({ profileDir = null, profile = null, reason =
180
198
  }
181
199
  }
182
200
 
183
- export { PLUGIN_SRC_DIR, NPM_NAME_RE, probeRegistryPackage, probeFailureKind, materializePackageForLink, linkSpecFor, linkSpecIsIntact, profileNameOf, suggestedPinAction, suggestedReconcileAction }
201
+ export { PLUGIN_SRC_DIR, NPM_NAME_RE, probeRegistryPackage, probeFailureKind, materializePackageForLink, linkSpecFor, linkSpecIsIntact, profileNameOf, suggestedAllowBuildsAction, suggestedPinAction, suggestedReconcileAction }
@@ -9,6 +9,9 @@
9
9
  // `allowBuilds` / `ignored build scripts` 这一类**只提示,绝不自动写**用户的 allowBuilds / 构建白名单。
10
10
  // allowBuilds 决定哪些依赖可以执行安装期脚本(postinstall),自动放行 = 替用户授权任意代码执行;
11
11
  // 这是必须由人做的安全决策,所以本模块对它只产出文案,且 retry 恒为 null。
12
+ // 2026-09-27 追加:控制台现在提供一个**显式**动作(`allow-builds`,见 domain/allow-builds.js)——
13
+ // 只有用户**自己点**「允许这些构建脚本」才会写盘(改前备份 + 写后读回核实 + 只动 allowBuilds 块)。
14
+ // 本模块仍然一行都不写盘:这里的 hint 只是把用户引到那个按钮上。
12
15
  //
13
16
  // 分类依据是**失败文本**(pnpm 的 stderr / ExecError message),同一个 kind 的文案只维护一份。
14
17
 
@@ -49,6 +52,21 @@ const DIAGNOSIS_RULES = [
49
52
  hint: 'registry 的 minimumReleaseAge(供应链安全间隔)拦下了这个版本:稍后重试即可(版本"过龄"后自动放行);'
50
53
  + '确有紧急需要时由你自行放宽(pnpm 侧 --config.minimum-release-age=0)——本控制台不替你放宽安全间隔。',
51
54
  },
55
+ {
56
+ // 2026-09-27 加法:pnpm 11 的**构建脚本未获批准**硬错误(`strictDepBuilds` 默认为 true)。
57
+ // 与下面泛化的 allowBuilds 分开的理由:这一条要能**点名**是哪些包、并且说清一件事 ——
58
+ // 报这个错的时候安装其实已经完成(清单与 lock 都就位),它既不执行任何脚本、也不影响加载。
59
+ // 真机证据(2026-09-27 官方桌面端实例,pnpm 11.7.0):
60
+ // [ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: cloudflared@0.7.3, cpu-features@0.0.10, ssh2@1.17.0
61
+ // → `pnpm add link:…` exitCode=1,而清单/lock/node_modules 都已经是目标状态。
62
+ // 出路只有一条:用户**自己**点「允许这些构建脚本」(显式动作 allow-builds,写盘前先备份)。
63
+ kind: 'ignored-builds',
64
+ re: /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts|ignored build scripts/iu,
65
+ retry: null,
66
+ withPackages: true,
67
+ hint: '该依赖带安装脚本(postinstall 一类)且未获批准:清单与 lock 通常已经就位,这一步不执行任何脚本、也不影响加载;'
68
+ + '如需放行,请在动作框里点「允许这些构建脚本」(只在你点击时才写入最小放行项),本控制台绝不自动放行。',
69
+ },
52
70
  {
53
71
  kind: 'allowBuilds',
54
72
  re: /allowBuilds|allow-builds|ignored build scripts|Ignored build scripts|approve-builds|ERR_PNPM_IGNORED_BUILDS/iu,
@@ -117,6 +135,29 @@ function missingPackagesFrom(text) {
117
135
  return found
118
136
  }
119
137
 
138
+ /** 从**构建脚本被忽略**的报错文本里点名依赖(纯函数,2026-09-27 加法)。
139
+ *
140
+ * pnpm 的两种文案形态:
141
+ * `[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: cloudflared@0.7.3, cpu-features@0.0.10, ssh2@1.17.0`
142
+ * `Ignored build scripts: sharp, esbuild`
143
+ * 只认冒号后面那一行;包名去掉尾部 `@版本`(scoped 包名开头的 `@` 是作用域,不能用第一个 `@` 切)。
144
+ * 认不出来就返回空数组(调用方据此保留原样文案,绝不编造包名 —— 这条与 missingPackagesFrom 同一规矩)。 */
145
+ function ignoredBuildsFrom(text) {
146
+ const raw = typeof text === 'string' ? text : String(text ?? '')
147
+ const line = raw.match(/Ignored build scripts:\s*([^\n\r]+)/iu)
148
+ if (line === null) return []
149
+ const found = []
150
+ for (const part of line[1].split(',')) {
151
+ const spec = part.trim().replace(/[.。;;,]+$/u, '')
152
+ if (spec === '') continue
153
+ const at = spec.lastIndexOf('@')
154
+ const name = at > 0 ? spec.slice(0, at) : spec
155
+ if (!/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/iu.test(name)) continue
156
+ if (!found.includes(name)) found.push(name)
157
+ }
158
+ return found
159
+ }
160
+
120
161
  /** 按分类取短句 hint(纯函数;2026-09-27 加法):**文案只有一份**——
121
162
  * lockfile-health.js 的体检结论也走这里,避免"同一分类两处措辞"。认不出的分类返回 null。 */
122
163
  function hintForKind(kind, packages = []) {
@@ -131,7 +172,11 @@ function hintForKind(kind, packages = []) {
131
172
  * packages(2026-09-27 加法):文本里能点名的依赖包名(认不出就是空数组)——只用于展示,不参与任何判定。 */
132
173
  function classifyInstallFailure(stderrOrMessage) {
133
174
  const text = typeof stderrOrMessage === 'string' ? stderrOrMessage : String(stderrOrMessage ?? '')
134
- const packages = missingPackagesFrom(text)
175
+ // 构建脚本类报错的"涉及依赖"在 `Ignored build scripts:` 这一行里(不在 URL 里),单独认一次;
176
+ // 其余分类仍走 URL 还原(与之前一模一样)。
177
+ const urlish = missingPackagesFrom(text)
178
+ const buildish = ignoredBuildsFrom(text)
179
+ const packages = buildish.length > 0 ? buildish : urlish
135
180
  for (const rule of DIAGNOSIS_RULES) {
136
181
  if (!rule.re.test(text)) continue
137
182
  return { kind: rule.kind, hint: hintForKind(rule.kind, packages), retry: rule.retry, packages }
@@ -180,4 +225,4 @@ async function retryPnpmOnce({ ch, profileDir, name, registries, pnpmError, job,
180
225
  }
181
226
  }
182
227
 
183
- export { classifyInstallFailure, longerTimeoutFor, diagnosisText, retryPnpmOnce, missingPackagesFrom, hintForKind, DIAGNOSIS_RULES }
228
+ export { classifyInstallFailure, longerTimeoutFor, diagnosisText, retryPnpmOnce, missingPackagesFrom, ignoredBuildsFrom, hintForKind, DIAGNOSIS_RULES }
@@ -12,7 +12,16 @@
12
12
  // · pin-dependency:把清单里的该包钉成 `link:<DSH_HOME>/plugin-src/<包名>`,再跑一次
13
13
  // `pnpm add <link:…>` 把 lock / node_modules 对齐(argv 由 infra/exec.js#pnpmAddArgs 产出);
14
14
  // · reconcile-lock:只重写 pnpm-lock.yaml,复用 lockfile-health 的 repair(argv 由 repairArgsFor
15
- // 唯一产出,**不带**任何绕过供应链闸的开关)。
15
+ // 唯一产出,**不带**任何绕过供应链闸的开关);
16
+ // · allow-builds(2026-09-27 加法,选项 2 的核心):在 profile 的 pnpm-workspace.yaml 里
17
+ // **补上具体包名**的构建放行项(domain/allow-builds.js:改前备份 + 写后读回核实 + 只动那几行)。
18
+ // 它**不跑任何命令**,更不下载/执行脚本 —— 只影响"pnpm 下次安装是否还会因未批准而报错"。
19
+ // 只有用户自己点「允许这些构建脚本」才会走到这里(绝不自动放行)。
20
+ //
21
+ // 等待预算(2026-09-27 真机实测后的改错):放行之后**第一次**安装会真的执行那些脚本
22
+ // (cloudflared 下二进制、cpu-features/ssh2 走 node-gyp 原生编译;本机实测 18m59.6s 才结束,exitCode=0)。
23
+ // 原先这里固定 180s,会把一次**成功**的安装杀成"失败"。所以:profile 处于"已放行但还没构建过"时,
24
+ // 改用更长的预算(ACTION_BUILD_TIMEOUT_MS),其余情况**保持 180s 不变**。
16
25
  //
17
26
  // 供应链闸的一次性放宽(写清代价,绝不静默):pnpm 11 的 `minimumReleaseAge` 默认 24h,
18
27
  // 本控制台自己刚发的版本 (<24h) 会让**任何** lockfile 校验失败 —— 连"把本地 link: 记进 lock"都做不成。
@@ -23,7 +32,9 @@
23
32
  // 分层:L1 domain —— 不认识 cordis ctx,IO/执行器一律可注入(便于离线单测)。
24
33
  import { existsSync, readFileSync } from 'node:fs'
25
34
  import { join } from 'node:path'
26
- import { NPM_NAME_RE } from './dep-source.js'
35
+ import { applyAllowBuilds, pendingApprovedBuilds, readWorkspaceApprovals } from './allow-builds.js'
36
+ import { NPM_NAME_RE, suggestedAllowBuildsAction } from './dep-source.js'
37
+ import { classifyInstallFailure, ignoredBuildsFrom } from './install-diagnose.js'
27
38
  import { runLockfileRepair } from './lockfile-health.js'
28
39
  import { pinProfileDependency, specOf } from './manifest.js'
29
40
  import { lockVersion } from './selfupdate.js'
@@ -31,10 +42,16 @@ import { buildPnpmEnv, pnpmAddArgs, runPnpmWithFallback, unknownPnpmOption } fro
31
42
 
32
43
  const DEFAULT_REGISTRY = 'https://registry.npmmirror.com'
33
44
  /** 动作白名单(客户端只能给这些 kind;执行分支与 argv 全在服务端)。 */
34
- const ACTION_KINDS = ['pin-dependency', 'reconcile-lock']
45
+ const ACTION_KINDS = ['pin-dependency', 'reconcile-lock', 'allow-builds']
35
46
  /** 一旦出现这些键就整条拒绝:本接口不存在"传命令"的位置。 */
36
47
  const COMMAND_KEYS = ['command', 'cmd', 'argv', 'args', 'exec', 'execute', 'shell', 'script', 'run', 'spawn', 'bin', 'shellCommand']
37
48
  const OUTPUT_LIMIT = 4000
49
+ /** pin 动作里 pnpm 那一步的等待上限(默认不变 = 180s)。 */
50
+ const ACTION_ADD_TIMEOUT_MS = 180000
51
+ /** 已放行但还没构建过时的等待上限(30 分钟):真机构建实测 18m59.6s,180s 会把它杀成失败。 */
52
+ const ACTION_BUILD_TIMEOUT_MS = 1800000
53
+ /** A-② 里用户点名的 note 文案(**逐字**,配套断言钉死)。 */
54
+ const IGNORED_BUILDS_REACHED_NOTE = '依赖已钉住(清单+lock 已就位);pnpm 因构建脚本未获批准而报错 —— 这不会执行任何脚本,也不影响加载;如需放行请点「允许这些构建脚本」'
38
55
 
39
56
  /** 校验并翻译成结构化请求。返回 { ok, error, status, action, packageName, version, profile }。 */
40
57
  function parseActionRequest(body) {
@@ -45,7 +62,7 @@ function parseActionRequest(body) {
45
62
  }
46
63
  }
47
64
  const action = typeof raw.action === 'string' ? raw.action.trim() : ''
48
- if (action === '') return { ok: false, status: 400, error: '缺少 action(只允许 pin-dependency / reconcile-lock)' }
65
+ if (action === '') return { ok: false, status: 400, error: `缺少 action(只允许 ${ACTION_KINDS.join(' / ')})` }
49
66
  if (!ACTION_KINDS.includes(action)) {
50
67
  return { ok: false, status: 400, error: `未知动作 ${action}(只允许 ${ACTION_KINDS.join(' / ')})` }
51
68
  }
@@ -54,6 +71,10 @@ function parseActionRequest(body) {
54
71
  if (packageName === '') return { ok: false, status: 400, error: 'pin-dependency 需要 packageName' }
55
72
  if (packageName.length > 214 || !NPM_NAME_RE.test(packageName)) return { ok: false, status: 400, error: 'packageName 不是合法的 npm 包名' }
56
73
  }
74
+ // allow-builds 的 packageName 可省略(缺省 = 放行当前被忽略的全部);给了就必须是合法 npm 名。
75
+ if (action === 'allow-builds' && packageName !== '' && (packageName.length > 214 || !NPM_NAME_RE.test(packageName))) {
76
+ return { ok: false, status: 400, error: 'packageName 不是合法的 npm 包名' }
77
+ }
57
78
  const version = typeof raw.version === 'string' && raw.version.length <= 120 ? raw.version : null
58
79
  const profile = typeof raw.profile === 'string' && raw.profile.length <= 64 ? raw.profile : null
59
80
  return { ok: true, error: null, status: 200, action, packageName: packageName === '' ? null : packageName, version, profile }
@@ -146,6 +167,44 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
146
167
  ok: false, status: 200, action: parsed.action, packageName: parsed.packageName, profile: parsed.profile,
147
168
  registry, exitCode: null, command: null, stdout: '', stderr: '', relaxedReleaseAge: false,
148
169
  manifest: null, lock: null, notes: [], reason: null, hint: null, detail: null,
170
+ kind: parsed.action, ignoredBuilds: [], nextAction: null,
171
+ }
172
+ if (parsed.action === 'allow-builds') {
173
+ const applyAllow = typeof deps.applyAllow === 'function' ? deps.applyAllow : applyAllowBuilds
174
+ const pending = pendingApprovedBuilds(profileDir)
175
+ // 缺省 = 放行当前真被忽略的全部:优先用 pnpm 自己记的 ignoredBuilds(.modules.yaml),
176
+ // 回落到 workspace 文件里的未决占位项。**绝不**接受客户端点名一个"当前没被忽略"的包。
177
+ const blocked = pending.ignored.length > 0 ? pending.ignored : readWorkspaceApprovals(profileDir).blocked
178
+ const targets = parsed.packageName === null ? blocked : [parsed.packageName]
179
+ if (targets.length === 0) {
180
+ return { ...base, ok: false, status: 400, error: '当前没有待批准的构建脚本(pnpm 没记录被忽略的构建,workspace 里也没有未决占位项),无需放行' }
181
+ }
182
+ if (parsed.packageName !== null && !blocked.includes(parsed.packageName)) {
183
+ return { ...base, ok: false, status: 400, error: `${parsed.packageName} 不在当前被忽略的构建脚本名单里(${blocked.join('、') || '空'}),拒绝改写` }
184
+ }
185
+ const applied = await applyAllow({ profileDir, packages: targets, now: deps.now })
186
+ return {
187
+ ...base,
188
+ ok: applied.ok === true,
189
+ exitCode: applied.ok === true ? 0 : 1,
190
+ // 展示/复制的命令 = pnpm 自己的等价命令(本动作**不执行**它;服务端只写文件)
191
+ command: 'pnpm approve-builds',
192
+ changed: applied.changed === true,
193
+ added: Array.isArray(applied.added) ? applied.added : [],
194
+ file: applied.file ?? null,
195
+ form: applied.form ?? null,
196
+ fallback: applied.fallback === true,
197
+ sha256Before: applied.sha256Before ?? null,
198
+ sha256After: applied.sha256After ?? null,
199
+ backup: applied.backup ?? null,
200
+ verified: applied.verified ?? null,
201
+ allow: applied,
202
+ notes: [applied.note, applied.reason].filter((n) => typeof n === 'string' && n !== ''),
203
+ reason: applied.ok === true
204
+ ? (applied.changed === true ? `已放行:${(applied.added ?? []).join('、') || '(无新增)'}` : (applied.reason ?? '无需改动'))
205
+ : (applied.reason ?? '放行失败(未改动文件)'),
206
+ detail: applied,
207
+ }
149
208
  }
150
209
  if (parsed.action === 'reconcile-lock') {
151
210
  const view = await repair({ profileDir, registries: Array.isArray(registries) && registries.length > 0 ? registries : [registry] })
@@ -178,7 +237,13 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
178
237
  return { ...base, ok: false, status: 200, manifest: { before, after: before, changed: false, pinned: false }, reason: error instanceof Error ? error.message : String(error) }
179
238
  }
180
239
  const argv = pnpmAddArgs(pinned.spec, registry)
181
- const run = await runAddWithReleaseAgeRetry({ profileDir, spec: pinned.spec, registry, runAdd, timeoutMs: deps.timeoutMs })
240
+ // 等待预算:profile 若处于"已放行但还没构建过",这一步会真的去构建(实测 18m59.6s)——用长预算;
241
+ // 其余情况一个字都不变(仍是 180s)。
242
+ const pendingBuilds = pendingApprovedBuilds(profileDir)
243
+ const timeoutMs = Number.isFinite(deps.timeoutMs) && deps.timeoutMs > 0
244
+ ? deps.timeoutMs
245
+ : (pendingBuilds.pending === true ? ACTION_BUILD_TIMEOUT_MS : ACTION_ADD_TIMEOUT_MS)
246
+ const run = await runAddWithReleaseAgeRetry({ profileDir, spec: pinned.spec, registry, runAdd, timeoutMs })
182
247
  const after = await specOf(profileDir, parsed.packageName)
183
248
  const entry = lockVersion(profileDir, parsed.packageName)
184
249
  const lockSynced = typeof entry === 'string' && entry.startsWith('link:')
@@ -186,11 +251,27 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
186
251
  // 规范化成相对路径,那同样是"不经 registry 解析"的来源型 spec,判据见 domain/dep-source.js#linkSpecIsIntact)
187
252
  const pinnedOk = typeof after === 'string' && after.startsWith('link:')
188
253
  const manifests = { before, after, changed: before !== after, planned: pinned.spec, pinned: pinnedOk }
254
+ // 失败分类只认一份真源(install-diagnose 的规则表):这里只关心"是不是构建脚本未获批准"。
255
+ const failure = run.ok === true ? null : classifyInstallFailure(`${run.stderr}\n${String(run.error?.message ?? '')}`)
256
+ const ignored = failure?.kind === 'ignored-builds'
257
+ const ignoredBuilds = ignored === true ? ignoredBuildsFrom(run.stderr) : []
258
+ const nextAction = ignored === true ? suggestedAllowBuildsAction({ packages: ignoredBuilds, profile: parsed.profile, reason: 'pnpm 因构建脚本未获批准而报错' }) : null
189
259
  const notes = []
190
260
  if (run.relaxedReleaseAge) notes.push('本机 pnpm 的 24h 供应链闸(minimumReleaseAge)拦下了这次 lock 校验,已**仅对这一条命令**追加 --config.minimumReleaseAge=0 重试一次(有安全代价,未写进任何配置文件)。')
191
- // 三种"pnpm 那一步没跑成"的形态都如实记一句(2026-09-27 真机桌面端实例实测:corepack 在该进程里
192
- // 解析不到 → pnpm 那一步失败,而清单/lock 其实已达成目标;只留 stderr 会让用户自己猜发生了什么)
193
- if (!run.ok) {
261
+ if (pendingBuilds.pending === true) {
262
+ notes.push(`本 profile 已有放行项但还没构建过:本次 pnpm 那一步会真的执行构建脚本(本机实测约 19 分钟),`
263
+ + `所以等待上限提到了 ${Math.round(timeoutMs / 60000)} 分钟,不会中途把一次**成功**的安装杀掉。`)
264
+ }
265
+ if (!run.ok && ignored === true) {
266
+ // A-② 语义:清单与 lock 已到目标状态时,这类报错**不是失败** —— 它一个脚本都不执行、也不影响加载。
267
+ // exitCode 如实回报(绝不抹成 0);只把定性、涉及哪些包、以及出口(显式放行按钮)讲清楚。
268
+ notes.push(manifests.pinned && lockSynced
269
+ ? IGNORED_BUILDS_REACHED_NOTE
270
+ : `清单与 lock 还没到目标状态,而 pnpm 又因构建脚本未获批准(ERR_PNPM_IGNORED_BUILDS)中止(exitCode=${run.exitCode}):可先点「允许这些构建脚本」,再重跑本动作。`)
271
+ if (ignoredBuilds.length > 0) notes.push(`被忽略的构建脚本涉及:${ignoredBuilds.join('、')}(exitCode=${run.exitCode} 如实回报,未抹成 0)。`)
272
+ } else if (!run.ok) {
273
+ // 其余"pnpm 那一步没跑成"的形态照旧如实记一句(2026-09-27 真机桌面端实例实测:corepack 在该进程里
274
+ // 解析不到 → pnpm 那一步失败,而清单/lock 其实已达成目标;只留 stderr 会让用户自己猜发生了什么)
194
275
  notes.push(manifests.pinned
195
276
  ? (lockSynced
196
277
  ? `pnpm 那一步没跑完(exitCode=${run.exitCode},原始输出见 stderr):清单与 lock 已经是 link:,目标状态已达成。`
@@ -199,6 +280,9 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
199
280
  }
200
281
  return {
201
282
  ...base,
283
+ kind: ignored === true ? 'ignored-builds' : parsed.action,
284
+ ignoredBuilds,
285
+ nextAction,
202
286
  // 只有"清单真的是 link: **且** lock 里也有这条 link"才算成功;只成一半时 partial=true 并如实说明
203
287
  ok: manifests.pinned && lockSynced,
204
288
  partial: manifests.pinned && !lockSynced,
@@ -207,13 +291,15 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
207
291
  stdout: run.stdout,
208
292
  stderr: run.stderr,
209
293
  relaxedReleaseAge: run.relaxedReleaseAge,
294
+ timeoutMs,
210
295
  manifest: manifests,
211
296
  lock: { entry: entry ?? null, synced: lockSynced },
212
297
  notes,
298
+ failureKind: failure?.kind ?? null,
213
299
  reason: manifests.pinned
214
300
  ? (lockSynced ? `${parsed.packageName} 已钉住:清单与 pnpm-lock.yaml 都指向 link:${pinned.dir}` : `${parsed.packageName} 的清单已钉住,lock 未对齐`)
215
301
  : `${parsed.packageName} 的清单写回未生效(实际是 ${after ?? '(无该条目)'})`,
216
302
  }
217
303
  }
218
304
 
219
- export { ACTION_KINDS, COMMAND_KEYS, DEFAULT_REGISTRY, OUTPUT_LIMIT, isProfilePackage, isReleaseAgeBlock, parseActionRequest, runAddWithReleaseAgeRetry, runSuggestedAction }
305
+ export { ACTION_ADD_TIMEOUT_MS, ACTION_BUILD_TIMEOUT_MS, ACTION_KINDS, COMMAND_KEYS, DEFAULT_REGISTRY, IGNORED_BUILDS_REACHED_NOTE, OUTPUT_LIMIT, isProfilePackage, isReleaseAgeBlock, parseActionRequest, runAddWithReleaseAgeRetry, runSuggestedAction }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.23",
3
+ "version": "0.5.24",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",