@1e0zj/dsh-plugin-mall 0.4.5 → 0.4.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -16,9 +16,6 @@ window.__ModuleLoader__.load({
16
16
  // ── styles ──────────────────────────────────────────────────────────────
17
17
  var css = [
18
18
  ".mkt_root{display:flex;flex-direction:column;gap:12px;max-width:1040px}",
19
- ".mkt_head{display:flex;flex-direction:column;gap:2px}",
20
- ".mkt_title{font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}",
21
- ".mkt_sub{font-size:12px;color:var(--dsw-alias-label-tertiary)}",
22
19
  ".mkt_row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}",
23
20
  ".mkt_input{flex:1;min-width:200px;background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:6px 10px;font-size:13px;color:var(--dsw-alias-label-primary)}",
24
21
  ".mkt_input::placeholder{color:var(--dsw-alias-label-caption)}",
@@ -985,10 +982,6 @@ window.__ModuleLoader__.load({
985
982
  var verifyPending = results !== null && results.items.some(function (it) { return verified[it.fullName] === undefined; });
986
983
 
987
984
  return h("div", { className: "mkt_root" },
988
- h("div", { className: "mkt_head" },
989
- h("div", { className: "mkt_title" }, "插件市场"),
990
- h("div", { className: "mkt_sub" }, "搜索 GitHub dsh-plugin 话题插件 · 装/卸后需重启 dsh 生效")
991
- ),
992
985
  h("div", { className: "mkt_row" },
993
986
  h("input", {
994
987
  className: "mkt_input",
@@ -1007,7 +1000,6 @@ window.__ModuleLoader__.load({
1007
1000
  h("option", { value: "forks" }, "按 fork")
1008
1001
  ),
1009
1002
  h("button", { className: "mkt_btn mkt_btnPrimary", disabled: loading, onClick: doSearch }, loading ? "搜索中…" : "搜索"),
1010
- h("button", { className: "mkt_btn", onClick: refreshInstalled }, "刷新已装"),
1011
1003
  h("button", { className: "mkt_btn mkt_btnDanger", disabled: restarting, onClick: doRestart }, restarting ? "重启中…" : "重启 dsh"),
1012
1004
  h("label", { className: "mkt_check" },
1013
1005
  h("input", { type: "checkbox", checked: verifiedOnly, onChange: function (e) { setVerifiedOnly(e.target.checked); } }),
package/src/guard.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  readdirSync,
18
18
  realpathSync,
19
19
  rmSync,
20
+ statSync,
20
21
  symlinkSync,
21
22
  writeFileSync,
22
23
  } from "node:fs";
@@ -811,6 +812,217 @@ function disabledState(value) {
811
812
  return value ? "disabled" : "enabled";
812
813
  }
813
814
 
815
+ /**
816
+ * Rows that would BRICK startup: an MCP client entry, fatal on failure, whose
817
+ * entry file does not exist in the candidate (issue #14).
818
+ *
819
+ * The shape is deliberately the provable minimum — every condition must hold
820
+ * exactly, and anything short of it is left alone:
821
+ *
822
+ * - name is exactly @deepseek-ai/dsh-mcp-client, transport stdio,
823
+ * failOnStartupError is the literal true, command the literal "node";
824
+ * - the row's FINAL state is enabled (checked on the composed tree, so a
825
+ * user patch that disables it, retargets args, or drops the fatal flag
826
+ * exempts it — and a candidate that overrides an EXISTING mcp row into
827
+ * this shape is caught just the same);
828
+ * - args has exactly one element — the entry — which is EXACTLY
829
+ * `!!js dshHomePath('<literal>')`, no concatenation, no ternary;
830
+ * - the literal points INSIDE `profiles/<this profile>/node_modules/<the
831
+ * candidate's own package>/…` — other profiles and other packages are out
832
+ * of scope (the probe tree cannot verify them), and the remainder is
833
+ * clamped segment-wise;
834
+ * - the last segment carries a runnable extension and resolves to a plain
835
+ * file.
836
+ *
837
+ * A miss in the isolated probe tree is a BLOCK only when nothing could have
838
+ * built the entry later: no install-time scripts, no binding.gyp, no .hooks/
839
+ * (pnpm's requiresBuild treats all three as build-capable — verified against
840
+ * its worker.js source, see docs/dsh-notes.md). A build-capable candidate
841
+ * downgrades to a WARNING here because the probe disables lifecycle scripts
842
+ * while a real install may run them after approval; the hard gate for that
843
+ * case is mcpEntryAuditForInstall at finalize time. The browsing-time scan
844
+ * cannot run any of this (no file tree); this is install-preflight only.
845
+ */
846
+ const DSH_MCP_CLIENT_MODULE = "@deepseek-ai/dsh-mcp-client";
847
+ // dshHomePath 只有作为 !!js 表达式才被 loader 求值成路径;!!js 在内存里是
848
+ // { __jsExpr: source }。只认「恰好一个单字面量实参的调用」,其余形态
849
+ // (拼接、三元、嵌套)求值前无从判断,一律不碰。实参里允许反斜杠
850
+ // (Windows 写法,匹配后归一成 /);引号排除——出现引号即不是这种形状。
851
+ const DSH_HOME_PATH_EXACT = /^dshHomePath\(\s*(['"])([^'"]*)\1\s*\)$/;
852
+ // 安装期 lifecycle 脚本。pnpm 的 requiresBuild(worker.js: pkgRequiresBuild,
853
+ // 11.21.0 逐字核对)实际只认 preinstall/install/postinstall;下面的
854
+ // prepare/prepublish 是本守卫额外的保守信号——它们不在 pnpm 的清单里,
855
+ // 但也绝非不可能在安装期产出文件,宁可多 warn 一次终检,不冒误杀风险。
856
+ const INSTALL_TIME_SCRIPT_RE = /^(?:pre|post)?install$|^prepare$|^prepublish$/;
857
+ const RUNNABLE_ENTRY_RE = /\.(?:js|mjs|cjs)$/i;
858
+
859
+ /**
860
+ * Whether the entry might only exist after a build pnpm would gate behind
861
+ * approval. Aligned with pnpm's own requiresBuild (worker.js:
862
+ * pkgRequiresBuild + filesIncludeInstallScripts, verified verbatim against
863
+ * 11.21.0): scripts preinstall/install/postinstall, a root binding.gyp, or
864
+ * anything under .hooks/. guard additionally treats prepare/prepublish as
865
+ * build-capable — a conservative addition of ours beyond pnpm's actual set,
866
+ * widening the warn lane, never narrowing the block lane.
867
+ */
868
+ function candidateMayBuildEntry(candidateManifest, candidateDir) {
869
+ if (Object.keys(candidateManifest?.scripts ?? {}).some((name) => INSTALL_TIME_SCRIPT_RE.test(name))) return true;
870
+ if (typeof candidateDir !== "string") return false;
871
+ if (isPlainFile(join(candidateDir, "binding.gyp"))) return true;
872
+ try {
873
+ return readdirSync(join(candidateDir, ".hooks")).length > 0;
874
+ } catch {
875
+ return false;
876
+ }
877
+ }
878
+
879
+ /** A plain file at the path (not a directory, not missing). */
880
+ function isPlainFile(path) {
881
+ try {
882
+ return statSync(path).isFile();
883
+ } catch {
884
+ return false;
885
+ }
886
+ }
887
+
888
+ /**
889
+ * Match a row against the fatal shape; returns `{ pkgName, fileSegments }` or
890
+ * undefined. Pure — no filesystem access — so preflight and the post-install
891
+ * audit share one definition of what counts.
892
+ *
893
+ * Path handling notes (issue #14 review): the dshHomePath literal may use
894
+ * backslashes on Windows, so it is normalized to `/` before matching and the
895
+ * profile-name segment compares case-insensitively there; and existsSync is
896
+ * NOT entry resolution (`node src/guard` resolves to src/guard.js) — so only
897
+ * paths whose last segment carries a runnable extension (.js/.mjs/.cjs) are
898
+ * judged at all, and a target that exists but is not a plain file counts as
899
+ * missing.
900
+ */
901
+ function fatalMcpEntry(row, profileName) {
902
+ if (row.name !== DSH_MCP_CLIENT_MODULE || row.state !== "enabled") return undefined;
903
+ const config = row.options?.config;
904
+ if (config === null || typeof config !== "object" || Array.isArray(config)) return undefined;
905
+ if (config.transport !== "stdio" || config.failOnStartupError !== true || config.command !== "node") return undefined;
906
+ const args = Array.isArray(config.args) ? config.args : [];
907
+ // 唯一参数才是入口。多个参数里混着 Node CLI 开关和业务路径(如
908
+ // --output dshHomePath('runtime/result.json')),逐一当入口查会把运行期
909
+ // 产物误判成缺失;不解析 Node CLI,多参行一律不判。
910
+ if (args.length !== 1 || !isJsExpr(args[0])) return undefined;
911
+ const call = DSH_HOME_PATH_EXACT.exec(String(args[0].__jsExpr ?? ""));
912
+ if (call === null) return undefined;
913
+ // JS 字符串字面量里的 Windows 分隔符写作 \\(求值后是单个 \);单独的 \
914
+ // 是转义序列(\w 之类),求值结果无法静态确定——含未成对反斜杠的形态
915
+ // 一律不判。成对的 \\ 直接折成 /。
916
+ const rawLiteral = call[2];
917
+ let soloBackslash = false;
918
+ const normalized = [];
919
+ for (let i = 0; i < rawLiteral.length; i++) {
920
+ const ch = rawLiteral[i];
921
+ if (ch === "\\") {
922
+ if (rawLiteral[i + 1] === "\\") {
923
+ normalized.push("/");
924
+ i++;
925
+ } else {
926
+ soloBackslash = true;
927
+ break;
928
+ }
929
+ } else {
930
+ normalized.push(ch);
931
+ }
932
+ }
933
+ if (soloBackslash) return undefined;
934
+ const segments = normalized.join("").split("/");
935
+ const fold = process.platform === "win32" ? (value) => value.toLowerCase() : (value) => value;
936
+ if (segments.length < 5) return undefined;
937
+ if (fold(segments[0]) !== "profiles" || fold(segments[1]) !== fold(profileName) || fold(segments[2]) !== "node_modules") return undefined;
938
+ const rest = segments.slice(3);
939
+ const pkgName = rest[0]?.startsWith("@") === true && rest.length > 1 ? `${rest[0]}/${rest[1]}` : rest[0];
940
+ if (typeof pkgName !== "string" || pkgName.length === 0 || !NPM_PACKAGE_NAME_RE.test(pkgName)) return undefined;
941
+ const fileSegments = rest.slice(pkgName.split("/").length);
942
+ if (fileSegments.length === 0 || fileSegments.some((part) => part.length === 0 || part === "." || part === "..")) return undefined;
943
+ if (!RUNNABLE_ENTRY_RE.test(fileSegments[fileSegments.length - 1])) return undefined;
944
+ return { pkgName, fileSegments };
945
+ }
946
+
947
+ /**
948
+ * Package-name equality with the platform's own semantics: Windows resolves
949
+ * node_modules case-insensitively, so a row written as node_modules/MCP-BRICK
950
+ * targets the same package as manifest name mcp-brick — comparing strictly
951
+ * there would let a missing entry slip past both preflight and the audit.
952
+ */
953
+ function samePackageName(left, right) {
954
+ return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
955
+ }
956
+
957
+ function mcpStartupFileIssues(mountedRows, candidateManifest, candidateName, candidateDir, profileDir) {
958
+ if (candidateName.length === 0 || typeof candidateDir !== "string") return [];
959
+ const profileName = basename(profileDir);
960
+ const hasInstallScripts = candidateMayBuildEntry(candidateManifest, candidateDir);
961
+ const issues = [];
962
+ for (const row of mountedRows) {
963
+ const fatal = fatalMcpEntry(row, profileName);
964
+ if (fatal === undefined || !samePackageName(fatal.pkgName, candidateName)) continue; // 只判候选自己的包:探针树里只有它
965
+ if (isPlainFile(join(candidateDir, ...fatal.fileSegments))) continue;
966
+ const fileRel = fatal.fileSegments.join("/");
967
+ if (hasInstallScripts) {
968
+ // 探装禁了 lifecycle 脚本,正式安装可能在用户审批后执行它们并生成
969
+ // 入口——静态「必缺」在这里不成立;硬保证移到装后终检。
970
+ issues.push(issue(
971
+ "warn",
972
+ "mcp-entry-unverifiable",
973
+ "入口缺失,但候选可能在构建后才生成它",
974
+ `行 ${row.id}(${DSH_MCP_CLIENT_MODULE})设置了 failOnStartupError: true 并以 node 启动 ${fileRel},探装(脚本禁用)里该文件不存在。候选带安装期脚本或构建清单(binding.gyp / .hooks),入口可能要等构建执行后才生成:预检不据此拦截,安装完成前会对照真树终检,仍缺失则回滚。`,
975
+ { row: row.id, file: fileRel },
976
+ ));
977
+ } else {
978
+ issues.push(issue(
979
+ "block",
980
+ "mcp-entry-missing",
981
+ "安装会使 dsh 无法启动",
982
+ `行 ${row.id}(${DSH_MCP_CLIENT_MODULE},stdio)设置了 failOnStartupError: true,并以 node 启动候选包内的 ${fileRel},但候选包里没有这个文件(未构建的源码安装的典型形态,且没有安装期脚本可能生成它)。安装后 dsh 会在装配阶段因该行失败而直接退出。`,
983
+ { row: row.id, file: fileRel },
984
+ ));
985
+ }
986
+ }
987
+ return issues;
988
+ }
989
+
990
+ /**
991
+ * Post-install audit (issue #14): the REAL profile tree, at the moment a
992
+ * successful install is about to commit. Scripts that were going to run have
993
+ * run (or were never declared); if the fatal entry is still not a plain file,
994
+ * committing would leave a profile that cannot start. The caller treats any
995
+ * returned issue as install failure so the outer handler rolls back.
996
+ *
997
+ * Only rows whose entry points into THE CANDIDATE's own package are judged —
998
+ * other packages' rows are not this install's doing, and the audit is not the
999
+ * place to condemn pre-existing profile state.
1000
+ */
1001
+ export function mcpEntryAuditForInstall({ profileDir, candidateName }) {
1002
+ if (typeof candidateName !== "string" || candidateName.length === 0 || typeof profileDir !== "string") return [];
1003
+ let current;
1004
+ try {
1005
+ current = installedProfile(profileDir, []);
1006
+ } catch {
1007
+ return []; // unreadable profile is somebody else's error to report
1008
+ }
1009
+ const profileName = basename(profileDir);
1010
+ const issues = [];
1011
+ for (const row of current.composedRows) {
1012
+ const fatal = fatalMcpEntry(row, profileName);
1013
+ if (fatal === undefined || !samePackageName(fatal.pkgName, candidateName)) continue;
1014
+ // Windows 文件系统本身不区分大小写,行里写 MCP-BRICK 也能落到同一目录。
1015
+ if (isPlainFile(join(profileDir, "node_modules", ...fatal.pkgName.split("/"), ...fatal.fileSegments))) continue;
1016
+ issues.push(issue(
1017
+ "block",
1018
+ "mcp-entry-missing",
1019
+ "安装完成但入口仍缺失",
1020
+ `行 ${row.id}(${DSH_MCP_CLIENT_MODULE})以 failOnStartupError: true 启动 ${fatal.fileSegments.join("/")},安装完成后该文件仍不存在——提交这个 profile 会让 dsh 在装配阶段退出。`,
1021
+ { row: row.id, file: fatal.fileSegments.join("/") },
1022
+ ));
1023
+ }
1024
+ return issues;
1025
+ }
814
1026
  /** How a pair of projected rows would mount together. */
815
1027
  function bothMount(left, right) {
816
1028
  const states = [left.state, right.state];
@@ -1310,6 +1522,11 @@ export function inspectCandidate({ profileDir, candidateManifestPath, spec }) {
1310
1522
  issues.push(...namelessRowIssues(simulated.mounted.filter((row) => row.owner === candidateName), candidateName || spec));
1311
1523
  issues.push(...newConflictIssues(candidateName, current.composedRows, simulated.mounted));
1312
1524
  issues.push(...patchTargetIssues(candidateName, candidate.manifest, patch, current, simulated));
1525
+ // 落盘后的文件树检查只能在这里做:探针树里候选包已实体化,MCP 入口存不
1526
+ // 存在是可判定的(带安装期脚本的候选降为 warn,硬保证在装后终检——见
1527
+ // mcpStartupFileIssues 的注释)。浏览期扫描(inspectRemoteCandidate)
1528
+ // 没有文件树,不参与。
1529
+ issues.push(...mcpStartupFileIssues(simulated.mounted, candidate.manifest, candidateName, candidate.dir, profileDir));
1313
1530
 
1314
1531
  // UI replacements historically predate exclusiveGroups. Keep the
1315
1532
  // heuristic advisory-only to avoid blocking legitimate sidebar extensions.
@@ -3368,6 +3585,279 @@ async function selfTest() {
3368
3585
  if (validateInstalledProfile(p).ok !== true) throw new Error("healthy profile should validate clean");
3369
3586
  }
3370
3587
 
3588
+ // ── MCP failOnStartupError 指向缺失文件(issue #14)────────────────────
3589
+ //
3590
+ // 事故形态(2026-08-22,managed-agents):候选从 github: 源装下来没有
3591
+ // dist/,insert 的 MCP 行 failOnStartupError: true 指向不存在的入口,
3592
+ // 装上即砖、dsh 装配阶段退出。判定只认可证形状,扫的是**最终组合行**:
3593
+ // 用户 patch 停用或改参可豁免,候选改坏既有 MCP 行同样抓得到。
3594
+ {
3595
+ const p = join(root, "profiles", "mcpcheck");
3596
+ mkdirSync(p, { recursive: true });
3597
+ const resetProfile = (userPatch) => {
3598
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: {} }));
3599
+ writeFileSync(join(p, "cordis.patch.yml"), userPatch ?? "[]\n");
3600
+ };
3601
+ const mcpInsert = (file, extra = {}) => `
3602
+ - insert:
3603
+ - id: mcp-x
3604
+ name: '@deepseek-ai/dsh-mcp-client'
3605
+ config:
3606
+ serverName: x
3607
+ transport: stdio
3608
+ command: node
3609
+ args:
3610
+ - !!js dshHomePath('profiles/mcpcheck/node_modules/mcp-brick/${file}')
3611
+ failOnStartupError: true
3612
+ `.replace("failOnStartupError: true", `failOnStartupError: ${extra.fatal ?? true}`)
3613
+ + (extra.append ?? "");
3614
+ const candidate = (label, pkgName, patchText) => {
3615
+ const dir = join(root, `cand-${label}`);
3616
+ mkdirSync(dir, { recursive: true });
3617
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: pkgName, version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
3618
+ writeFileSync(join(dir, "cordis.patch.yml"), patchText);
3619
+ return dir;
3620
+ };
3621
+ const inspect = (dir, name, userPatch) => {
3622
+ resetProfile(userPatch);
3623
+ return inspectCandidate({ profileDir: p, candidateManifestPath: join(dir, "package.json"), spec: name });
3624
+ };
3625
+
3626
+ // 1) 入口缺失 → block(事故原样:无 dist 的源码树)。
3627
+ const missing = inspect(candidate("missing", "mcp-brick", mcpInsert("dist/mcp/index.js")), "mcp-brick");
3628
+ if (missing.verdict !== "blocked" || !missing.issues.some((e) => e.code === "mcp-entry-missing")) {
3629
+ throw new Error(`MCP 入口缺失必须 block,实得 ${missing.verdict}: ${JSON.stringify(missing.issues.map((e) => e.code))}`);
3630
+ }
3631
+ console.log("PASS MCP 入口:缺失 + failOnStartupError → block");
3632
+
3633
+ // 2) 入口存在 → 放行。
3634
+ const presentDir = candidate("present", "mcp-brick", mcpInsert("dist/mcp/index.js"));
3635
+ mkdirSync(join(presentDir, "dist", "mcp"), { recursive: true });
3636
+ writeFileSync(join(presentDir, "dist", "mcp", "index.js"), "");
3637
+ const present = inspect(presentDir, "mcp-brick");
3638
+ if (present.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("入口存在时不得报 mcp-entry-missing");
3639
+ console.log("PASS MCP 入口:文件存在 → 放行");
3640
+
3641
+ // 3) failOnStartupError: false → 不判(失败不致命,装上也不会砖)。
3642
+ const lenient = inspect(candidate("lenient", "mcp-brick", mcpInsert("dist/none.js", { fatal: false })), "mcp-brick");
3643
+ if (lenient.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("failOnStartupError:false 不得判入口");
3644
+ console.log("PASS MCP 入口:failOnStartupError:false → 放行");
3645
+
3646
+ // 4) 用户 patch 停用该行 → 最终行不运行,不判。
3647
+ const disabledRow = inspect(candidate("disabled", "mcp-brick", mcpInsert("dist/none.js")), "mcp-brick", "- id: mcp-x\n disabled: true\n");
3648
+ if (disabledRow.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("用户停用的行不得判入口");
3649
+ console.log("PASS MCP 入口:用户 patch 停用 → 放行");
3650
+
3651
+ // 5) 用户 patch 修正 args 指向存在的文件 → 放行。
3652
+ const fixDir = candidate("fixed", "mcp-brick", mcpInsert("dist/none.js"));
3653
+ mkdirSync(join(fixDir, "dist"), { recursive: true });
3654
+ writeFileSync(join(fixDir, "dist", "fixed.js"), "");
3655
+ const fixed = inspect(fixDir, "mcp-brick", `
3656
+ - id: mcp-x
3657
+ config:
3658
+ serverName: x
3659
+ transport: stdio
3660
+ command: node
3661
+ args:
3662
+ - !!js dshHomePath('profiles/mcpcheck/node_modules/mcp-brick/dist/fixed.js')
3663
+ failOnStartupError: true
3664
+ `);
3665
+ if (fixed.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("用户已把 args 修到存在的文件,不得再判");
3666
+ console.log("PASS MCP 入口:用户 patch 修正 args → 放行");
3667
+
3668
+ // 6) 非精确 !!js 表达式(拼接)→ 求值前无从判断,不判。
3669
+ const inexact = inspect(candidate("inexact", "mcp-brick", `
3670
+ - insert:
3671
+ - id: mcp-x
3672
+ name: '@deepseek-ai/dsh-mcp-client'
3673
+ config:
3674
+ transport: stdio
3675
+ command: node
3676
+ args:
3677
+ - !!js dshHomePath('profiles/mcpcheck/node_modules/' + 'mcp-brick/dist/none.js')
3678
+ failOnStartupError: true
3679
+ `), "mcp-brick");
3680
+ if (inexact.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("拼接表达式不得判入口");
3681
+ console.log("PASS MCP 入口:非精确 !!js 表达式 → 不误判");
3682
+
3683
+ // 7) 候选改坏既有 MCP 行(override)→ 同样检出。行是 existing-host
3684
+ // 插的、原本不致命;候选把 config 整个换成指向自己包内缺失文件
3685
+ // 且 failOnStartupError: true —— 只看 insert 会漏掉这一种。
3686
+ resetProfile();
3687
+ // 真实形态:已装插件同时在 dependencies 和 dsh.profile.bundles 里
3688
+ // (bundleLayers 只从 bundles 列表构建,光有依赖不成层)。
3689
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { "existing-host": "1.0.0" }, dsh: { profile: { bundles: ["existing-host"] } } }));
3690
+ mkdirSync(join(p, "node_modules", "existing-host"), { recursive: true });
3691
+ writeFileSync(join(p, "node_modules", "existing-host", "package.json"), JSON.stringify({ name: "existing-host", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
3692
+ writeFileSync(join(p, "node_modules", "existing-host", "cordis.patch.yml"), `
3693
+ - insert:
3694
+ - id: mcp-existing
3695
+ name: '@deepseek-ai/dsh-mcp-client'
3696
+ config:
3697
+ transport: stdio
3698
+ command: node
3699
+ args:
3700
+ - !!js dshHomePath('profiles/mcpcheck/node_modules/existing-host/dist/mcp.js')
3701
+ failOnStartupError: false
3702
+ `);
3703
+ const breaker = inspectCandidate({ profileDir: p, candidateManifestPath: join(candidate("breaker", "mcp-brick", `
3704
+ - id: mcp-existing
3705
+ config:
3706
+ transport: stdio
3707
+ command: node
3708
+ args:
3709
+ - !!js dshHomePath('profiles/mcpcheck/node_modules/mcp-brick/dist/none.js')
3710
+ failOnStartupError: true
3711
+ `), "package.json"), spec: "mcp-brick" });
3712
+ if (!breaker.issues.some((e) => e.code === "mcp-entry-missing")) {
3713
+ throw new Error(`候选 override 既有 MCP 行改坏必须检出,实得 ${JSON.stringify(breaker.issues.map((e) => e.code))}`);
3714
+ }
3715
+ console.log("PASS MCP 入口:候选 override 既有行改坏 → 检出");
3716
+
3717
+ // 8) scoped 候选包:路径前缀含 scope 段,同样判得到。
3718
+ const scopedDir = candidate("scoped", "@scope/mcp-pkg", `
3719
+ - insert:
3720
+ - id: mcp-x
3721
+ name: '@deepseek-ai/dsh-mcp-client'
3722
+ config:
3723
+ transport: stdio
3724
+ command: node
3725
+ args:
3726
+ - !!js dshHomePath('profiles/mcpcheck/node_modules/@scope/mcp-pkg/dist/none.js')
3727
+ failOnStartupError: true
3728
+ `);
3729
+ resetProfile();
3730
+ const scopedRep = inspectCandidate({ profileDir: p, candidateManifestPath: join(scopedDir, "package.json"), spec: "@scope/mcp-pkg" });
3731
+ if (!scopedRep.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("scoped 候选的缺失入口必须检出");
3732
+ console.log("PASS MCP 入口:scoped 包路径 → 检出");
3733
+
3734
+ // 9) 多参数:唯一参数才是入口,业务路径不参与判定(review P1)。业务
3735
+ // 路径故意用 .js 后缀——过得了扩展名闸门,钉的就是「唯一参数」这
3736
+ // 条规则本身,不是被扩展名顺带挡住的。
3737
+ const multiArg = inspect(candidate("multiarg", "mcp-brick", `
3738
+ - insert:
3739
+ - id: mcp-x
3740
+ name: '@deepseek-ai/dsh-mcp-client'
3741
+ config:
3742
+ transport: stdio
3743
+ command: node
3744
+ args:
3745
+ - server.js
3746
+ - '--output'
3747
+ - !!js dshHomePath('profiles/mcpcheck/node_modules/mcp-brick/runtime/out.js')
3748
+ failOnStartupError: true
3749
+ `), "mcp-brick");
3750
+ if (multiArg.issues.some((e) => e.code === "mcp-entry-missing" || e.code === "mcp-entry-unverifiable")) {
3751
+ throw new Error("多参数行的业务路径不得当入口判");
3752
+ }
3753
+ console.log("PASS MCP 入口:多参数(业务路径)→ 不判");
3754
+
3755
+ // 10) 无扩展名入口:node 会做扩展名/目录解析,existsSync 不等价——不判。
3756
+ const extless = inspect(candidate("extless", "mcp-brick", mcpInsert("dist/mcp/entry")), "mcp-brick");
3757
+ if (extless.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("无扩展名路径不得判入口");
3758
+ console.log("PASS MCP 入口:无扩展名 → 不判");
3759
+
3760
+ // 11) 路径存在但是目录(名字带 .js):不是普通文件,按缺失论。
3761
+ const dirDir = candidate("dir-entry", "mcp-brick", mcpInsert("dist/index.js"));
3762
+ mkdirSync(join(dirDir, "dist", "index.js"), { recursive: true }); // 目录!
3763
+ const dirEntry = inspect(dirDir, "mcp-brick");
3764
+ if (!dirEntry.issues.some((e) => e.code === "mcp-entry-missing")) throw new Error("目录顶替入口文件必须 block");
3765
+ console.log("PASS MCP 入口:路径是目录(非普通文件)→ block");
3766
+
3767
+ // 12) 候选带 postinstall + 入口缺失 → 降为 warn(装后终检把关),不 block。
3768
+ const scriptedDir = candidate("scripted", "mcp-brick", mcpInsert("dist/mcp/index.js"));
3769
+ writeFileSync(join(scriptedDir, "package.json"), JSON.stringify({ name: "mcp-brick", version: "1.0.0", scripts: { postinstall: "node build.js" }, dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
3770
+ const scripted = inspect(scriptedDir, "mcp-brick");
3771
+ const unverifiable = scripted.issues.find((e) => e.code === "mcp-entry-unverifiable");
3772
+ if (scripted.verdict !== "warning" || unverifiable === undefined || scripted.issues.some((e) => e.code === "mcp-entry-missing")) {
3773
+ throw new Error(`带安装期脚本的候选应降为 warn(终检把关),实得 ${scripted.verdict}: ${JSON.stringify(scripted.issues.map((e) => e.code))}`);
3774
+ }
3775
+ console.log("PASS MCP 入口:候选带 postinstall → warn 不 block(终检把关)");
3776
+
3777
+ // 13) 反斜杠路径:JS 字面量里的真实写法是 \\(求值后单个 \)——归一
3778
+ // 后同样检出。单个不成对的 \ 是转义序列(\w 之类),求值结果无
3779
+ // 从静态确定,不判。
3780
+ const backslash = inspect(candidate("backslash", "mcp-brick", `
3781
+ - insert:
3782
+ - id: mcp-x
3783
+ name: '@deepseek-ai/dsh-mcp-client'
3784
+ config:
3785
+ transport: stdio
3786
+ command: node
3787
+ args:
3788
+ - !!js dshHomePath('profiles\\\\mcpcheck\\\\node_modules\\\\mcp-brick\\\\dist\\\\none.js')
3789
+ failOnStartupError: true
3790
+ `), "mcp-brick");
3791
+ if (!backslash.issues.some((e) => e.code === "mcp-entry-missing")) {
3792
+ throw new Error(`反斜杠路径(\\\\ 形式)应归一检出,实得 ${JSON.stringify(backslash.issues.map((e) => e.code))}`);
3793
+ }
3794
+ console.log("PASS MCP 入口:反斜杠路径(\\\\ 形式)归一 → 检出");
3795
+ const soloBackslash = inspect(candidate("solo-backslash", "mcp-brick", `
3796
+ - insert:
3797
+ - id: mcp-x
3798
+ name: '@deepseek-ai/dsh-mcp-client'
3799
+ config:
3800
+ transport: stdio
3801
+ command: node
3802
+ args:
3803
+ - !!js dshHomePath('profiles\\mcpcheck\\node_modules\\mcp-brick\\dist\\none.js')
3804
+ failOnStartupError: true
3805
+ `), "mcp-brick");
3806
+ if (soloBackslash.issues.some((e) => e.code === "mcp-entry-missing" || e.code === "mcp-entry-unverifiable")) {
3807
+ throw new Error("不成对的单个反斜杠(转义序列)不得判");
3808
+ }
3809
+ console.log("PASS MCP 入口:单个反斜杠(转义序列)→ 不判");
3810
+
3811
+ // 13b) binding.gyp(无任何 scripts):pnpm 的 requiresBuild 同样视为
3812
+ // 需构建——入口可能由 node-gyp 链路产出,静态只 warn。
3813
+ const gypDir = candidate("gyp", "mcp-brick", mcpInsert("dist/mcp/index.js"));
3814
+ writeFileSync(join(gypDir, "binding.gyp"), "{}\n");
3815
+ const gyp = inspect(gypDir, "mcp-brick");
3816
+ if (!gyp.issues.some((e) => e.code === "mcp-entry-unverifiable") || gyp.issues.some((e) => e.code === "mcp-entry-missing")) {
3817
+ throw new Error(`binding.gyp 候选应降为 warn,实得 ${gyp.verdict}: ${JSON.stringify(gyp.issues.map((e) => e.code))}`);
3818
+ }
3819
+ console.log("PASS MCP 入口:binding.gyp(无 scripts)→ warn 不 block");
3820
+
3821
+ // 13c) 包名大小写变体:manifest 是 mcp-brick,行里写 MCP-BRICK。
3822
+ // win32 上 node_modules 解析不区分大小写——同一个包,照判;
3823
+ // posix 上那是另一个名字,不判。两平台各自的判定才是正确语义。
3824
+ const cased = inspect(candidate("cased", "mcp-brick", `
3825
+ - insert:
3826
+ - id: mcp-x
3827
+ name: '@deepseek-ai/dsh-mcp-client'
3828
+ config:
3829
+ transport: stdio
3830
+ command: node
3831
+ args:
3832
+ - !!js dshHomePath('profiles/mcpcheck/node_modules/MCP-BRICK/dist/none.js')
3833
+ failOnStartupError: true
3834
+ `), "mcp-brick");
3835
+ const casedBlocked = cased.issues.some((e) => e.code === "mcp-entry-missing");
3836
+ if (process.platform === "win32" ? !casedBlocked : casedBlocked) {
3837
+ throw new Error(`包名大小写变体在 ${process.platform} 上判定错误: ${JSON.stringify(cased.issues.map((e) => e.code))}`);
3838
+ }
3839
+ console.log(`PASS MCP 入口:包名大小写变体按平台语义判定(${process.platform} → ${casedBlocked ? "检出" : "不判"})`);
3840
+
3841
+ // 14) 装后终检:真树里入口仍缺 → issue;存在 → 空;别人的包 → 不判。
3842
+ {
3843
+ resetProfile();
3844
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { "mcp-brick": "1.0.0" }, dsh: { profile: { bundles: ["mcp-brick"] } } }));
3845
+ const installedCand = join(p, "node_modules", "mcp-brick");
3846
+ mkdirSync(installedCand, { recursive: true });
3847
+ writeFileSync(join(installedCand, "package.json"), JSON.stringify({ name: "mcp-brick", version: "1.0.0", dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
3848
+ writeFileSync(join(installedCand, "cordis.patch.yml"), mcpInsert("dist/mcp/index.js"));
3849
+ const auditMissing = mcpEntryAuditForInstall({ profileDir: p, candidateName: "mcp-brick" });
3850
+ if (auditMissing.length !== 1 || auditMissing[0].code !== "mcp-entry-missing") throw new Error("终检:入口缺失必须报");
3851
+ mkdirSync(join(installedCand, "dist", "mcp"), { recursive: true });
3852
+ writeFileSync(join(installedCand, "dist", "mcp", "index.js"), "");
3853
+ if (mcpEntryAuditForInstall({ profileDir: p, candidateName: "mcp-brick" }).length !== 0) throw new Error("终检:入口存在不得报");
3854
+ if (mcpEntryAuditForInstall({ profileDir: p, candidateName: "someone-else" }).length !== 0) throw new Error("终检:别人的包不归这次安装判");
3855
+ console.log("PASS MCP 入口:装后终检(缺→报、有→过、他人包→不判)");
3856
+ }
3857
+
3858
+ resetProfile();
3859
+ }
3860
+
3371
3861
  // validateInstalledProfile detects a loader-id collision between two
3372
3862
  // already-installed bundles (the failure mode a bad install introduces).
3373
3863
  {
@@ -4789,4 +5279,4 @@ if (process.argv[1]?.endsWith("guard.js") && process.argv.includes("--self-test"
4789
5279
  console.error(`FAIL ${error.stack ?? error.message}`);
4790
5280
  process.exitCode = 1;
4791
5281
  });
4792
- }
5282
+ }
package/src/installer.js CHANGED
@@ -16,7 +16,7 @@ import { createHash } from "node:crypto";
16
16
  import { dump, load } from "js-yaml";
17
17
  import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
18
18
  import { describeBuildScripts, npmNameOf } from "./github.js";
19
- import { clearPendingApprovalPause, commitPendingSnapshot, createProfileSnapshot, describeRollbackRebuild, markPendingApprovalPause, markPendingSnapshot, pausedCandidateBeforeState, pendingApprovalPaused, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot, validateInstalledProfile, validateRemoveCompletion } from "./guard.js";
19
+ import { clearPendingApprovalPause, commitPendingSnapshot, createProfileSnapshot, describeRollbackRebuild, markPendingApprovalPause, markPendingSnapshot, mcpEntryAuditForInstall, pausedCandidateBeforeState, pendingApprovalPaused, pnpmGuardEnv, pnpmSpawnPlan, readValidatedPendingSnapshot, rollbackPendingSnapshot, validateInstalledProfile, validateRemoveCompletion } from "./guard.js";
20
20
 
21
21
  // ── spec normalization ──────────────────────────────────────────────────────
22
22
 
@@ -1811,6 +1811,18 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1811
1811
  /** Post-success accounting: reconcile bundles, register client rows, summarize. */
1812
1812
  const finalizeSuccess = () => {
1813
1813
  const bundles = reconcileBundles(profileDir, beforeDeps);
1814
+ // 装后终检(issue #14):预检的探装禁了 lifecycle 脚本,正式安装可能
1815
+ // 在用户审批后执行了它们——入口是否真的产生,只有此刻这棵真树说了算
1816
+ // (预检对带安装期脚本的候选只给了 warn)。此刻构建要么跑完要么本来
1817
+ // 就没有,终检不过就按失败结算,外层回滚到装前快照。
1818
+ const auditIssues = mcpEntryAuditForInstall({ profileDir, candidateName: preflight?.candidate?.name ?? npmNameOf(spec) });
1819
+ if (auditIssues.length > 0) {
1820
+ push(`\n[dsh-plugin-mall] ${auditIssues[0].title}: ${auditIssues[0].detail}\n`);
1821
+ // 只描述失败,不预言恢复:回滚此刻还没发生(在外层收尾里),成没成
1822
+ // 由它自己的三态结论去说。在这里写「已回滚」,回滚失败时 detail 会
1823
+ // 同时出现「已回滚」和「无法回滚」两句打架的话。
1824
+ return { status: "failed", detail: `pnpm installed ${spec}, but ${auditIssues[0].title}: 行 ${auditIssues[0].row ?? auditIssues[0].extra?.row} 的入口 ${auditIssues[0].file ?? auditIssues[0].extra?.file} 仍缺失,安装按失败结算。` };
1825
+ }
1814
1826
  const manifest = readManifest(profileDir);
1815
1827
  const currentDeps = new Set(Object.keys(manifest.dependencies ?? {}));
1816
1828
  const clientRows = [];
@@ -3665,6 +3677,94 @@ async function runTransactionFixtures() {
3665
3677
  }
3666
3678
  }
3667
3679
 
3680
+ // 4c. 装后终检(issue #14):预检的探装禁了 lifecycle 脚本,带安装期脚本
3681
+ // 的候选入口要等构建后才可能出现——预检对它们只 warn,这里是硬闸。
3682
+ // pnpm 成功、入口在真树里仍缺 → failed + 回滚(绝不提交一块砖);
3683
+ // 构建产出了入口 → completed。beforeExit 模拟 pnpm add 的落盘效果
3684
+ // (node_modules 实体化 + manifest 依赖写入)。
3685
+ {
3686
+ const fatalPatch = `
3687
+ - insert:
3688
+ - id: mcp-x
3689
+ name: '@deepseek-ai/dsh-mcp-client'
3690
+ config:
3691
+ transport: stdio
3692
+ command: node
3693
+ args:
3694
+ - !!js dshHomePath('profiles/p/node_modules/mcp-brick-pkg/dist/mcp/index.js')
3695
+ failOnStartupError: true
3696
+ `;
3697
+ const materializeCandidate = (profileDir, withEntry) => {
3698
+ const dir = join(profileDir, "node_modules", "mcp-brick-pkg");
3699
+ mkdirSync(dir, { recursive: true });
3700
+ if (withEntry) {
3701
+ mkdirSync(join(dir, "dist", "mcp"), { recursive: true });
3702
+ writeFileSync(join(dir, "dist", "mcp", "index.js"), "");
3703
+ }
3704
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "mcp-brick-pkg", version: "1.0.0", scripts: { postinstall: "node build.js" }, dsh: { bundle: { patch: "./cordis.patch.yml" } } }));
3705
+ writeFileSync(join(dir, "cordis.patch.yml"), fatalPatch);
3706
+ const manifest = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
3707
+ manifest.dependencies = { ...(manifest.dependencies ?? {}), "mcp-brick-pkg": "1.0.0" };
3708
+ writeFileSync(join(profileDir, "package.json"), JSON.stringify(manifest, undefined, 2) + "\n");
3709
+ };
3710
+ const runAuditInstall = (profileDir, withEntry) => {
3711
+ const spawn = scriptedSpawn([{ code: 0, out: "Done in 1s\n", beforeExit: () => materializeCandidate(profileDir, withEntry) }]);
3712
+ return runInstall({ profile: "p", spec: "mcp-brick-pkg", preflight: preflightStub("mcp-brick-pkg"), _profileDir: profileDir, _spawn: spawn.spawnFn, _describe: async () => [] }).done;
3713
+ };
3714
+
3715
+ {
3716
+ const { profileDir, cleanup } = makeTempProfile("audit-missing");
3717
+ try {
3718
+ const outcome = await runAuditInstall(profileDir, false);
3719
+ const depGone = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8")).dependencies?.["mcp-brick-pkg"] === undefined;
3720
+ check(
3721
+ "装后终检:入口仍缺 → failed 且回滚(不提交砖)",
3722
+ outcome.status === "failed" && /入口/.test(outcome.detail ?? "") && depGone && !existsSync(pendingMarkerPath(profileDir)),
3723
+ `status=${outcome.status} detail=${(outcome.detail ?? "").slice(0, 120)}`,
3724
+ );
3725
+ } finally {
3726
+ cleanup();
3727
+ }
3728
+ }
3729
+ {
3730
+ const { profileDir, cleanup } = makeTempProfile("audit-present");
3731
+ try {
3732
+ const outcome = await runAuditInstall(profileDir, true);
3733
+ check("装后终检:构建产出了入口 → completed", outcome.status === "completed", `status=${outcome.status} detail=${(outcome.detail ?? "").slice(0, 120)}`);
3734
+ } finally {
3735
+ cleanup();
3736
+ }
3737
+ }
3738
+
3739
+ // 终检失败 + 回滚做不成(marker 被外部抽掉):detail 绝不能同时出现
3740
+ // 「已回滚」和「无法回滚」——终检文案只说失败,恢复结论由外层三态拼。
3741
+ {
3742
+ const { profileDir, cleanup } = makeTempProfile("audit-rollback-fails");
3743
+ try {
3744
+ const spawn = scriptedSpawn([{
3745
+ code: 0,
3746
+ out: "Done in 1s\n",
3747
+ beforeExit: () => {
3748
+ materializeCandidate(profileDir, false);
3749
+ rmSync(pendingMarkerPath(profileDir), { force: true }); // 抽掉还原目标
3750
+ },
3751
+ }]);
3752
+ const outcome = await runInstall({ profile: "p", spec: "mcp-brick-pkg", preflight: preflightStub("mcp-brick-pkg"), _profileDir: profileDir, _spawn: spawn.spawnFn, _describe: async () => [] }).done;
3753
+ const detail = outcome.detail ?? "";
3754
+ check(
3755
+ "终检失败 + 回滚失败 → 不预言恢复,failed 且指名 marker",
3756
+ outcome.status === "failed"
3757
+ && /仍缺失/.test(detail)
3758
+ && /could NOT be restored|marker/.test(detail)
3759
+ && !/has been rolled back/.test(detail),
3760
+ `status=${outcome.status} detail=${detail.slice(0, 160)}`,
3761
+ );
3762
+ } finally {
3763
+ cleanup();
3764
+ }
3765
+ }
3766
+ }
3767
+
3668
3768
  // 4b-2. 取消了,但回滚做不成。marker 被外部删掉(外部清理、磁盘故障、
3669
3769
  // 或本轮压根没登记上),rollbackPendingSnapshot 返回 undefined 而不抛。
3670
3770
  //