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

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.12",
3
+ "version": "0.4.14",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/cli.js CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  resolveDshHome,
62
62
  rollbackPendingSnapshot,
63
63
  validateInstalledProfile,
64
+ validatePendingProfile,
64
65
  validateRemoveCompletion,
65
66
  } from "./guard.js";
66
67
  // github.js imports node builtins only — the host-independence of this CLI is
@@ -502,7 +503,7 @@ async function cmdAdd({ spec, profile, home, acceptWarnings }) {
502
503
  // rolled back immediately; otherwise the marker stays pending and the next
503
504
  // dsh startup (or `guard recover`) commits it once the plugin actually
504
505
  // loads.
505
- const validation = validateInstalledProfile(profileDir);
506
+ const validation = validatePendingProfile(profileDir);
506
507
  if (result.exitCode === 0 && validation.ok) {
507
508
  console.log(`[guard] installed ${spec} into profile "${profile}".`);
508
509
  console.log("Restart dsh to load it. On the next startup — or via `node src/cli.js guard recover` — the pending snapshot is committed once the profile proves loadable; if dsh fails to boot, the same command rolls it back.");
@@ -596,7 +597,7 @@ async function cmdRemove({
596
597
 
597
598
  let validation;
598
599
  try {
599
- validation = validateInstalledProfile(profileDir);
600
+ validation = validatePendingProfile(profileDir);
600
601
  } catch (error) {
601
602
  rollbackAndThrow(`remove completed but static profile validation threw: ${error.message}`);
602
603
  }
@@ -1327,7 +1328,7 @@ async function cmdLaunch({
1327
1328
  }
1328
1329
  let validation;
1329
1330
  try {
1330
- validation = validateInstalledProfile(profileDir);
1331
+ validation = validatePendingProfile(profileDir, marker);
1331
1332
  } catch (error) {
1332
1333
  throw new Error(`static validation of profile "${profile}" failed — refusing to launch: ${error.message}`);
1333
1334
  }
package/src/client.js CHANGED
@@ -443,7 +443,7 @@ window.__ModuleLoader__.load({
443
443
  meta: clip(props.spec || "", 50),
444
444
  actions: actions,
445
445
  },
446
- props.detail ? h("div", { className: "mkt_desc" }, props.detail) : null,
446
+ h("div", { className: "mkt_desc" }, "pnpm 已拦截下列安装脚本,尚未执行任何插件代码。允许后会先复核制品再运行;暂不允许时,重启 dsh 会自动撤回这次安装。"),
447
447
  pkgs.map(function (p, index) {
448
448
  var facts = [];
449
449
  if (typeof p.weeklyDownloads === "number") facts.push("周下载 " + p.weeklyDownloads.toLocaleString());
@@ -609,7 +609,6 @@ window.__ModuleLoader__.load({
609
609
  job.needsApproval && job.needsApproval.length > 0
610
610
  ? h(ApprovalRequest, {
611
611
  spec: job.spec,
612
- detail: job.detail,
613
612
  needsApproval: job.needsApproval,
614
613
  busy: props.approving === job.spec,
615
614
  onApprove: function (names) {
package/src/guard.js CHANGED
@@ -24,8 +24,10 @@ import {
24
24
  import { homedir, tmpdir } from "node:os";
25
25
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
26
26
  import { createRequire } from "node:module";
27
+ import { createHash } from "node:crypto";
27
28
  import { JSON_SCHEMA, Type, load } from "js-yaml";
28
29
  import { satisfies, validRange } from "semver";
30
+ import { stripTerminalControlSequences } from "./terminal.js";
29
31
 
30
32
  // Official patches (e.g. @deepseek-ai/dsh-base, dsh-web-app) mark raw JS
31
33
  // expressions with the scalar tag `!!js`. Construct the loader's own marker —
@@ -1450,6 +1452,84 @@ export function validateInstalledProfile(profileDir) {
1450
1452
  };
1451
1453
  }
1452
1454
 
1455
+ /** Stable identity for one installed-profile blocker across a transaction. */
1456
+ function staticBlockerFingerprint(entry) {
1457
+ const canonical = (value) => {
1458
+ if (Array.isArray(value)) return value.map(canonical);
1459
+ if (value !== null && typeof value === "object") {
1460
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
1461
+ }
1462
+ return value;
1463
+ };
1464
+ return createHash("sha256").update(JSON.stringify(canonical(entry))).digest("hex");
1465
+ }
1466
+
1467
+ function staticBlockerFingerprints(validation) {
1468
+ return validation.issues
1469
+ .filter((entry) => entry.severity === "block")
1470
+ .map(staticBlockerFingerprint);
1471
+ }
1472
+
1473
+ /**
1474
+ * Validate the live profile relative to the state captured before a pending
1475
+ * transaction. An unchanged blocker that was already present did not come
1476
+ * from this install/remove and must not make the transaction roll back. New
1477
+ * markers carry the baseline; old markers do not, and deliberately retain the
1478
+ * old fail-closed behaviour.
1479
+ */
1480
+ export function validatePendingProfile(profileDir, pending = readValidatedPendingSnapshot(profileDir)) {
1481
+ const validation = validateInstalledProfile(profileDir);
1482
+ if (!Array.isArray(pending?.baselineBlockers)) {
1483
+ return { ...validation, allIssues: validation.issues, preexistingIssues: [] };
1484
+ }
1485
+
1486
+ const candidateName = pending.preflight?.candidate?.name ?? pending.candidate?.name;
1487
+ const remaining = new Map();
1488
+ for (const fingerprint of pending.baselineBlockers) {
1489
+ remaining.set(fingerprint, (remaining.get(fingerprint) ?? 0) + 1);
1490
+ }
1491
+ const issues = [];
1492
+ const preexistingIssues = [];
1493
+ for (const entry of validation.issues) {
1494
+ if (entry.severity !== "block") {
1495
+ issues.push(entry);
1496
+ continue;
1497
+ }
1498
+ // Updating the package that owns (or participates in) an old blocker must
1499
+ // prove that blocker was actually fixed. Only unrelated historical issues
1500
+ // receive baseline treatment.
1501
+ const candidateRelated = entry.package === candidateName
1502
+ || (Array.isArray(entry.conflictsWith) && entry.conflictsWith.includes(candidateName));
1503
+ if (candidateRelated) {
1504
+ issues.push(entry);
1505
+ continue;
1506
+ }
1507
+ const fingerprint = staticBlockerFingerprint(entry);
1508
+ const count = remaining.get(fingerprint) ?? 0;
1509
+ if (count > 0) {
1510
+ preexistingIssues.push(entry);
1511
+ remaining.set(fingerprint, count - 1);
1512
+ } else {
1513
+ issues.push(entry);
1514
+ }
1515
+ }
1516
+ const blockers = issues.filter((entry) => entry.severity === "block");
1517
+ const warnings = issues.filter((entry) => entry.severity === "warn");
1518
+ const verdict = blockers.length > 0 ? "blocked" : warnings.length > 0 ? "warning" : "safe";
1519
+ return {
1520
+ ok: blockers.length === 0,
1521
+ verdict,
1522
+ issues,
1523
+ allIssues: validation.issues,
1524
+ preexistingIssues,
1525
+ summary: blockers.length > 0
1526
+ ? `发现 ${blockers.length} 个本次新增阻断问题、${preexistingIssues.length} 个既有阻断问题、${warnings.length} 个警告`
1527
+ : preexistingIssues.length > 0
1528
+ ? `本次变更未新增阻断问题(保留 ${preexistingIssues.length} 个既有阻断问题)`
1529
+ : validation.summary,
1530
+ };
1531
+ }
1532
+
1453
1533
  /**
1454
1534
  * Remove leftover `node_modules` entries for packages that are no longer part
1455
1535
  * of the profile's declared dependencies. pnpm hoists each direct dependency
@@ -1723,7 +1803,7 @@ function spawnCapture(command, args, options, onOutput, { signal, treeKill = fal
1723
1803
  let child;
1724
1804
  const chunks = [];
1725
1805
  const push = (value) => {
1726
- const text = value.toString();
1806
+ const text = stripTerminalControlSequences(value);
1727
1807
  chunks.push(text);
1728
1808
  onOutput?.(text);
1729
1809
  };
@@ -1831,7 +1911,7 @@ export async function preflightInstall({ profileDir, spec, onOutput, signal }) {
1831
1911
  const result = await spawnCapture(
1832
1912
  plan.command,
1833
1913
  probeAddArgs(spec),
1834
- { cwd: probeDir, env: process.env, shell: plan.shell },
1914
+ { cwd: probeDir, env: pnpmGuardEnv(process.env), shell: plan.shell },
1835
1915
  onOutput,
1836
1916
  { signal, treeKill: plan.treeKill },
1837
1917
  );
@@ -1998,6 +2078,9 @@ function assertStoredTransactionMatches(pending) {
1998
2078
  if (pending.operation === "remove" && stored.metadata.packageName !== pending.metadata.packageName) {
1999
2079
  throw new Error("pending remove packageName does not match snapshot.json — refusing to act on it (left untouched for manual inspection)");
2000
2080
  }
2081
+ if (JSON.stringify(stored.baselineBlockers) !== JSON.stringify(pending.baselineBlockers)) {
2082
+ throw new Error("pending marker baseline blockers do not match snapshot.json — refusing to act on it (left untouched for manual inspection)");
2083
+ }
2001
2084
  }
2002
2085
 
2003
2086
  /**
@@ -2037,6 +2120,12 @@ function sanitizeSnapshot(marker, home) {
2037
2120
  if (!Array.isArray(marker.dependencies) || marker.dependencies.some((name) => typeof name !== "string" || !NPM_PACKAGE_NAME_RE.test(name))) {
2038
2121
  throw new Error("pending marker has missing or corrupt dependency metadata — refusing to act on it (left untouched for manual inspection)");
2039
2122
  }
2123
+ if (marker.baselineBlockers !== undefined && (
2124
+ !Array.isArray(marker.baselineBlockers)
2125
+ || marker.baselineBlockers.some((fingerprint) => typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint))
2126
+ )) {
2127
+ throw new Error("pending marker has corrupt baseline blocker metadata — refusing to act on it (left untouched for manual inspection)");
2128
+ }
2040
2129
  validatePendingTransaction(marker);
2041
2130
  const profileDir = resolve(String(marker.profileDir ?? ""));
2042
2131
  const profilesRoot = resolve(join(home, "profiles"));
@@ -2090,7 +2179,12 @@ export function createProfileSnapshot(profileDir, metadata = {}) {
2090
2179
  } catch {
2091
2180
  /* manifest unreadable — the rest of the snapshot still captures the bytes */
2092
2181
  }
2093
- const snapshot = { version: SNAPSHOT_VERSION, id, dir, profileDir, createdAt: Date.now(), files, dependencies, operation: normalizedMetadata.operation, metadata: normalizedMetadata };
2182
+ // Static validation after an install must judge what THIS transaction
2183
+ // changed. Without this baseline, an unrelated legacy plugin that already
2184
+ // shadowed host modules made every later update look guilty and forced the
2185
+ // marketplace itself back to its old version on restart.
2186
+ const baselineBlockers = staticBlockerFingerprints(validateInstalledProfile(profileDir));
2187
+ const snapshot = { version: SNAPSHOT_VERSION, id, dir, profileDir, createdAt: Date.now(), files, dependencies, baselineBlockers, operation: normalizedMetadata.operation, metadata: normalizedMetadata };
2094
2188
  writeFileSync(join(dir, "snapshot.json"), JSON.stringify(snapshot, undefined, 2) + "\n");
2095
2189
  return snapshot;
2096
2190
  }
@@ -2127,7 +2221,7 @@ export function markPendingSnapshot(snapshot, record = {}) {
2127
2221
  if (existsSync(markerPath)) {
2128
2222
  throw new Error(`profile already has a pending install marker at ${markerPath} — run \`dsh-plugin-guard guard recover\` (or let dsh startup recovery consume it) before installing again`);
2129
2223
  }
2130
- const pending = { ...snapshot, ...record, pendingAt: Date.now() };
2224
+ const pending = { ...snapshot, ...record, baselineBlockers: snapshot.baselineBlockers, pendingAt: Date.now() };
2131
2225
  // Reject producer bugs before persisting them. The read/recovery boundary
2132
2226
  // repeats this validation because the marker is attacker-controllable.
2133
2227
  validatePendingTransaction(pending);
@@ -2212,6 +2306,13 @@ function addedDependencyNames(pending, originalDependencies = pending?.dependenc
2212
2306
  export function pnpmGuardEnv(base = process.env) {
2213
2307
  return {
2214
2308
  ...base,
2309
+ // Browser jobs render plain text rather than a terminal. Prevent pnpm from
2310
+ // emitting colours at the source; output capture also strips CSI controls
2311
+ // defensively in case a child ignores these conventional switches.
2312
+ NO_COLOR: "1",
2313
+ FORCE_COLOR: "0",
2314
+ npm_config_color: "false",
2315
+ NPM_CONFIG_COLOR: "false",
2215
2316
  npm_config_auto_install_peers: "false",
2216
2317
  NPM_CONFIG_AUTO_INSTALL_PEERS: "false",
2217
2318
  };
@@ -2855,7 +2956,7 @@ export function recoverProfile(profileDir) {
2855
2956
  rebuild: rolled?.rebuild,
2856
2957
  };
2857
2958
  }
2858
- const validation = validateInstalledProfile(profileDir);
2959
+ const validation = validatePendingProfile(profileDir, pending);
2859
2960
  const candidateName = pending.preflight?.candidate?.name ?? pending.candidate?.name;
2860
2961
  const removeValidation = isRemove
2861
2962
  ? validateRemoveCompletion(pending.profileDir, candidateName)
@@ -3958,6 +4059,58 @@ async function selfTest() {
3958
4059
  if (existsSync(snap.dir)) throw new Error("recoverProfile commit should delete the snapshot dir");
3959
4060
  }
3960
4061
 
4062
+ // A blocker that predates the transaction is still a real profile problem,
4063
+ // but it is not evidence that this update broke the profile. This is the
4064
+ // reported marketplace self-update failure: an unrelated legacy plugin
4065
+ // shadows host modules, so the new mall version used to be rolled back.
4066
+ {
4067
+ const p = join(root, "profiles", "preexisting-blocker");
4068
+ mkdirSync(join(p, "node_modules", "legacy"), { recursive: true });
4069
+ mkdirSync(join(p, "node_modules", "good"), { recursive: true });
4070
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { legacy: "1.0.0", good: "1.0.0" } }));
4071
+ writeFileSync(join(p, "cordis.patch.yml"), "[]\n");
4072
+ writeFileSync(join(p, "node_modules", "legacy", "package.json"), JSON.stringify({
4073
+ name: "legacy", version: "1.0.0", dependencies: { "@deepseek-ai/dsh-llm": "*" },
4074
+ }));
4075
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "1.0.0" }));
4076
+ if (validateInstalledProfile(p).issues.filter((entry) => entry.code === "host-module-shadow").length !== 1) {
4077
+ throw new Error("pre-existing blocker fixture prerequisite failed");
4078
+ }
4079
+ const snap = createProfileSnapshot(p, { fixture: true });
4080
+ markPendingSnapshot(snap, { spec: "good@2.0.0", preflight: { candidate: { name: "good", version: "2.0.0", kind: "plain" } } });
4081
+ writeFileSync(join(p, "package.json"), JSON.stringify({ dependencies: { legacy: "1.0.0", good: "2.0.0" } }));
4082
+ writeFileSync(join(p, "node_modules", "good", "package.json"), JSON.stringify({ name: "good", version: "2.0.0" }));
4083
+ const relative = validatePendingProfile(p);
4084
+ if (!relative.ok || relative.preexistingIssues.length !== 1 || relative.issues.some((entry) => entry.severity === "block")) {
4085
+ throw new Error(`unchanged pre-existing blocker must not condemn this update: ${JSON.stringify(relative)}`);
4086
+ }
4087
+ const rec = recoverProfile(p);
4088
+ if (rec.action !== "committed") throw new Error(`unchanged pre-existing blocker should commit this update, got ${rec.action}`);
4089
+ if (readJson(join(p, "node_modules", "good", "package.json")).version !== "2.0.0") {
4090
+ throw new Error("committing past a pre-existing blocker must keep the updated candidate");
4091
+ }
4092
+ console.log("PASS pending 基线:既有宿主模块 blocker 不再回滚无关更新");
4093
+
4094
+ const candidateProfile = join(root, "profiles", "candidate-own-blocker");
4095
+ mkdirSync(join(candidateProfile, "node_modules", "legacy"), { recursive: true });
4096
+ writeFileSync(join(candidateProfile, "package.json"), JSON.stringify({ dependencies: { legacy: "1.0.0" } }));
4097
+ writeFileSync(join(candidateProfile, "cordis.patch.yml"), "[]\n");
4098
+ writeFileSync(join(candidateProfile, "node_modules", "legacy", "package.json"), JSON.stringify({
4099
+ name: "legacy", version: "1.0.0", dependencies: { "@deepseek-ai/dsh-llm": "*" },
4100
+ }));
4101
+ const ownSnap = createProfileSnapshot(candidateProfile, { fixture: true });
4102
+ markPendingSnapshot(ownSnap, { spec: "legacy@2.0.0", preflight: { candidate: { name: "legacy", version: "2.0.0", kind: "plain" } } });
4103
+ writeFileSync(join(candidateProfile, "node_modules", "legacy", "package.json"), JSON.stringify({
4104
+ name: "legacy", version: "2.0.0", dependencies: { "@deepseek-ai/dsh-llm": "*" },
4105
+ }));
4106
+ const own = validatePendingProfile(candidateProfile);
4107
+ if (own.ok || !own.issues.some((entry) => entry.code === "host-module-shadow")) {
4108
+ throw new Error("an updated candidate must not inherit an exemption for its own old blocker");
4109
+ }
4110
+ commitPendingSnapshot(candidateProfile);
4111
+ console.log("PASS pending 基线:候选自身的既有 blocker 必须在更新中修复");
4112
+ }
4113
+
3961
4114
  // Approval-pause mark, part 1: a paused marker must NEVER commit on
3962
4115
  // recovery — not even when the static validation would pass (the version
3963
4116
  // sits there with its build scripts never approved; committing would drop
@@ -4602,6 +4755,21 @@ async function selfTest() {
4602
4755
  if (env.npm_config_auto_install_peers !== "false" || env.NPM_CONFIG_AUTO_INSTALL_PEERS !== "false") {
4603
4756
  throw new Error("pnpmGuardEnv must disable peer auto-install");
4604
4757
  }
4758
+ if (env.NO_COLOR !== "1" || env.FORCE_COLOR !== "0"
4759
+ || env.npm_config_color !== "false" || env.NPM_CONFIG_COLOR !== "false") {
4760
+ throw new Error("pnpmGuardEnv must disable terminal colours for browser job logs");
4761
+ }
4762
+
4763
+ let streamed = "";
4764
+ const coloured = await spawnCapture(
4765
+ process.execPath,
4766
+ ["-e", "process.stdout.write('\\u001b[96mprobe ok\\u001b[39m\\n')"],
4767
+ { env: process.env, shell: false },
4768
+ (text) => { streamed += text; },
4769
+ );
4770
+ if (coloured.exitCode !== 0 || coloured.output !== "probe ok\n" || streamed !== "probe ok\n") {
4771
+ throw new Error(`spawnCapture must strip terminal controls from captured and streamed logs: ${JSON.stringify({ coloured, streamed })}`);
4772
+ }
4605
4773
  }
4606
4774
 
4607
4775
  // candidateRestoredCompatible: version vs the restored dependency spec.
@@ -5279,4 +5447,4 @@ if (process.argv[1]?.endsWith("guard.js") && process.argv.includes("--self-test"
5279
5447
  console.error(`FAIL ${error.stack ?? error.message}`);
5280
5448
  process.exitCode = 1;
5281
5449
  });
5282
- }
5450
+ }
package/src/index.js CHANGED
@@ -1026,13 +1026,12 @@ export function resolveRestartLaunchPlan({ profile, config = {}, isWindows }) {
1026
1026
 
1027
1027
  const nodePath = process.execPath;
1028
1028
  const originalDshArgs = process.argv.slice(2);
1029
- // A restart is always requested from a page that is open and that reconnects
1030
- // to the successor on its own, so dsh's start-up browser handoff would only
1031
- // add a second window onto the one already showing the result. Suppressing it
1032
- // is safe HERE and nowhere else: this route is gated on a live browser
1033
- // session. If the flag is already in the original argv, leave it alone.
1034
- const suppressOpen = !originalDshArgs.includes("--no-open");
1035
- const dshArgs = suppressOpen ? [...originalDshArgs, "--no-open"] : [...originalDshArgs];
1029
+ // Restart the exact command the user started. `--no-open` belongs to newer
1030
+ // Web profiles, not to the stable dsh launcher contract; adding it here made
1031
+ // older hosts reject the successor with "unknown option '--no-open'" after
1032
+ // the outgoing process had already exited. A duplicate browser tab is less
1033
+ // harmful than inventing an argv capability the running host never proved.
1034
+ const dshArgs = [...originalDshArgs];
1036
1035
  // The outgoing host names itself so `guard launch` can wait for it to be
1037
1036
  // gone before binding the port — see --await-exit in cli.js.
1038
1037
  const args = [cliPath, "guard", "launch", "--profile", name, "--await-exit", String(process.pid), "--", nodePath, dshEntry, ...dshArgs];
@@ -1045,7 +1044,6 @@ export function resolveRestartLaunchPlan({ profile, config = {}, isWindows }) {
1045
1044
  dshEntry,
1046
1045
  dshArgs,
1047
1046
  profile: name,
1048
- suppressedBrowserOpen: suppressOpen,
1049
1047
  awaitExitPid: process.pid,
1050
1048
  };
1051
1049
  }
@@ -3350,15 +3348,15 @@ export async function runSelfTests() {
3350
3348
  const dshArgs = plan.args.slice(dashDash + 3); // -- node <dshEntry> …
3351
3349
  check("重启带 --await-exit 且是本进程 pid", plan.args[plan.args.indexOf("--await-exit") + 1] === String(process.pid) && plan.awaitExitPid === process.pid);
3352
3350
  check("--await-exit 排在 `--` 之前(是 guard 的参数,不是 dsh 的)", plan.args.indexOf("--await-exit") < dashDash);
3353
- check("重启给 dsh --no-open", dshArgs.includes("--no-open") && plan.suppressedBrowserOpen === true);
3354
- check("原始 dsh 参数原样保留", dshArgs.slice(0, 2).join(" ") === "--profile web");
3351
+ check("重启不注入宿主版本相关参数", dshArgs.join(" ") === "--profile web" && !dshArgs.includes("--no-open"));
3352
+ check("原始 dsh 参数原样保留", dshArgs.join(" ") === "--profile web");
3355
3353
  check("plan 附带可见模式所需的 dshArgs", plan.dshArgs.join(" ") === dshArgs.join(" "));
3356
3354
 
3357
- // 用户自己已经写了 --no-open 时不重复追加。
3355
+ // 用户自己传给宿主的参数仍逐字保留。
3358
3356
  process.argv = [process.execPath, "/x/bin.js", "--profile", "web", "--no-open"];
3359
3357
  const already = resolveRestartLaunchPlan({ profile: "web", config: { allowRestart: true } });
3360
3358
  const alreadyArgs = already.ok ? already.args.slice(already.args.indexOf("--") + 3) : [];
3361
- check("已有 --no-open 则不重复追加", already.ok && alreadyArgs.filter((a) => a === "--no-open").length === 1 && already.suppressedBrowserOpen === false);
3359
+ check("用户原有 --no-open 原样保留", already.ok && alreadyArgs.filter((a) => a === "--no-open").length === 1);
3362
3360
  } else {
3363
3361
  // 裸检出里解析不到官方 dsh 入口,plan 只能 fail——说清楚,别假装验过。
3364
3362
  console.log(` SKIP 重启 argv fixture(${plan.error})`);
@@ -3546,7 +3544,7 @@ export async function runSelfTests() {
3546
3544
  readyFile: join(fileRoot, "r1.json"),
3547
3545
  cwd: "C:/w",
3548
3546
  command: "C:/node/node.exe",
3549
- args: ["C:/dsh/index.js", "web", "--no-open"],
3547
+ args: ["C:/dsh/index.js", "web"],
3550
3548
  };
3551
3549
  check("plan payload 校验通过", validateRestartPlanPayload(basePlan).ok === true);
3552
3550
  for (const [override, needle] of [
@@ -3744,7 +3742,7 @@ export async function runSelfTests() {
3744
3742
  nodePath: process.execPath,
3745
3743
  cliPath: join(visibleRoot, "cli.js"),
3746
3744
  dshEntry: join(visibleRoot, "dsh-entry.js"),
3747
- dshArgs: ["--profile", "web", "--no-open"],
3745
+ dshArgs: ["--profile", "web"],
3748
3746
  profile: "web",
3749
3747
  awaitExitPid: 4242,
3750
3748
  };
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;
@@ -1562,10 +1571,11 @@ function pendingMarkerPath(profileDir) {
1562
1571
  function renderApprovalNeeded(spec, disclosure) {
1563
1572
  const lines = [
1564
1573
  `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.",
1574
+ "No install script ran and no plugin code loaded. The candidate is staged",
1575
+ "with its scripts blocked, and the original profile snapshot is retained.",
1576
+ "On approval, the materialized bytes and commands must match this disclosure",
1577
+ "before the verified tree is rebuilt. If you do not approve, restart dsh or",
1578
+ "run `dsh-plugin-guard guard recover` to roll the paused transaction back.",
1569
1579
  "",
1570
1580
  ];
1571
1581
  for (const entry of disclosure) {
@@ -1661,8 +1671,9 @@ function runInstallInner({ profile, spec, allowBuildScripts, approvedProof, pref
1661
1671
  const collected = [];
1662
1672
  const deltaQueue = [];
1663
1673
  const push = (text) => {
1664
- collected.push(text);
1665
- deltaQueue.push(text);
1674
+ const plainText = stripTerminalControlSequences(text);
1675
+ collected.push(plainText);
1676
+ deltaQueue.push(plainText);
1666
1677
  };
1667
1678
 
1668
1679
  const workspacePath = join(profileDir, "pnpm-workspace.yaml");
@@ -2154,7 +2165,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
2154
2165
  }
2155
2166
  const deltaQueue = [];
2156
2167
  const push = (text) => {
2157
- deltaQueue.push(text);
2168
+ deltaQueue.push(stripTerminalControlSequences(text));
2158
2169
  };
2159
2170
  let current = undefined;
2160
2171
  let cancelRequested = false; // see endedByCancel: exit codes cannot tell us this on Windows
@@ -2221,7 +2232,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
2221
2232
  try {
2222
2233
  proc = (_spawn ?? spawn)(plan.command, ["remove", packageName, "--reporter=append-only"], {
2223
2234
  cwd: profileDir,
2224
- env: process.env,
2235
+ env: pnpmGuardEnv(process.env),
2225
2236
  shell: plan.shell,
2226
2237
  stdio: ["ignore", "pipe", "pipe"],
2227
2238
  windowsHide: true,
@@ -2284,7 +2295,7 @@ function runRemoveInner({ profile, packageName, _profileDir, _spawn, _corepack =
2284
2295
  // 退出码 0 不等于卸干净了。落盘校验用的是启动恢复同一套判据:
2285
2296
  // profile 整体仍然自洽,且这个包确实从清单和装配层里消失了。任何一条
2286
2297
  // 不过就还原——一个「装着但坏」的 profile 比一个没卸掉的插件糟得多。
2287
- const profileCheck = validateInstalledProfile(profileDir);
2298
+ const profileCheck = validatePendingProfile(profileDir);
2288
2299
  const removeCheck = validateRemoveCompletion(profileDir, packageName);
2289
2300
  if (!profileCheck.ok || !removeCheck.ok) {
2290
2301
  const blockers = [...profileCheck.issues, ...removeCheck.issues]
@@ -2703,7 +2714,10 @@ async function runTransactionFixtures() {
2703
2714
  try {
2704
2715
  materializeFakePackage(profileDir, "some-plugin", "1.0.0");
2705
2716
  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" }]);
2717
+ // pnpm 11 on Windows may colour stderr even when it is captured through a
2718
+ // pipe. In particular, the reset code lands directly after the selector.
2719
+ const colouredIgnoredBuilds = "Packages are cloned\n\u001b[31mIgnored build scripts: node-pty@1.0.0\u001b[39m\nDone\n";
2720
+ const { spawnFn, calls } = scriptedSpawn([{ code: 0, out: colouredIgnoredBuilds }]);
2707
2721
  const producer = runInstall({
2708
2722
  profile: "p",
2709
2723
  spec: "some-plugin",
@@ -2725,7 +2739,7 @@ async function runTransactionFixtures() {
2725
2739
  const output = producer.readOutput();
2726
2740
  const markerBefore = pendingMarkerPath(profileDir);
2727
2741
  check(
2728
- "退出码 0 + Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
2742
+ "退出码 0 + 彩色 Ignored build scripts(未批准)→ 停在批准闸,返回 proof,不 finalize,暂停保留 marker",
2729
2743
  outcome.status === "failed"
2730
2744
  && Array.isArray(outcome.needsApproval)
2731
2745
  && outcome.needsApproval.some((entry) => entry.name === "node-pty")
@@ -2740,7 +2754,11 @@ async function runTransactionFixtures() {
2740
2754
  && entry.weeklyDownloads === 123)
2741
2755
  && calls.length === 1
2742
2756
  && existsSync(markerBefore)
2743
- && /paused for build-script approval/.test(output),
2757
+ && /candidate is staged/.test(outcome.detail ?? "")
2758
+ && !/profile was restored/.test(outcome.detail ?? "")
2759
+ && /paused for build-script approval/.test(output)
2760
+ && /Ignored build scripts: node-pty@1\.0\.0/.test(output)
2761
+ && !output.includes("\u001b["),
2744
2762
  `status=${outcome.status} calls=${calls.length} marker=${existsSync(markerBefore)}`,
2745
2763
  );
2746
2764
  check(
@@ -3413,7 +3431,7 @@ async function runTransactionFixtures() {
3413
3431
  let snapshotsWhileRunning = [];
3414
3432
  const { spawnFn } = scriptedSpawn([{
3415
3433
  code: 0,
3416
- out: "Done\n",
3434
+ out: "\u001b[32mDone\u001b[39m\n",
3417
3435
  // pnpm 真正卸掉:清单与目录都拿走,落盘校验才会通过。
3418
3436
  beforeExit: () => {
3419
3437
  snapshotsWhileRunning = listSnapshots();
@@ -3423,14 +3441,17 @@ async function runTransactionFixtures() {
3423
3441
  rmSync(join(profileDir, "node_modules", "pkg-f"), { recursive: true, force: true });
3424
3442
  },
3425
3443
  }]);
3426
- const outcome = await runRemove({ profile: "p", packageName: "pkg-f", _profileDir: profileDir, _spawn: spawnFn }).done;
3444
+ const producer = runRemove({ profile: "p", packageName: "pkg-f", _profileDir: profileDir, _spawn: spawnFn });
3445
+ const outcome = await producer.done;
3446
+ const output = producer.readOutput();
3427
3447
  const snapshotsLeft = listSnapshots();
3428
3448
  check(
3429
- "卸载成功 → marker 与 snapshot 都被提交清理(且快照确实创建过)",
3449
+ "卸载成功 → 日志去色,marker 与 snapshot 都被提交清理(且快照确实创建过)",
3430
3450
  outcome.status === "completed"
3431
3451
  && snapshotsWhileRunning.length === 1
3432
3452
  && !existsSync(pendingMarkerPath(profileDir))
3433
- && snapshotsLeft.length === 0,
3453
+ && snapshotsLeft.length === 0
3454
+ && output === "Done\n",
3434
3455
  `status=${outcome.status} 运行中快照=${snapshotsWhileRunning.join(",")} marker=${existsSync(pendingMarkerPath(profileDir))} 残留快照=${snapshotsLeft.join(",")} detail=${JSON.stringify(outcome.detail)}`,
3435
3456
  );
3436
3457
  } finally {
@@ -0,0 +1,11 @@
1
+ // Job logs are rendered as plain text in the browser. Child processes such as
2
+ // pnpm may still emit terminal colour controls when their stdio is piped (for
3
+ // example when FORCE_COLOR is inherited), which the browser shows as `[96m`.
4
+ // Keep the sanitizer dependency-free and limited to CSI sequences: those cover
5
+ // pnpm's colours/progress controls without deleting ordinary user text.
6
+
7
+ const ANSI_CSI_RE = new RegExp("\\u001b\\[[0-?]*[ -/]*[@-~]", "g");
8
+
9
+ export function stripTerminalControlSequences(value) {
10
+ return String(value ?? "").replace(ANSI_CSI_RE, "");
11
+ }