@1e0zj/dsh-plugin-mall 0.4.7 → 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/src/client.js CHANGED
@@ -29,6 +29,7 @@ window.__ModuleLoader__.load({
29
29
  ".mkt_btnSm{padding:3px 10px;font-size:12px}",
30
30
  ".mkt_error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}",
31
31
  ".mkt_notice{border-left:3px solid var(--dsw-alias-state-error-primary);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary);padding:7px 10px;font-size:12px;line-height:18px;overflow-wrap:anywhere}",
32
+ ".mkt_noticeInfo{border-left-color:var(--dsw-alias-state-accent-primary);color:inherit}",
32
33
  ".mkt_list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;align-items:stretch;min-width:0}",
33
34
  "@media (max-width:820px){.mkt_list{grid-template-columns:minmax(0,1fr)}}",
34
35
  ".mkt_listHead{grid-column:1/-1;font-size:12px;color:var(--dsw-alias-label-tertiary)}",
@@ -395,6 +396,13 @@ window.__ModuleLoader__.load({
395
396
  return h("div", { className: "mkt_notice", role: "alert" }, props.message);
396
397
  }
397
398
 
399
+ // 状态性提示(区别于 ErrorNotice 的 role=alert):「重启已调度」这类
400
+ // 成功路径的进展信息,页面自动重连后随刷新自然消失。
401
+ function StatusNotice(props) {
402
+ if (!props.message) return null;
403
+ return h("div", { className: "mkt_notice mkt_noticeInfo", role: "status" }, props.message);
404
+ }
405
+
398
406
  function RestartDialog(props) {
399
407
  useEffect(function () {
400
408
  function onKeyDown(event) {
@@ -435,7 +443,7 @@ window.__ModuleLoader__.load({
435
443
  meta: clip(props.spec || "", 50),
436
444
  actions: actions,
437
445
  },
438
- props.detail ? h("div", { className: "mkt_desc" }, props.detail) : null,
446
+ h("div", { className: "mkt_desc" }, "pnpm 已拦截下列安装脚本,尚未执行任何插件代码。允许后会先复核制品再运行;暂不允许时,重启 dsh 会自动撤回这次安装。"),
439
447
  pkgs.map(function (p, index) {
440
448
  var facts = [];
441
449
  if (typeof p.weeklyDownloads === "number") facts.push("周下载 " + p.weeklyDownloads.toLocaleString());
@@ -601,7 +609,6 @@ window.__ModuleLoader__.load({
601
609
  job.needsApproval && job.needsApproval.length > 0
602
610
  ? h(ApprovalRequest, {
603
611
  spec: job.spec,
604
- detail: job.detail,
605
612
  needsApproval: job.needsApproval,
606
613
  busy: props.approving === job.spec,
607
614
  onApprove: function (names) {
@@ -764,6 +771,9 @@ window.__ModuleLoader__.load({
764
771
  var _restarting = useState(false);
765
772
  var restarting = _restarting[0];
766
773
  var setRestarting = _restarting[1];
774
+ var _restartNote = useState(null);
775
+ var restartNote = _restartNote[0];
776
+ var setRestartNote = _restartNote[1];
767
777
  var _restartConfirm = useState(false);
768
778
  var restartConfirm = _restartConfirm[0];
769
779
  var setRestartConfirm = _restartConfirm[1];
@@ -1026,10 +1036,18 @@ window.__ModuleLoader__.load({
1026
1036
  clearRestartPing();
1027
1037
  setRestarting(true);
1028
1038
  setError(null);
1039
+ setRestartNote(null);
1029
1040
  var requestedAt = Date.now();
1030
1041
  var previousHostStartedAt = Number(hostStartedAt);
1031
- call("restart", {}).then(function () {
1042
+ var scheduledLogPath = null;
1043
+ call("restart", {}).then(function (value) {
1032
1044
  if (restartControlRef.current.mounted !== true) return;
1045
+ // 响应语义是「已调度交接」而不是「重启成功」:新宿主何时接管
1046
+ // 由下面的轮询判断。这里只把输出会出现在哪里告诉用户。
1047
+ scheduledLogPath = value && value.logPath ? String(value.logPath) : null;
1048
+ setRestartNote(value && value.mode === "visible"
1049
+ ? "重启已调度——dsh 正在新的控制台窗口中启动,窗口里 Ctrl+C 可停止;输出同时写入 " + scheduledLogPath
1050
+ : "重启已调度,输出与日志:" + (scheduledLogPath || "<home>/guard/ 下"));
1033
1051
  var tries = 0;
1034
1052
  var ping = setInterval(function () {
1035
1053
  if (restartControlRef.current.mounted !== true) {
@@ -1040,7 +1058,8 @@ window.__ModuleLoader__.load({
1040
1058
  if (tries > 40) {
1041
1059
  clearRestartPing();
1042
1060
  setRestarting(false);
1043
- reportError("restart", "2 分钟内未检测到 dsh 重启完成,请手动检查 dsh 状态后刷新页面。");
1061
+ reportError("restart", "2 分钟内未检测到 dsh 重启完成,请手动检查 dsh 状态后刷新页面。"
1062
+ + (scheduledLogPath ? "输出与日志:" + scheduledLogPath : ""));
1044
1063
  return;
1045
1064
  }
1046
1065
  // Prefer identity change over wall-clock ordering: NTP may move the
@@ -1063,6 +1082,7 @@ window.__ModuleLoader__.load({
1063
1082
  if (restartControlRef.current.mounted !== true) return;
1064
1083
  clearRestartPing();
1065
1084
  setRestarting(false);
1085
+ setRestartNote(null);
1066
1086
  reportError("restart", e);
1067
1087
  });
1068
1088
  }, [call, clearRestartPing, hostStartedAt, reportError]);
@@ -1128,6 +1148,7 @@ window.__ModuleLoader__.load({
1128
1148
  "只看已验证插件")
1129
1149
  ),
1130
1150
  h(ErrorNotice, { message: error && (error.scope === "search" || error.scope === "restart") ? error.message : null }),
1151
+ restarting ? h(StatusNotice, { message: restartNote }) : null,
1131
1152
  h(InstalledPanel, {
1132
1153
  installed: installed,
1133
1154
  removing: removing,
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
+ }