@1e0zj/dsh-plugin-mall 0.4.12 → 0.4.15

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/src/installer.js CHANGED
@@ -16,7 +16,8 @@ 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, mcpEntryAuditForInstall, 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, validatePendingProfile, validateRemoveCompletion } from "./guard.js";
20
+ import { stripTerminalControlSequences } from "./terminal.js";
20
21
 
21
22
  // ── spec normalization ──────────────────────────────────────────────────────
22
23
 
@@ -630,12 +631,20 @@ const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
630
631
  */
631
632
  function parseIgnoredBuilds(output) {
632
633
  const found = new Map();
634
+ // pnpm enables colours even though stdout/stderr are pipes on some Windows
635
+ // setups (observed with pnpm 11). A reset code after the selector makes the
636
+ // anchored `@version` parser miss, so `node-pty@1.1.0\x1b[39m` used to be
637
+ // treated as an invalid package name and the approval pause became an
638
+ // ordinary failed install. Strip CSI terminal controls again here as a
639
+ // fail-closed parsing boundary; the stream capture already removes them from
640
+ // the plain-text job log shown to users.
641
+ const plainOutput = stripTerminalControlSequences(output);
633
642
  // Only pnpm's own notice line is a parsing source. "allowBuilds" also
634
643
  // appears in pnpm's advice/error text (never followed by a name list), and
635
644
  // matching it fed error echoes into the allow-list, corrupting the YAML.
636
645
  const pattern = /(?:Ignored build scripts|onlyBuiltDependencies)\s*:\s*([^\n]+)/gi;
637
646
  let match;
638
- while ((match = pattern.exec(output)) !== null) {
647
+ while ((match = pattern.exec(plainOutput)) !== null) {
639
648
  for (const raw of match[1].split(",")) {
640
649
  const candidate = raw.trim();
641
650
  if (candidate.length === 0) continue;
@@ -757,6 +766,169 @@ function splitBuildSelector(key) {
757
766
  return NPM_NAME_RE.test(name) ? { name, spec: text.slice(suffix.index + 1) } : undefined;
758
767
  }
759
768
 
769
+ /**
770
+ * Workspace keys pnpm writes durably on its own during an install. Everything
771
+ * else the transaction touches in pnpm-workspace.yaml is our temporary
772
+ * `allowBuilds` neutralization, which the post-install restore is supposed to
773
+ * wipe.
774
+ */
775
+ const PERSISTENT_WORKSPACE_KEYS = ["minimumReleaseAgeExclude"];
776
+
777
+ /**
778
+ * Match ONE top-level `key:` line, in every legal YAML spelling of the key:
779
+ * bare, single-quoted, or double-quoted. Key-locating that only knows the
780
+ * bare form misreads a quoted original as "key absent" and appends a
781
+ * duplicate mapping key — silently corrupting the file.
782
+ */
783
+ function topLevelKeyRegex(key) {
784
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
785
+ return new RegExp(`^(?:${escaped}|'${escaped}'|"${escaped}")\\s*:`);
786
+ }
787
+
788
+ /**
789
+ * Splice one top-level key's value into a workspace yaml, textually.
790
+ *
791
+ * The value is re-serialized, but the surrounding file keeps its bytes:
792
+ * comments, key order, and unrelated keys are the user's, and the restore
793
+ * must not cost them their formatting (same discipline as the patch-file
794
+ * edits).
795
+ */
796
+ function spliceWorkspaceKey(content, key, value) {
797
+ const block = dump({ [key]: value }, { lineWidth: -1, noRefs: true, sortKeys: false }).replace(/\n+$/, "");
798
+ const keyLine = topLevelKeyRegex(key);
799
+ const lines = content.split("\n");
800
+ const start = lines.findIndex((line) => keyLine.test(line));
801
+ if (start === -1) {
802
+ return `${content.replace(/\n*$/, "\n")}${block}\n`;
803
+ }
804
+ // The key's block runs until the next top-level line (or EOF); indented
805
+ // entries, indented comments, and blank lines in between all belong to it.
806
+ let end = start + 1;
807
+ while (end < lines.length && !/^\S/.test(lines[end])) end += 1;
808
+ const blockLines = block.split("\n");
809
+ // Keep the user's own spelling of the key line (quoted or bare) when it
810
+ // carries no inline value; only the entries below it are re-serialized.
811
+ if (lines[start].replace(keyLine, "").trim() === "") blockLines[0] = lines[start].replace(/\s+$/, "");
812
+ lines.splice(start, end - start, ...blockLines);
813
+ return `${lines.join("\n").replace(/\n+$/, "")}\n`;
814
+ }
815
+
816
+ /**
817
+ * Extract the persistent workspace keys from a workspace yaml as a plain
818
+ * `{key: value}` object. Used to snapshot pnpm's own writes at a trusted
819
+ * moment — see the approval branch in runInstallInner for why the on-disk
820
+ * state after approved scripts ran is NOT trusted.
821
+ */
822
+ function extractPersistentWrites(content) {
823
+ const text = Buffer.isBuffer(content) ? content.toString("utf8") : content;
824
+ if (typeof text !== "string" || text.length === 0) return {};
825
+ let doc;
826
+ try {
827
+ doc = load(text) ?? {};
828
+ } catch {
829
+ return {};
830
+ }
831
+ const writes = {};
832
+ for (const key of PERSISTENT_WORKSPACE_KEYS) {
833
+ if (doc[key] !== undefined) writes[key] = doc[key];
834
+ }
835
+ return writes;
836
+ }
837
+
838
+ /**
839
+ * Merge captured persistent workspace writes into the user's pre-install
840
+ * bytes. {@link mergePersistentWorkspaceWrites} for the full story; this is
841
+ * the core that takes the writes as an object instead of a disk snapshot.
842
+ *
843
+ * Failing loudly by contract: when pnpm DID record something to merge but the
844
+ * merged text would not parse, or the spliced key does not come out exactly
845
+ * once with exactly the values requested, this THROWS. The caller
846
+ * (restoreOriginalWorkspace) turns that into a failed install with rollback.
847
+ * Silently returning the original bytes instead would report success while
848
+ * dropping the exemption — re-arming the lockfile policy against the very
849
+ * version this install just put on disk, which is the exact incident this
850
+ * merge exists to prevent. "Completed" must imply the exemption state is
851
+ * consistent with the lockfile.
852
+ */
853
+ function applyPersistentWrites(writes, originalContent) {
854
+ const original = Buffer.isBuffer(originalContent) ? originalContent.toString("utf8") : originalContent;
855
+ if (writes === null || typeof writes !== "object") {
856
+ throw new Error("persistent workspace writes must be an object");
857
+ }
858
+ const base = typeof original === "string" ? original : DEFAULT_WORKSPACE_YAML;
859
+ let baseDoc;
860
+ try {
861
+ baseDoc = load(base) ?? {};
862
+ } catch (error) {
863
+ throw new Error(`cannot merge the release-age exemption pnpm recorded: pnpm-workspace.yaml does not parse (${error.message})`);
864
+ }
865
+ let merged = base;
866
+ let changed = false;
867
+ for (const key of PERSISTENT_WORKSPACE_KEYS) {
868
+ const value = writes[key];
869
+ if (value === undefined) continue;
870
+ if (JSON.stringify(baseDoc[key]) === JSON.stringify(value)) continue;
871
+ merged = spliceWorkspaceKey(merged, key, value);
872
+ changed = true;
873
+ }
874
+ if (!changed) return original;
875
+ try {
876
+ const reparsed = load(merged) ?? {};
877
+ for (const key of PERSISTENT_WORKSPACE_KEYS) {
878
+ if (writes[key] === undefined) continue;
879
+ // The key must appear exactly once at top level (an unrecognized
880
+ // spelling in the original would otherwise leave a duplicate behind)
881
+ // and must parse back to the exact values being merged.
882
+ const occurrences = merged.split("\n").filter((line) => topLevelKeyRegex(key).test(line)).length;
883
+ if (occurrences !== 1) throw new Error("the key does not appear exactly once after the merge");
884
+ if (JSON.stringify(reparsed[key]) !== JSON.stringify(writes[key])) throw new Error("the merged value does not round-trip");
885
+ }
886
+ } catch (error) {
887
+ throw new Error(`cannot merge the release-age exemption pnpm recorded into pnpm-workspace.yaml (${error.message}) — the file uses a YAML spelling this merge does not recognize; simplify the minimumReleaseAgeExclude entry and retry`);
888
+ }
889
+ return merged;
890
+ }
891
+
892
+ /**
893
+ * Merge pnpm's own persisted workspace writes back into the user's pre-install
894
+ * bytes on the success path of an install.
895
+ *
896
+ * `pnpm add pkg@version` with the version inside the minimumReleaseAge window
897
+ * goes through and pnpm records an auto-exemption in
898
+ * `minimumReleaseAgeExclude` — a durable write to pnpm-workspace.yaml, not to
899
+ * the lockfile. The byte-restore that undoes our temporary `allowBuilds`
900
+ * neutralization wiped it along with everything else, so every marketplace
901
+ * update of a fresh release silently lost its exemption; the next unrelated
902
+ * install then failed lockfile policy verification on the very version that
903
+ * update had installed (real case: 0.4.14, updated to from the marketplace
904
+ * page, kept the profile un-installable for 24h).
905
+ *
906
+ * So restoring on success is a merge, not a byte rollback: start from the
907
+ * user's original bytes and splice in exactly the persistent keys pnpm wrote.
908
+ * When pnpm wrote nothing persistent the original bytes come back unchanged;
909
+ * when pnpm wrote something that cannot be merged safely this throws (see
910
+ * applyPersistentWrites) so the install fails and rolls back instead of
911
+ * reporting success with a dropped exemption.
912
+ *
913
+ * Only call this with a disk state no install script has had a chance to
914
+ * touch (or with a writes snapshot captured before they ran) — a script that
915
+ * got approved to execute could otherwise launder arbitrary release-age
916
+ * exemptions into the user's config as "pnpm writes".
917
+ *
918
+ * @param afterContent - pnpm-workspace.yaml as it sat on disk when pnpm
919
+ * finished (scripts still blocked, or a snapshot taken before they ran).
920
+ * @param originalContent - the user's pre-install bytes, or undefined when the
921
+ * file did not exist before (the install wrote the default template).
922
+ * @returns the bytes to leave on disk — `originalContent` as-is when pnpm
923
+ * wrote nothing persistent.
924
+ */
925
+ export function mergePersistentWorkspaceWrites(afterContent, originalContent) {
926
+ // Callers hand in the pre-save Buffer from readFileSync; normalize so the
927
+ // "no original file" case (undefined) is not confused with a Buffer.
928
+ const original = Buffer.isBuffer(originalContent) ? originalContent.toString("utf8") : originalContent;
929
+ return applyPersistentWrites(extractPersistentWrites(afterContent), original);
930
+ }
931
+
760
932
  /**
761
933
  * Neutralize allowBuilds in pnpm-workspace.yaml so that pnpm strictly blocks
762
934
  * the lifecycle scripts of everything this transaction introduces.
@@ -1562,10 +1734,11 @@ function pendingMarkerPath(profileDir) {
1562
1734
  function renderApprovalNeeded(spec, disclosure) {
1563
1735
  const lines = [
1564
1736
  `installing ${spec} requires running install-time code — approval needed.`,
1565
- "No install script ran and no plugin code loaded. The profile was restored",
1566
- "to its pre-install state. On approval, pnpm resolves again with scripts",
1567
- "blocked; the materialized bytes and commands must match this disclosure",
1568
- "before the verified tree is rebuilt. Nothing is left behind if you cancel.",
1737
+ "No install script ran and no plugin code loaded. The candidate is staged",
1738
+ "with its scripts blocked, and the original profile snapshot is retained.",
1739
+ "On approval, the materialized bytes and commands must match this disclosure",
1740
+ "before the verified tree is rebuilt. If you do not approve, restart dsh or",
1741
+ "run `dsh-plugin-guard guard recover` to roll the paused transaction back.",
1569
1742
  "",
1570
1743
  ];
1571
1744
  for (const entry of disclosure) {
@@ -1661,23 +1834,38 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1661
1834
  const collected = [];
1662
1835
  const deltaQueue = [];
1663
1836
  const push = (text) => {
1664
- collected.push(text);
1665
- deltaQueue.push(text);
1837
+ const plainText = stripTerminalControlSequences(text);
1838
+ collected.push(plainText);
1839
+ deltaQueue.push(plainText);
1666
1840
  };
1667
1841
 
1668
1842
  const workspacePath = join(profileDir, "pnpm-workspace.yaml");
1669
1843
  const originalWorkspaceBytes = existsSync(workspacePath) ? readFileSync(workspacePath) : undefined;
1670
1844
  let workspaceRestored = false;
1671
1845
  let workspaceRestoreError;
1672
- const restoreOriginalWorkspace = () => {
1846
+ const restoreOriginalWorkspace = ({ preservePnpmWrites = false, persistentWrites } = {}) => {
1673
1847
  if (workspaceRestored) return true;
1674
1848
  try {
1849
+ // On success the restore is a merge: pnpm's own durable writes to the
1850
+ // workspace (the minimumReleaseAgeExclude auto-exemption for an
1851
+ // explicitly pinned fresh release) survive, everything this transaction
1852
+ // wrote on top of the user's bytes does not. See
1853
+ // mergePersistentWorkspaceWrites for why a plain byte rollback here is
1854
+ // a bug, not hygiene. `persistentWrites` overrides the disk read with a
1855
+ // snapshot captured before approved scripts ran.
1856
+ let nextBytes = originalWorkspaceBytes;
1857
+ if (preservePnpmWrites) {
1858
+ const writes = persistentWrites !== undefined
1859
+ ? persistentWrites
1860
+ : extractPersistentWrites(existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : undefined);
1861
+ nextBytes = applyPersistentWrites(writes, originalWorkspaceBytes);
1862
+ }
1675
1863
  if (_restoreWorkspace !== undefined) {
1676
- _restoreWorkspace(workspacePath, originalWorkspaceBytes);
1677
- } else if (originalWorkspaceBytes === undefined) {
1864
+ _restoreWorkspace(workspacePath, nextBytes);
1865
+ } else if (nextBytes === undefined) {
1678
1866
  rmSync(workspacePath, { force: true });
1679
1867
  } else {
1680
- writeFileSync(workspacePath, originalWorkspaceBytes);
1868
+ writeFileSync(workspacePath, nextBytes);
1681
1869
  }
1682
1870
  workspaceRestored = true;
1683
1871
  workspaceRestoreError = undefined;
@@ -1887,12 +2075,15 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1887
2075
  const ignored = parseIgnoredBuilds(log);
1888
2076
 
1889
2077
  if (ignored.length === 0) {
1890
- if (!restoreOriginalWorkspace()) {
1891
- return { status: "failed", detail: `could not restore pnpm-workspace.yaml after the script-blocking probe: ${workspaceRestoreError?.message ?? "unknown error"}` };
1892
- }
1893
2078
  if (outcome.exitCode !== 0) {
2079
+ restoreOriginalWorkspace();
1894
2080
  return { status: "failed", detail: `pnpm add ${spec} failed (exit code ${outcome.exitCode}). See job output.` };
1895
2081
  }
2082
+ // pnpm succeeded: keep its durable workspace writes (the release-age
2083
+ // exemption it may have recorded for this very spec), wipe the rest.
2084
+ if (!restoreOriginalWorkspace({ preservePnpmWrites: true })) {
2085
+ return { status: "failed", detail: `could not restore pnpm-workspace.yaml after the script-blocking probe: ${workspaceRestoreError?.message ?? "unknown error"}` };
2086
+ }
1896
2087
  return tryFinalize();
1897
2088
  }
1898
2089
 
@@ -1947,6 +2138,15 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1947
2138
  push("[dsh-plugin-mall] temporarily allowing them in the profile's pnpm-workspace.yaml and retrying once.\n");
1948
2139
 
1949
2140
  workspaceRestored = false; // allow writing temporary approved builds
2141
+ // Snapshot pnpm's persistent writes BEFORE the rebuild runs. The add that
2142
+ // produced them executed with every script blocked, so this delta is
2143
+ // provably pnpm's own. Once the approved postinstall scripts execute they
2144
+ // can write anything into this file — a broad release-age exemption would
2145
+ // otherwise be laundered into the user's config as a "pnpm write" by the
2146
+ // success-restore merge. Only this snapshot is merged afterwards.
2147
+ const preRebuildPersistentWrites = extractPersistentWrites(
2148
+ existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : undefined,
2149
+ );
1950
2150
  try {
1951
2151
  const currentWs = existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : DEFAULT_WORKSPACE_YAML;
1952
2152
  const nextWs = enableApprovedBuildSelectors(currentWs, ignored.map((entry) => entry.selector));
@@ -1960,8 +2160,16 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1960
2160
  current = retry;
1961
2161
  const retryOutcome = await retry.done;
1962
2162
 
1963
- // Restore workspace bytes on EVERY branch (including success) before finalize
1964
- if (!restoreOriginalWorkspace()) {
2163
+ // Restore workspace on EVERY branch (including success) before finalize.
2164
+ // Success restores as a merge of the PRE-REBUILD snapshot only: the
2165
+ // exemption pnpm recorded for the approved install's spec survives, the
2166
+ // temporary approved-build bytes do not, and whatever the approved
2167
+ // scripts wrote to the file during the rebuild does not either. Failure
2168
+ // paths roll the bytes back wholesale.
2169
+ const retrySucceeded = retryOutcome.spawnError === undefined
2170
+ && !endedByCancel(retryOutcome, cancelRequested)
2171
+ && retryOutcome.exitCode === 0;
2172
+ if (!restoreOriginalWorkspace(retrySucceeded ? { preservePnpmWrites: true, persistentWrites: preRebuildPersistentWrites } : {})) {
1965
2173
  return { status: "failed", detail: `approved scripts finished but pnpm-workspace.yaml could not be restored: ${workspaceRestoreError?.message ?? "unknown error"}` };
1966
2174
  }
1967
2175
 
@@ -2154,7 +2362,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
2154
2362
  }
2155
2363
  const deltaQueue = [];
2156
2364
  const push = (text) => {
2157
- deltaQueue.push(text);
2365
+ deltaQueue.push(stripTerminalControlSequences(text));
2158
2366
  };
2159
2367
  let current = undefined;
2160
2368
  let cancelRequested = false; // see endedByCancel: exit codes cannot tell us this on Windows
@@ -2221,7 +2429,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
2221
2429
  try {
2222
2430
  proc = (_spawn ?? spawn)(plan.command, ["remove", packageName, "--reporter=append-only"], {
2223
2431
  cwd: profileDir,
2224
- env: process.env,
2432
+ env: pnpmGuardEnv(process.env),
2225
2433
  shell: plan.shell,
2226
2434
  stdio: ["ignore", "pipe", "pipe"],
2227
2435
  windowsHide: true,
@@ -2284,7 +2492,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
2284
2492
  // 退出码 0 不等于卸干净了。落盘校验用的是启动恢复同一套判据:
2285
2493
  // profile 整体仍然自洽,且这个包确实从清单和装配层里消失了。任何一条
2286
2494
  // 不过就还原——一个「装着但坏」的 profile 比一个没卸掉的插件糟得多。
2287
- const profileCheck = validateInstalledProfile(profileDir);
2495
+ const profileCheck = validatePendingProfile(profileDir);
2288
2496
  const removeCheck = validateRemoveCompletion(profileDir, packageName);
2289
2497
  if (!profileCheck.ok || !removeCheck.ok) {
2290
2498
  const blockers = [...profileCheck.issues, ...removeCheck.issues]
@@ -2411,6 +2619,7 @@ function runAllowBuildsFixtures() {
2411
2619
  if (!ok && error !== undefined) console.log(` 抛错: ${error.message}`);
2412
2620
  }
2413
2621
  failed += runNeutralizeFixtures();
2622
+ failed += runMergeWritesFixtures();
2414
2623
  return failed;
2415
2624
  }
2416
2625
 
@@ -2509,6 +2718,130 @@ function runNeutralizeFixtures() {
2509
2718
  return failed;
2510
2719
  }
2511
2720
 
2721
+ // ── mergePersistentWorkspaceWrites:成功还原时留住 pnpm 的持久写入 ─────────
2722
+ //
2723
+ // 真实事故:0.4.14 从市场页更新,pnpm 为显式指定的冷却期版本记了
2724
+ // minimumReleaseAgeExclude 豁免,字节还原把它一起抹掉;此后 24 小时内任何
2725
+ // 安装都在 lockfile 供应链策略验证那步被这个「自己刚装的版本」拦下。
2726
+ // 这组钉的是合并的边界:豁免要活下来,用户的注释/键序/其余键一字不动,
2727
+ // 我们的临时中和(allowBuilds)照旧不留。
2728
+ const MERGE_WRITES_FIXTURES = [
2729
+ {
2730
+ label: "pnpm 往已有豁免条目追加版本 → 豁免更新,注释与键序不动",
2731
+ original: "packages:\n - .\n# 用户注释不许丢\nnodeLinker: hoisted\nminimumReleaseAgeExclude:\n - '@scope/pkg@0.3.4'\n",
2732
+ after: "packages:\n - .\nnodeLinker: hoisted\nminimumReleaseAgeExclude:\n - '@scope/pkg@0.3.4 || 0.4.14'\nallowBuilds: {}\n",
2733
+ check: (d, text) => d.minimumReleaseAgeExclude[0] === "@scope/pkg@0.3.4 || 0.4.14"
2734
+ && text.includes("# 用户注释不许丢")
2735
+ && text.indexOf("nodeLinker: hoisted") < text.indexOf("minimumReleaseAgeExclude:")
2736
+ && !text.includes("allowBuilds"),
2737
+ },
2738
+ {
2739
+ label: "原文件没有豁免键、pnpm 新增整块 → 追加到文件尾",
2740
+ original: "packages:\n - .\nnodeLinker: hoisted\n",
2741
+ after: "nodeLinker: hoisted\npackages:\n - .\nminimumReleaseAgeExclude:\n - 'fresh-pkg@1.0.0'\n",
2742
+ check: (d, text) => d.minimumReleaseAgeExclude[0] === "fresh-pkg@1.0.0"
2743
+ && text.startsWith("packages:\n - .\n")
2744
+ && text.includes("nodeLinker: hoisted"),
2745
+ },
2746
+ {
2747
+ label: "pnpm 没写任何持久键 → 字节原样返回(不是重排后的等价文本)",
2748
+ original: "packages:\n - .\nnodeLinker: hoisted\nallowBuilds:\n node-pty: true\n",
2749
+ after: "nodeLinker: hoisted\npackages:\n - .\nallowBuilds: {}\n",
2750
+ check: (d, text) => text === "packages:\n - .\nnodeLinker: hoisted\nallowBuilds:\n node-pty: true\n",
2751
+ },
2752
+ {
2753
+ label: "豁免值没变(只是文本被重排)→ 字节原样返回",
2754
+ original: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@1.0.0'\n",
2755
+ after: "minimumReleaseAgeExclude:\n - 'a@1.0.0'\npackages:\n - .\n",
2756
+ check: (d, text) => text === "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@1.0.0'\n",
2757
+ },
2758
+ {
2759
+ label: "原文件不存在 + pnpm 写了豁免 → 默认模板带上豁免保留下来",
2760
+ original: undefined,
2761
+ after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'p@1.0.0'\n",
2762
+ check: (d) => d.minimumReleaseAgeExclude[0] === "p@1.0.0" && Array.isArray(d.packages) && d.packages[0] === ".",
2763
+ },
2764
+ {
2765
+ label: "原文件不存在 + pnpm 没写 → 返回 undefined(维持删除语义)",
2766
+ original: undefined,
2767
+ after: "packages:\n - .\nnodeLinker: hoisted\n",
2768
+ check: (d, text) => text === undefined,
2769
+ },
2770
+ {
2771
+ label: "磁盘内容解析不了 → 退回纯字节还原",
2772
+ original: "packages:\n - .\n",
2773
+ after: "not: [valid: yaml\n - broken",
2774
+ check: (d, text) => text === "packages:\n - .\n",
2775
+ },
2776
+ {
2777
+ label: "磁盘上没有 workspace 文件 → 退回纯字节还原",
2778
+ original: "packages:\n - .\n",
2779
+ after: undefined,
2780
+ check: (d, text) => text === "packages:\n - .\n",
2781
+ },
2782
+ {
2783
+ label: "原键是单引号写法 → 识别并替换,不追加重复键",
2784
+ original: "packages:\n - .\n'minimumReleaseAgeExclude':\n - 'a@1.0.0'\n",
2785
+ after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@1.0.0 || 9.9.9'\n",
2786
+ check: (d, text) => d.minimumReleaseAgeExclude[0] === "a@1.0.0 || 9.9.9"
2787
+ && text.split("\n").filter((l) => /['\"]?minimumReleaseAgeExclude['\"]?\s*:/.test(l)).length === 1
2788
+ && text.includes("'minimumReleaseAgeExclude':"),
2789
+ },
2790
+ {
2791
+ label: "原键是双引号写法 → 识别并替换,不追加重复键",
2792
+ original: "packages:\n - .\n\"minimumReleaseAgeExclude\":\n - 'a@1.0.0'\n",
2793
+ after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@2.0.0'\n",
2794
+ check: (d, text) => d.minimumReleaseAgeExclude[0] === "a@2.0.0"
2795
+ && text.split("\n").filter((l) => /['\"]?minimumReleaseAgeExclude['\"]?\s*:/.test(l)).length === 1,
2796
+ },
2797
+ {
2798
+ label: "原文件本就有重复键(已损坏)→ 拒绝合并并抛错,绝不静默丢豁免",
2799
+ original: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@1.0.0'\nminimumReleaseAgeExclude:\n - 'b@1.0.0'\n",
2800
+ after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@2.0.0'\n",
2801
+ expectThrow: /cannot merge the release-age exemption/,
2802
+ check: () => false,
2803
+ },
2804
+ {
2805
+ label: "豁免键用 alias 引用别处(复杂写法)→ 展开替换,语义等价、锚点不动",
2806
+ original: "exemptions: &ex\n - 'a@1.0.0'\npackages:\n - .\nminimumReleaseAgeExclude: *ex\n",
2807
+ after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@2.0.0'\n",
2808
+ check: (d, text) => JSON.stringify(d.minimumReleaseAgeExclude) === JSON.stringify(["a@2.0.0"])
2809
+ && text.includes("exemptions: &ex"),
2810
+ },
2811
+ {
2812
+ label: "原文件 alias 指向不存在的锚点(解析失败)→ 抛错而非静默丢豁免",
2813
+ original: "packages:\n - .\nminimumReleaseAgeExclude: *missing\n",
2814
+ after: "packages:\n - .\nminimumReleaseAgeExclude:\n - 'a@2.0.0'\n",
2815
+ expectThrow: /does not parse/,
2816
+ check: () => false,
2817
+ },
2818
+ ];
2819
+
2820
+ function runMergeWritesFixtures() {
2821
+ let failed = 0;
2822
+ for (const fx of MERGE_WRITES_FIXTURES) {
2823
+ let ok = false;
2824
+ let out;
2825
+ try {
2826
+ out = mergePersistentWorkspaceWrites(fx.after, fx.original);
2827
+ if (fx.expectThrow !== undefined) {
2828
+ ok = false; // 该抛的没抛
2829
+ } else if (out === undefined || typeof out === "string") {
2830
+ // 产出必须可解析(undefined 除外,那是删除语义)。
2831
+ if (typeof out === "string") load(out);
2832
+ ok = fx.check(out === undefined ? undefined : load(out), out) === true;
2833
+ }
2834
+ } catch (error) {
2835
+ ok = fx.expectThrow !== undefined && fx.expectThrow.test(error?.message ?? "");
2836
+ if (!ok) console.log(` 抛错: ${error?.message}`);
2837
+ }
2838
+ if (!ok) failed++;
2839
+ console.log(` ${ok ? "PASS" : "FAIL"} merge-writes: ${fx.label}`);
2840
+ if (!ok && out !== undefined) console.log(` 产出:\n${String(out).split("\n").map((l) => ` | ${l}`).join("\n")}`);
2841
+ }
2842
+ return failed;
2843
+ }
2844
+
2512
2845
  // ── transaction fixtures (deterministic, offline) ───────────────────────────
2513
2846
  //
2514
2847
  // The findings these pin:
@@ -2703,7 +3036,10 @@ async function runTransactionFixtures() {
2703
3036
  try {
2704
3037
  materializeFakePackage(profileDir, "some-plugin", "1.0.0");
2705
3038
  materializeFakePackage(profileDir, "node-pty", "1.0.0", { install: "node install.js" });
2706
- const { spawnFn, calls } = scriptedSpawn([{ code: 0, out: "Packages are cloned\nIgnored build scripts: node-pty@1.0.0\nDone\n" }]);
3039
+ // pnpm 11 on Windows may colour stderr even when it is captured through a
3040
+ // pipe. In particular, the reset code lands directly after the selector.
3041
+ const colouredIgnoredBuilds = "Packages are cloned\n\u001b[31mIgnored build scripts: node-pty@1.0.0\u001b[39m\nDone\n";
3042
+ const { spawnFn, calls } = scriptedSpawn([{ code: 0, out: colouredIgnoredBuilds }]);
2707
3043
  const producer = runInstall({
2708
3044
  profile: "p",
2709
3045
  spec: "some-plugin",
@@ -2725,7 +3061,7 @@ async function runTransactionFixtures() {
2725
3061
  const output = producer.readOutput();
2726
3062
  const markerBefore = pendingMarkerPath(profileDir);
2727
3063
  check(
2728
- "退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
3064
+ "退出码 0 + 彩色 Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
2729
3065
  outcome.status === "failed"
2730
3066
  && Array.isArray(outcome.needsApproval)
2731
3067
  && outcome.needsApproval.some((entry) => entry.name === "node-pty")
@@ -2740,7 +3076,11 @@ async function runTransactionFixtures() {
2740
3076
  && entry.weeklyDownloads === 123)
2741
3077
  && calls.length === 1
2742
3078
  && existsSync(markerBefore)
2743
- && /paused for build-script approval/.test(output),
3079
+ && /candidate is staged/.test(outcome.detail ?? "")
3080
+ && !/profile was restored/.test(outcome.detail ?? "")
3081
+ && /paused for build-script approval/.test(output)
3082
+ && /Ignored build scripts: node-pty@1\.0\.0/.test(output)
3083
+ && !output.includes("\u001b["),
2744
3084
  `status=${outcome.status} calls=${calls.length} marker=${existsSync(markerBefore)}`,
2745
3085
  );
2746
3086
  check(
@@ -2906,6 +3246,203 @@ async function runTransactionFixtures() {
2906
3246
  }
2907
3247
  }
2908
3248
 
3249
+ // 1b2. 成功安装 + pnpm 为显式指定的冷却期版本记了豁免 → 豁免必须活过
3250
+ // 成功路径的 workspace 还原。0.4.14 事故的钉子:字节还原把 pnpm 的这条
3251
+ // 持久写入一起抹掉,此后 24 小时内任何安装都在 lockfile 供应链策略验证
3252
+ // 那步被「自己刚装的版本」拦死。
3253
+ {
3254
+ const { profileDir, cleanup } = makeTempProfile("release-age-exemption");
3255
+ try {
3256
+ const initialWs = "packages:\n - .\n# 用户注释不许丢\nnodeLinker: hoisted\nminimumReleaseAgeExclude:\n - '@1e0zj/pkg@0.3.4'\n";
3257
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
3258
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
3259
+ const { spawnFn } = scriptedSpawn([
3260
+ {
3261
+ code: 0,
3262
+ out: "Done\n",
3263
+ beforeExit: () => {
3264
+ // 模拟 pnpm 11 的行为:显式装冷却期内的版本 → 放行并往 workspace
3265
+ // 的 minimumReleaseAgeExclude 追加记录(pnpm 会并入已有条目)。
3266
+ const wsPath = join(profileDir, "pnpm-workspace.yaml");
3267
+ const parsed = load(readFileSync(wsPath, "utf8"));
3268
+ parsed.minimumReleaseAgeExclude = ["@1e0zj/pkg@0.3.4 || 0.4.14"];
3269
+ writeFileSync(wsPath, dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false }));
3270
+ },
3271
+ },
3272
+ ]);
3273
+ const producer = runInstall({
3274
+ profile: "p",
3275
+ spec: "some-plugin",
3276
+ preflight: preflightStub("some-plugin"),
3277
+ _profileDir: profileDir,
3278
+ _spawn: spawnFn,
3279
+ _describe: describeStub,
3280
+ });
3281
+ const outcome = await producer.done;
3282
+ const finalWs = readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8");
3283
+ const finalDoc = load(finalWs);
3284
+ check(
3285
+ "退出码 0 + pnpm 记了冷却豁免 → 豁免活过还原,注释与用户键保留、中和态不残留",
3286
+ outcome.status === "completed"
3287
+ && finalDoc.minimumReleaseAgeExclude[0] === "@1e0zj/pkg@0.3.4 || 0.4.14"
3288
+ && finalWs.includes("# 用户注释不许丢")
3289
+ && finalWs.includes("nodeLinker: hoisted")
3290
+ && finalDoc.allowBuilds === undefined,
3291
+ `status=${outcome.status} finalWs=${JSON.stringify(finalWs)}`,
3292
+ );
3293
+ } finally {
3294
+ cleanup();
3295
+ }
3296
+ }
3297
+
3298
+ // 1b3. 反向边界:pnpm 失败(哪怕豁免已落盘)→ 字节还原,豁免不保留——
3299
+ // 没装成就不欠供应链策略的债。
3300
+ {
3301
+ const { profileDir, cleanup } = makeTempProfile("release-age-exemption-failed");
3302
+ try {
3303
+ const initialWs = "packages:\n - .\nnodeLinker: hoisted\n";
3304
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
3305
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
3306
+ const { spawnFn } = scriptedSpawn([
3307
+ {
3308
+ code: 1,
3309
+ out: "ERR_PNPM_NO_MATCHING_VERSION\n",
3310
+ beforeExit: () => {
3311
+ const wsPath = join(profileDir, "pnpm-workspace.yaml");
3312
+ const parsed = load(readFileSync(wsPath, "utf8"));
3313
+ parsed.minimumReleaseAgeExclude = ["some-plugin@9.9.9"];
3314
+ writeFileSync(wsPath, dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false }));
3315
+ },
3316
+ },
3317
+ ]);
3318
+ const producer = runInstall({
3319
+ profile: "p",
3320
+ spec: "some-plugin",
3321
+ preflight: preflightStub("some-plugin"),
3322
+ _profileDir: profileDir,
3323
+ _spawn: spawnFn,
3324
+ _describe: describeStub,
3325
+ });
3326
+ const outcome = await producer.done;
3327
+ const finalWs = readFileSync(join(profileDir, "pnpm-workspace.yaml"), "utf8");
3328
+ check(
3329
+ "退出码非 0(豁免已落盘)→ 字节还原,豁免不保留",
3330
+ outcome.status === "failed" && finalWs === initialWs,
3331
+ `status=${outcome.status} finalWs=${JSON.stringify(finalWs)}`,
3332
+ );
3333
+ } finally {
3334
+ cleanup();
3335
+ }
3336
+ }
3337
+
3338
+ // 1b4. 批准执行的构建脚本篡改豁免 → 只有 rebuild 前捕获的 pnpm 豁免被
3339
+ // 合并。已批准的 postinstall 在 rebuild 里跑过之后,磁盘上的 workspace
3340
+ // 谁都能写:宽泛豁免若被当「pnpm 持久写入」合并,等于借审批之手把
3341
+ // 供应链冷却期防线拆了。
3342
+ {
3343
+ const { profileDir, cleanup } = makeTempProfile("exemption-laundering");
3344
+ try {
3345
+ const initialWs = "packages:\n - .\nnodeLinker: hoisted\n";
3346
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
3347
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
3348
+ materializeFakePackage(profileDir, "node-pty", "1.0.0", { install: "node install.js" });
3349
+ const approvedProof = computeMaterializedProof(profileDir, "some-plugin", [{ name: "node-pty", version: "1.0.0", selector: "node-pty@1.0.0" }]);
3350
+ const wsPath = join(profileDir, "pnpm-workspace.yaml");
3351
+ const writeWs = (exemptions) => {
3352
+ const parsed = load(readFileSync(wsPath, "utf8"));
3353
+ parsed.minimumReleaseAgeExclude = exemptions;
3354
+ writeFileSync(wsPath, dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false }));
3355
+ };
3356
+ const { spawnFn } = scriptedSpawn([
3357
+ {
3358
+ // 第一次 add:脚本全禁,pnpm 为显式指定的冷却期版本记豁免。
3359
+ code: 0,
3360
+ out: "Ignored build scripts: node-pty@1.0.0\n",
3361
+ beforeExit: () => writeWs(["some-plugin@1.0.0"]),
3362
+ },
3363
+ {
3364
+ // rebuild:node-pty 的 postinstall(已获批准)顺手篡改豁免。
3365
+ code: 0,
3366
+ out: "Done\n",
3367
+ beforeExit: () => writeWs(["some-plugin@1.0.0", "evil-pkg@*"]),
3368
+ },
3369
+ ]);
3370
+ const producer = runInstall({
3371
+ profile: "p",
3372
+ spec: "some-plugin",
3373
+ allowBuildScripts: ["node-pty"],
3374
+ approvedProof,
3375
+ preflight: preflightStub("some-plugin"),
3376
+ _profileDir: profileDir,
3377
+ _spawn: spawnFn,
3378
+ _describe: describeStub,
3379
+ });
3380
+ const outcome = await producer.done;
3381
+ const finalWs = readFileSync(wsPath, "utf8");
3382
+ const finalDoc = load(finalWs);
3383
+ check(
3384
+ "批准脚本在 rebuild 里篡改豁免 → 只有 rebuild 前的 pnpm 豁免被合并,篡改不进用户配置",
3385
+ outcome.status === "completed"
3386
+ && JSON.stringify(finalDoc.minimumReleaseAgeExclude) === JSON.stringify(["some-plugin@1.0.0"])
3387
+ && finalWs.includes("nodeLinker: hoisted")
3388
+ && finalDoc.allowBuilds === undefined,
3389
+ `status=${outcome.status} exemptions=${JSON.stringify(finalDoc.minimumReleaseAgeExclude)}`,
3390
+ );
3391
+ } finally {
3392
+ cleanup();
3393
+ }
3394
+ }
3395
+
3396
+ // 1b5. 端到端:pnpm 成功记了豁免,但用户的 workspace 用了合并器不认识的
3397
+ // 写法(flow mapping——load 认得、顶格键定位不认得)→ 合并失败必须让
3398
+ // 安装 failed 并回滚 profile,而不是报成功、静默丢豁免把雷留给下一次安装。
3399
+ {
3400
+ const { profileDir, cleanup } = makeTempProfile("exemption-merge-unmergeable");
3401
+ try {
3402
+ const initialWs = "{packages: ['.'], nodeLinker: hoisted, minimumReleaseAgeExclude: [a@1.0.0]}\n";
3403
+ writeFileSync(join(profileDir, "pnpm-workspace.yaml"), initialWs);
3404
+ materializeFakePackage(profileDir, "some-plugin", "1.0.0");
3405
+ const manifestBefore = readFileSync(join(profileDir, "package.json"), "utf8");
3406
+ const wsPath = join(profileDir, "pnpm-workspace.yaml");
3407
+ const { spawnFn } = scriptedSpawn([
3408
+ {
3409
+ code: 0,
3410
+ out: "Done\n",
3411
+ beforeExit: () => {
3412
+ // 模拟 pnpm:显式冷却期版本放行 + 记豁免 + 把依赖写进 manifest。
3413
+ // pnpm 读写的是被 neutralize 重排过的 block 风格文件。
3414
+ const parsed = load(readFileSync(wsPath, "utf8"));
3415
+ parsed.minimumReleaseAgeExclude = ["a@2.0.0"];
3416
+ writeFileSync(wsPath, dump(parsed, { lineWidth: -1, noRefs: true, sortKeys: false }));
3417
+ const pkg = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
3418
+ pkg.dependencies = { ...(pkg.dependencies ?? {}), "some-plugin": "1.0.0" };
3419
+ writeFileSync(join(profileDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
3420
+ },
3421
+ },
3422
+ ]);
3423
+ const outcome = await runInstall({
3424
+ profile: "p",
3425
+ spec: "some-plugin",
3426
+ preflight: preflightStub("some-plugin"),
3427
+ _profileDir: profileDir,
3428
+ _spawn: spawnFn,
3429
+ _describe: describeStub,
3430
+ }).done;
3431
+ const finalWs = readFileSync(wsPath, "utf8");
3432
+ check(
3433
+ "pnpm 记豁免但 workspace 写法无法安全合并 → failed + 明说原因 + profile 原样 + marker 清除",
3434
+ outcome.status === "failed"
3435
+ && /cannot merge the release-age exemption/.test(outcome.detail ?? "")
3436
+ && finalWs === initialWs
3437
+ && readFileSync(join(profileDir, "package.json"), "utf8") === manifestBefore
3438
+ && !existsSync(pendingMarkerPath(profileDir)),
3439
+ `status=${outcome.status} detail=${JSON.stringify(outcome.detail)} finalWs=${JSON.stringify(finalWs)}`,
3440
+ );
3441
+ } finally {
3442
+ cleanup();
3443
+ }
3444
+ }
3445
+
2909
3446
  // 1c. 攻击防御:批准后修改 postinstall / 文件内容 → retry 时 proof 校验失败,绝不重试并回滚
2910
3447
  {
2911
3448
  const { profileDir, cleanup } = makeTempProfile("attack-tampered-proof");
@@ -3413,7 +3950,7 @@ async function runTransactionFixtures() {
3413
3950
  let snapshotsWhileRunning = [];
3414
3951
  const { spawnFn } = scriptedSpawn([{
3415
3952
  code: 0,
3416
- out: "Done\n",
3953
+ out: "\u001b[32mDone\u001b[39m\n",
3417
3954
  // pnpm 真正卸掉:清单与目录都拿走,落盘校验才会通过。
3418
3955
  beforeExit: () => {
3419
3956
  snapshotsWhileRunning = listSnapshots();
@@ -3423,14 +3960,17 @@ async function runTransactionFixtures() {
3423
3960
  rmSync(join(profileDir, "node_modules", "pkg-f"), { recursive: true, force: true });
3424
3961
  },
3425
3962
  }]);
3426
- const outcome = await runRemove({ profile: "p", packageName: "pkg-f", _profileDir: profileDir, _spawn: spawnFn }).done;
3963
+ const producer = runRemove({ profile: "p", packageName: "pkg-f", _profileDir: profileDir, _spawn: spawnFn });
3964
+ const outcome = await producer.done;
3965
+ const output = producer.readOutput();
3427
3966
  const snapshotsLeft = listSnapshots();
3428
3967
  check(
3429
- "卸载成功 → marker 与 snapshot 都被提交清理(且快照确实创建过)",
3968
+ "卸载成功 → 日志去色,marker 与 snapshot 都被提交清理(且快照确实创建过)",
3430
3969
  outcome.status === "completed"
3431
3970
  && snapshotsWhileRunning.length === 1
3432
3971
  && !existsSync(pendingMarkerPath(profileDir))
3433
- && snapshotsLeft.length === 0,
3972
+ && snapshotsLeft.length === 0
3973
+ && output === "Done\n",
3434
3974
  `status=${outcome.status} 运行中快照=${snapshotsWhileRunning.join(",")} marker=${existsSync(pendingMarkerPath(profileDir))} 残留快照=${snapshotsLeft.join(",")} detail=${JSON.stringify(outcome.detail)}`,
3435
3975
  );
3436
3976
  } finally {
@@ -3868,7 +4408,9 @@ async function runTransactionFixtures() {
3868
4408
  */
3869
4409
  function runToggleFixtures() {
3870
4410
  let failed = 0;
4411
+ let total = 0;
3871
4412
  const check = (label, ok, extra = "") => {
4413
+ total += 1;
3872
4414
  if (!ok) failed++;
3873
4415
  console.log(` ${ok ? "PASS" : "FAIL"} ${label}${ok ? "" : ` ${extra}`}`);
3874
4416
  };
@@ -4007,16 +4549,20 @@ function runToggleFixtures() {
4007
4549
  }
4008
4550
  })());
4009
4551
 
4010
- return failed;
4552
+ return { failed, total };
4011
4553
  }
4012
4554
 
4013
4555
  if (process.argv[1]?.endsWith("installer.js") && process.argv.includes("--self-test")) {
4014
4556
  console.log("启用/停用 patch 层 fixtures:");
4015
- const toggleFailed = runToggleFixtures();
4557
+ const toggle = runToggleFixtures();
4016
4558
  console.log();
4017
4559
  console.log("allowBuilds 合并 fixtures:");
4018
- const failed = runAllowBuildsFixtures() + toggleFailed;
4019
- console.log(`${ALLOW_BUILDS_FIXTURES.length - failed}/${ALLOW_BUILDS_FIXTURES.length} passed`);
4560
+ const failed = runAllowBuildsFixtures() + toggle.failed;
4561
+ // 分母要数全跑过的用例(toggle/neutralize/merge-writes 也在这批里),
4562
+ // 只数 ALLOW_BUILDS_FIXTURES 会把后来加的组漏在「10/10」的假象外。
4563
+ const fixtureTotal = ALLOW_BUILDS_FIXTURES.length + NEUTRALIZE_FIXTURES.length
4564
+ + MERGE_WRITES_FIXTURES.length + toggle.total;
4565
+ console.log(`${fixtureTotal - failed}/${fixtureTotal} passed`);
4020
4566
  // 实装 pnpm add 的参数/环境(纯函数):peer 自动安装必须关闭,否则
4021
4567
  // marketplace 安装会把 @deepseek-ai 宿主依赖栈拉进 profile;构建脚本必须
4022
4568
  // 严格,否则 pnpm 退出码 0 却跳过构建脚本,批准闸形同虚设。