@noob-stupid/dsh-plugin-console 0.5.23 → 0.5.25
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 +19 -0
- package/README.zh.md +18 -0
- package/lib/client.js +94 -9
- package/lib/server/domain/allow-builds.js +330 -0
- package/lib/server/domain/dep-source.js +19 -1
- package/lib/server/domain/git-channel.js +33 -2
- package/lib/server/domain/install-diagnose.js +47 -2
- package/lib/server/domain/install-job.js +31 -33
- package/lib/server/domain/install-verify.js +18 -1
- package/lib/server/domain/install.js +3 -3
- package/lib/server/domain/market.js +18 -1
- package/lib/server/domain/plugin-actions.js +95 -9
- package/lib/server/domain/preset-source.js +339 -0
- package/lib/server/domain/repo-size.js +136 -0
- package/lib/server/domain/repoland.js +86 -22
- package/lib/server/domain/suite.js +14 -28
- package/package.json +1 -1
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: "已切到托管模式:框架升级与回滚仍由本控制台负责",
|
|
@@ -296,6 +303,9 @@ window.__ModuleLoader__.load({
|
|
|
296
303
|
diagIntegrityNone: "来源无摘要,未校验",
|
|
297
304
|
diagIntegrityMismatch: "摘要不匹配(已安装,未拦截)",
|
|
298
305
|
diagIntegritySkip: "摘要未校验(读取失败,不影响安装)",
|
|
306
|
+
presetDot: "。预设:新建会话时选择",
|
|
307
|
+
presetInstalledMsg: "预设已装配",
|
|
308
|
+
diagChannelNotes: "通道说明",
|
|
299
309
|
direct: "(浏览器直连 GitHub;若你的浏览器打不开 GitHub,将自动回退到服务端通道)",
|
|
300
310
|
details: "详情",
|
|
301
311
|
expandDesc: "展开",
|
|
@@ -646,7 +656,13 @@ window.__ModuleLoader__.load({
|
|
|
646
656
|
actionOk: "Done",
|
|
647
657
|
actionPartial: "Partly done (manifest updated, lock not aligned)",
|
|
648
658
|
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.",
|
|
659
|
+
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.",
|
|
660
|
+
// 2026-09-27 additive (option 2): shown when pnpm fails because build scripts are not approved.
|
|
661
|
+
actionAllowLabel: "Allow these build scripts",
|
|
662
|
+
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).",
|
|
663
|
+
actionAllowOk: "Approval written",
|
|
664
|
+
actionAllowFailed: "Approval failed (file untouched)",
|
|
665
|
+
actionAllowBackup: "Backup",
|
|
650
666
|
safetyMode: "Operating mode",
|
|
651
667
|
safetyModeManaged: "Managed: this console owns framework upgrade and rollback (default, unchanged behaviour)",
|
|
652
668
|
safetyModeManagedOn: "Switched to managed: framework upgrade and rollback stay with this console",
|
|
@@ -821,6 +837,9 @@ window.__ModuleLoader__.load({
|
|
|
821
837
|
diagIntegrityNone: "No digest from source; not verified",
|
|
822
838
|
diagIntegrityMismatch: "Digest mismatch (installed, not blocked)",
|
|
823
839
|
diagIntegritySkip: "Digest not verified (read failed; install unaffected)",
|
|
840
|
+
presetDot: ". Preset: pick it when starting a new session",
|
|
841
|
+
presetInstalledMsg: "Presets installed",
|
|
842
|
+
diagChannelNotes: "Channel notes",
|
|
824
843
|
direct: "(The browser calls GitHub directly; the server channel is used as fallback.)",
|
|
825
844
|
details: "Details",
|
|
826
845
|
expandDesc: "Expand",
|
|
@@ -2868,6 +2887,11 @@ window.__ModuleLoader__.load({
|
|
|
2868
2887
|
}
|
|
2869
2888
|
} else if (data.ai === true) {
|
|
2870
2889
|
setMessage(t("installed") + ":" + data.packageName + "。" + (data.aiNote ?? t("aiRepaired")) + diagMessage(data));
|
|
2890
|
+
} else if (typeof data.presetNote === "string" && data.presetNote !== "") {
|
|
2891
|
+
// 预设装配成功(2026-09-27 批次 D-③):落盘路径由服务端如实下发,这里补一句
|
|
2892
|
+
// 「预设:新建会话时选择」(点击安装的当下没有新会话,必须先说清楚下一步)。
|
|
2893
|
+
setMessage(t("presetInstalledMsg") + ":" + data.presetNote + t("presetDot") + diagMessage(data));
|
|
2894
|
+
setReloadHint(true);
|
|
2871
2895
|
} else {
|
|
2872
2896
|
setMessage(t("installed") + ":" + data.packageName + "(" + (data.entryId ?? "") + ")。" + (data.curlNote ?? (data.bundle ? t("bundleNote") : t("installNote"))) + (data.bundleNote ? "。" + data.bundleNote : "") + (data.lockNote ? "。⚠️ " + data.lockNote : "") + (data.depNote ? "。" + data.depNote : "") + diagMessage(data));
|
|
2873
2897
|
}
|
|
@@ -2879,9 +2903,12 @@ window.__ModuleLoader__.load({
|
|
|
2879
2903
|
}
|
|
2880
2904
|
if (data.bundle) {
|
|
2881
2905
|
setRestartHint(true);
|
|
2882
|
-
} else if (data.suggestedAction !== null && data.suggestedAction !== undefined)
|
|
2906
|
+
} else if ((data.suggestedAction !== null && data.suggestedAction !== undefined)
|
|
2907
|
+
|| (typeof data.presetNote === "string" && data.presetNote !== "")) {
|
|
2883
2908
|
// 有建议动作时不自动刷新页面(2026-09-27 加法):旧行为 2.5 秒后 reload,
|
|
2884
2909
|
// 用户根本来不及点「执行」;这里改成给一个"请刷新"的可见提示。
|
|
2910
|
+
// 预设装配同理:只是往 ~/.dsh/.agent-presets 落盘,刷新页面没有意义,
|
|
2911
|
+
// 还会把「落盘路径 + 新建会话时选择」这句提示冲掉。
|
|
2885
2912
|
setReloadHint(true);
|
|
2886
2913
|
} else {
|
|
2887
2914
|
// 安装成功:自动刷新页面,让插件立即出现在列表里(可见的成功反馈)
|
|
@@ -2999,6 +3026,18 @@ window.__ModuleLoader__.load({
|
|
|
2999
3026
|
title: typeof diagnosis.hint === "string" ? diagnosis.hint : null,
|
|
3000
3027
|
});
|
|
3001
3028
|
}
|
|
3029
|
+
// ④ 通道备注(2026-09-27 批次 D-④):服务端一直在下发"为什么没试某个通道"(体积门禁、
|
|
3030
|
+
// 稀疏降级、release/git 预算…),但面板从未展示 → 用户只看到笼统的"失败"。
|
|
3031
|
+
// 失败时逐条显示(最多 6 条),成功时不显示(成功路径已有 presetNote/curlNote 等具体文案)。
|
|
3032
|
+
const channelNotes = Array.isArray(view.channelNotes) ? view.channelNotes.filter((n) => typeof n === "string" && n !== "") : [];
|
|
3033
|
+
if (view.status === "failed" && channelNotes.length > 0) {
|
|
3034
|
+
lines.push({
|
|
3035
|
+
key: "channelNotes",
|
|
3036
|
+
text: t("diagChannelNotes") + t("diagColon") + channelNotes.slice(0, 6).join(t("diagSemi")),
|
|
3037
|
+
level: "warn",
|
|
3038
|
+
title: channelNotes.join("\n"),
|
|
3039
|
+
});
|
|
3040
|
+
}
|
|
3002
3041
|
return lines;
|
|
3003
3042
|
};
|
|
3004
3043
|
/** 诊断短句 → 消息栏后缀(空串 = 没问题):只在既有文案**末尾追加**,一个字都不替换。 */
|
|
@@ -5197,20 +5236,32 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5197
5236
|
* 不新增徽章、不新增独立面板 —— 长解释一律进悬浮 title。 */
|
|
5198
5237
|
function SuggestedActionBox({ job, t }) {
|
|
5199
5238
|
const [state, setState] = react.useState({ phase: "idle", text: null, level: null, title: null, closed: false });
|
|
5239
|
+
// 2026-09-27 加法(选项 2):第二个显式动作的状态(「允许这些构建脚本」)。
|
|
5240
|
+
// 只在**执行结果**说明"pnpm 因构建脚本未获批准而报错"时才出现(服务端下发 state.nextAction);
|
|
5241
|
+
// 平时一个像素都不多占。
|
|
5242
|
+
const [allow, setAllow] = react.useState({ phase: "idle", text: null, level: null, title: null });
|
|
5200
5243
|
const action = job === null || job === undefined ? null : (job.suggestedAction ?? null);
|
|
5201
5244
|
if (action === null || typeof action.command !== "string" || state.closed === true) return null;
|
|
5202
5245
|
const running = state.phase === "running";
|
|
5203
5246
|
const label = action.kind === "pin-dependency" ? t("actionPinLabel")
|
|
5204
5247
|
: action.kind === "reconcile-lock" ? t("actionReconcileLabel")
|
|
5205
|
-
:
|
|
5248
|
+
: action.kind === "allow-builds" ? t("actionAllowLabel")
|
|
5249
|
+
: (typeof action.label === "string" && action.label !== "" ? action.label : String(action.kind ?? ""));
|
|
5250
|
+
const allowAction = state.nextAction !== null && state.nextAction !== undefined && typeof state.nextAction === "object"
|
|
5251
|
+
? state.nextAction
|
|
5252
|
+
: (action.kind === "allow-builds" ? action : null);
|
|
5253
|
+
const allowRunning = allow.phase === "running";
|
|
5254
|
+
const showAllow = allowAction !== null && typeof allowAction.command === "string" && allow.phase !== "done";
|
|
5206
5255
|
const run = () => {
|
|
5207
5256
|
if (running || action.payload === null || typeof action.payload !== "object") return;
|
|
5208
|
-
setState({ phase: "running", text: t("actionRunning"), level: null, title: null, closed: false });
|
|
5257
|
+
setState({ phase: "running", text: t("actionRunning"), level: null, title: null, closed: false, nextAction: state.nextAction ?? null });
|
|
5209
5258
|
callAction(action.payload).then(
|
|
5210
5259
|
(done) => {
|
|
5211
5260
|
const verdict = done?.ok === true ? t("actionOk") : done?.partial === true ? t("actionPartial") : t("actionFailed");
|
|
5212
5261
|
const parts = [verdict];
|
|
5213
5262
|
if (typeof done?.reason === "string" && done.reason !== "") parts.push(done.reason);
|
|
5263
|
+
// 服务端下发的 note(例如"已钉住,但 pnpm 因构建脚本未获批准而报错")优先展示
|
|
5264
|
+
if (Array.isArray(done?.notes) && done.notes.length > 0 && typeof done.notes[0] === "string") parts.push(done.notes[0]);
|
|
5214
5265
|
const raw = typeof done?.stderr === "string" && done.stderr.trim() !== "" ? done.stderr.trim() : "";
|
|
5215
5266
|
if (raw !== "") parts.push(raw.split("\n").slice(-1)[0]);
|
|
5216
5267
|
setState({
|
|
@@ -5219,13 +5270,36 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5219
5270
|
level: done?.ok === true ? null : "warn",
|
|
5220
5271
|
title: [done?.command, typeof done?.exitCode === "number" ? "exitCode=" + done.exitCode : null, done?.stdout, raw].filter((s) => typeof s === "string" && s !== "").join("\n") || null,
|
|
5221
5272
|
closed: false,
|
|
5273
|
+
// 只有真的到了"构建脚本未获批准"这一步才把它带出来(老服务端没有这个字段 → null)
|
|
5274
|
+
nextAction: done?.nextAction ?? null,
|
|
5222
5275
|
});
|
|
5223
5276
|
},
|
|
5224
|
-
(error) => setState({ phase: "error", text: (error !== null && typeof error.message === "string" && error.message !== "" ? error.message : t("actionFailed")), level: "error", title: null, closed: false }),
|
|
5277
|
+
(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
5278
|
);
|
|
5226
5279
|
};
|
|
5227
5280
|
const copy = () => {
|
|
5228
|
-
if (copyToClipboard(action.command)) setState({
|
|
5281
|
+
if (copyToClipboard(action.command)) setState({ ...state, text: t("actionCopied"), level: null, title: null });
|
|
5282
|
+
};
|
|
5283
|
+
// 「允许这些构建脚本」:同样是**结构化动作**(只发 nextAction.payload),服务端自己改文件;
|
|
5284
|
+
// 面板绝不拼命令、也不碰 profile。
|
|
5285
|
+
const runAllow = () => {
|
|
5286
|
+
if (allowRunning || allowAction === null || typeof allowAction.payload !== "object" || allowAction.payload === null) return;
|
|
5287
|
+
setAllow({ phase: "running", text: t("actionRunning"), level: null, title: null });
|
|
5288
|
+
callAction(allowAction.payload).then(
|
|
5289
|
+
(done) => {
|
|
5290
|
+
const verdict = done?.ok === true ? t("actionAllowOk") : t("actionAllowFailed");
|
|
5291
|
+
const parts = [verdict, typeof done?.reason === "string" ? done.reason : null, Array.isArray(done?.notes) ? done.notes[0] : null].filter((s) => typeof s === "string" && s !== "");
|
|
5292
|
+
setAllow({
|
|
5293
|
+
phase: done?.ok === true ? "done" : "error",
|
|
5294
|
+
text: parts.join(" · "),
|
|
5295
|
+
level: done?.ok === true ? null : "warn",
|
|
5296
|
+
title: [done?.file, Array.isArray(done?.added) && done.added.length > 0 ? t("actionAllowLabel") + ":" + done.added.join("、") : null,
|
|
5297
|
+
typeof done?.sha256Before === "string" ? "sha256 " + String(done.sha256Before).slice(0, 12) + " → " + String(done.sha256After ?? "").slice(0, 12) : null,
|
|
5298
|
+
typeof done?.backup === "string" ? t("actionAllowBackup") + ":" + done.backup : null].filter((s) => typeof s === "string" && s !== "").join("\n") || null,
|
|
5299
|
+
});
|
|
5300
|
+
},
|
|
5301
|
+
(error) => setAllow({ phase: "error", text: (error !== null && typeof error.message === "string" && error.message !== "" ? error.message : t("actionAllowFailed")), level: "error", title: null }),
|
|
5302
|
+
);
|
|
5229
5303
|
};
|
|
5230
5304
|
return el("div", { className: styles.detail, "data-suggested-action": String(action.kind ?? "") },
|
|
5231
5305
|
el("div", { className: styles.rowTop },
|
|
@@ -5233,7 +5307,7 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5233
5307
|
el("strong", { className: styles.name, title: t("actionLongTitle") }, label),
|
|
5234
5308
|
el("button", {
|
|
5235
5309
|
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 }),
|
|
5310
|
+
onClick: () => setState({ phase: "idle", text: null, level: null, title: null, closed: true, nextAction: null }),
|
|
5237
5311
|
}, "✕")),
|
|
5238
5312
|
el("code", {
|
|
5239
5313
|
className: styles.note,
|
|
@@ -5243,12 +5317,23 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
|
|
|
5243
5317
|
el("div", { className: styles.rowTop },
|
|
5244
5318
|
el("button", { type: "button", className: styles.toggle, disabled: running, onClick: run }, running ? t("actionRunning") : t("actionRun")),
|
|
5245
5319
|
el("button", { type: "button", className: styles.toggle, onClick: copy }, t("actionCopy")),
|
|
5320
|
+
// ★ 选项 2 的第二个动作:只在这个场景出现,文案短句 + 悬浮长解释(中英各一份)
|
|
5321
|
+
showAllow
|
|
5322
|
+
? el("button", { type: "button", className: styles.toggle, "data-allow-builds": "true", title: t("actionAllowLong"), disabled: allowRunning, onClick: runAllow }, allowRunning ? t("actionRunning") : t("actionAllowLabel"))
|
|
5323
|
+
: null,
|
|
5246
5324
|
el("span", {
|
|
5247
5325
|
className: styles.message,
|
|
5248
5326
|
"data-warn": state.level === "warn" ? "true" : undefined,
|
|
5249
5327
|
"data-error": state.level === "error" ? "true" : undefined,
|
|
5250
5328
|
title: state.title === null ? undefined : state.title,
|
|
5251
|
-
}, state.text ?? ""))
|
|
5329
|
+
}, state.text ?? "")),
|
|
5330
|
+
allow.text === null ? null : el("div", { className: styles.rowTop },
|
|
5331
|
+
el("span", {
|
|
5332
|
+
className: styles.message,
|
|
5333
|
+
"data-warn": allow.level === "warn" ? "true" : undefined,
|
|
5334
|
+
"data-error": allow.level === "error" ? "true" : undefined,
|
|
5335
|
+
title: allow.title === null ? undefined : allow.title,
|
|
5336
|
+
}, allow.text)));
|
|
5252
5337
|
}
|
|
5253
5338
|
|
|
5254
5339
|
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 }
|