@1e0zj/dsh-plugin-mall 0.4.1 → 0.4.3

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.1",
3
+ "version": "0.4.3",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -499,10 +499,10 @@ window.__ModuleLoader__.load({
499
499
  busy: props.approving === job.spec,
500
500
  onApprove: function (names) {
501
501
  // 不先 drop:旧条目由重试任务的 track(carryFromId) 原子接管
502
- // (撤条目 + 日志接续一拍完成)。先删的话,call("install")
503
- // 要走数秒(重试还会重跑一次隔离预检),面板会空白一段,
504
- // 「批准后任务消失、开始安装才冒出来」的割裂就是这么来的。
505
- // 等待期间按钮由 approving 态显示「继续中…」。
502
+ // (撤条目 + 日志接续一拍完成)。install RPC 现在只做本地校验、
503
+ // 立刻返回新 job id,但保留这个次序仍然是它最稳的形态——
504
+ // 万一 RPC 失败,旧条目和日志还在。等待期间按钮由 approving
505
+ // 态显示「继续中…」。
506
506
  props.onApprove(job.spec, names, job.approvalToken, id);
507
507
  },
508
508
  onDismiss: function () { props.onDismiss(id); },
@@ -1056,7 +1056,10 @@ window.__ModuleLoader__.load({
1056
1056
  var spec = preflight.spec;
1057
1057
  var carry = preflight.jobId;
1058
1058
  setPreflight(null);
1059
- doRawInstall(spec, { acceptWarnings: true }, carry);
1059
+ // consentDigest 来自预检 job extras,装的时候与当前报告比对:
1060
+ // 用户点「继续」到安装真正开跑之间候选包或 profile 变了,
1061
+ // 布尔同意不得沿用,要重新看新的警告。
1062
+ doRawInstall(spec, { acceptWarnings: true, acceptedReportDigest: preflight.report.consentDigest }, carry);
1060
1063
  },
1061
1064
  onClose: function () {
1062
1065
  if (preflight && preflight.spec) delete approvalTokensRef.current[preflight.spec];
package/src/github.js CHANGED
@@ -172,15 +172,20 @@ function normalizeRegistry(registry) {
172
172
  * Look up a package's `latest` manifest on an npm registry.
173
173
  * @param name - the npm package name.
174
174
  * @param options - `registry` defaults to npmjs; pass what pnpm installs from.
175
+ * `signal` cancels the request (and, critically, keeps the cancellation out
176
+ * of the cache — see below). `fresh` skips the read side of the cache: a
177
+ * cached "current version" is stale by construction, and the preflight
178
+ * staleness check exists precisely to compare against what the registry
179
+ * says NOW (the write side still populates the cache for other callers).
175
180
  * @returns `{latest, repositoryUrl, hostDeps}`, or null when unknown/unreachable.
176
181
  */
177
- export async function npmPackageInfo(name, { registry } = {}) {
182
+ export async function npmPackageInfo(name, { registry, signal, fresh = false } = {}) {
178
183
  const clean = String(name ?? "").trim();
179
184
  if (clean.length === 0 || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(clean)) return null;
180
185
  const base = normalizeRegistry(registry);
181
186
  const key = `${base}|${clean}`;
182
187
  const cached = npmCache.get(key);
183
- if (cached !== undefined && Date.now() - cached.at < NPM_CACHE_TTL) return cached.info;
188
+ if (fresh !== true && cached !== undefined && Date.now() - cached.at < NPM_CACHE_TTL) return cached.info;
184
189
  let info = null;
185
190
  try {
186
191
  // 单版本端点返回 latest 的完整 manifest(dependencies + repository 都在)。
@@ -188,6 +193,7 @@ export async function npmPackageInfo(name, { registry } = {}) {
188
193
  // 镜像(npmmirror 等)的同名端点响应格式一致,两个字段都保留。
189
194
  const response = await fetch(`${base}/${clean.replace("/", "%2F")}/latest`, {
190
195
  headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
196
+ signal,
191
197
  });
192
198
  if (response.ok) {
193
199
  const body = await response.json();
@@ -201,19 +207,61 @@ export async function npmPackageInfo(name, { registry } = {}) {
201
207
  };
202
208
  }
203
209
  }
204
- } catch {
210
+ } catch (error) {
211
+ // 取消不是「查不到」。若把 AbortError 也吞成 null,下面的 npmCache.set 会把
212
+ // 这个空答案钉住整个 TTL——用户取消一次,接下来 5 分钟里防抢注永远不匹配、
213
+ // 宿主影子检查看不到清单,而且完全无声。取消一律上抛,不进缓存。
214
+ if (error?.name === "AbortError") throw error;
205
215
  info = null; // registry unreachable — caller falls back
206
216
  }
207
217
  npmCache.set(key, { info, at: Date.now() });
208
218
  return info;
209
219
  }
210
220
 
221
+ const npmVersionsCache = new Map();
222
+
223
+ /**
224
+ * All published version strings of a package, oldest to newest, for resolving
225
+ * a RANGE spec ("pkg@^1.2.0") to the version pnpm would actually pick today.
226
+ * `/latest` cannot answer that — a new release inside the range is invisible
227
+ * to it until it becomes latest. Fetches the full packument (one request),
228
+ * which is why this lives behind its own TTL like npmPackageInfo. Same
229
+ * cancellation rule: AbortError is rethrown, never cached as an empty answer.
230
+ * @returns string[], or null when unreachable/unknown.
231
+ */
232
+ export async function npmPackageVersions(name, { registry, signal, fresh = false } = {}) {
233
+ const clean = String(name ?? "").trim();
234
+ if (clean.length === 0 || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(clean)) return null;
235
+ const base = normalizeRegistry(registry);
236
+ const key = `${base}|${clean}`;
237
+ const cached = npmVersionsCache.get(key);
238
+ if (fresh !== true && cached !== undefined && Date.now() - cached.at < NPM_CACHE_TTL) return cached.versions;
239
+ let versions = null;
240
+ try {
241
+ const response = await fetch(`${base}/${clean.replace("/", "%2F")}`, {
242
+ headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
243
+ signal,
244
+ });
245
+ if (response.ok) {
246
+ const body = await response.json();
247
+ if (body?.versions !== null && typeof body?.versions === "object") {
248
+ versions = Object.keys(body.versions);
249
+ }
250
+ }
251
+ } catch (error) {
252
+ if (error?.name === "AbortError") throw error;
253
+ versions = null;
254
+ }
255
+ npmVersionsCache.set(key, { versions, at: Date.now() });
256
+ return versions;
257
+ }
258
+
211
259
  /**
212
260
  * Rewrite "github:owner/repo" (or "owner/repo") to the npm package name when
213
261
  * that package exists on npm AND its repository URL points back at the repo
214
262
  * (anti-squatting). Anything else passes through untouched.
215
263
  */
216
- export async function preferNpmSpec({ spec, registry, sources }) {
264
+ export async function preferNpmSpec({ spec, registry, sources, signal }) {
217
265
  const raw = String(spec ?? "");
218
266
  // A scoped npm name ("@scope/name", "@scope/name@1.2.3") is shaped exactly
219
267
  // like owner/repo and matches the regex below, sending every such install
@@ -223,10 +271,10 @@ export async function preferNpmSpec({ spec, registry, sources }) {
223
271
  const githubMatch = /^(?:github:)?([^/\s]+\/[^/\s]+?)(?:\.git)?$/i.exec(raw);
224
272
  if (githubMatch === null) return raw;
225
273
  const repo = githubMatch[1];
226
- const { results } = await verifyPlugins({ repos: [repo], sources }); // cache hit after first verify
274
+ const { results } = await verifyPlugins({ repos: [repo], sources, signal }); // cache hit after first verify
227
275
  const declaredName = results[repo]?.name;
228
276
  if (typeof declaredName !== "string") return raw;
229
- const info = await npmPackageInfo(declaredName, { registry });
277
+ const info = await npmPackageInfo(declaredName, { registry, signal });
230
278
  if (info === null || info.repositoryUrl === undefined) return raw;
231
279
  const pointsBack = new RegExp(`github\\.com[/:]${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/|\\.git|$)`, "i").test(info.repositoryUrl);
232
280
  return pointsBack ? declaredName : raw;
@@ -254,7 +302,7 @@ export function npmNameOf(raw) {
254
302
  return match === null ? null : match[1];
255
303
  }
256
304
 
257
- export async function assertSafeToInstall({ spec, registry, sources }) {
305
+ export async function assertSafeToInstall({ spec, registry, sources, signal }) {
258
306
  const raw = String(spec ?? "");
259
307
  let hostDeps;
260
308
  if (/^(?:file:|link:)/i.test(raw)) {
@@ -268,7 +316,7 @@ export async function assertSafeToInstall({ spec, registry, sources }) {
268
316
  }
269
317
  } else if (/^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.test(raw)) {
270
318
  const repo = /^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.exec(raw)[1];
271
- const { results } = await verifyPlugins({ repos: [repo], sources });
319
+ const { results } = await verifyPlugins({ repos: [repo], sources, signal });
272
320
  hostDeps = results[repo]?.hostDeps;
273
321
  } else if (/^https?:\/\//i.test(raw)) {
274
322
  return; // 远程 tarball 下载前无法廉价检查;罕见路径,放行
@@ -279,7 +327,7 @@ export async function assertSafeToInstall({ spec, registry, sources }) {
279
327
  // 无法识别形状的 spec 一律拒绝,而不是静默跳过检查。
280
328
  throw new Error(`cannot analyze install spec ${JSON.stringify(raw)} for host-shadow dependencies — refusing to install`);
281
329
  }
282
- const info = await npmPackageInfo(name, { registry });
330
+ const info = await npmPackageInfo(name, { registry, signal });
283
331
  hostDeps = info?.hostDeps;
284
332
  }
285
333
  if (hostDeps !== undefined && hostDeps !== null && hostDeps.length > 0) {
package/src/guard.js CHANGED
@@ -1460,8 +1460,49 @@ export function pnpmSpawnPlan({ platform = process.platform, pathEnv = process.e
1460
1460
  return { command: `"${cmd}"`, shell: true, treeKill: true };
1461
1461
  }
1462
1462
 
1463
- function spawnCapture(command, args, options, onOutput) {
1463
+ /** Build the cancellation error every caller here recognises by `name`. */
1464
+ function abortError(message) {
1465
+ const error = new Error(message);
1466
+ error.name = "AbortError";
1467
+ return error;
1468
+ }
1469
+
1470
+ /**
1471
+ * Whether an error is a cancellation rather than a failure. fetch/AbortSignal
1472
+ * throw a DOMException named "AbortError"; abortError() above matches it. The
1473
+ * distinction is load-bearing: a cancellation must never be reported as a
1474
+ * verdict, cached, or turned into a failed install.
1475
+ */
1476
+ export function isAbortError(error) {
1477
+ return error?.name === "AbortError";
1478
+ }
1479
+
1480
+ /**
1481
+ * Spawn, stream, and capture — with an optional cancellation seam.
1482
+ *
1483
+ * `options.signal`/`options.treeKill` (5th argument) make the child killable:
1484
+ * an already-aborted signal spawns nothing at all, and an abort mid-run
1485
+ * terminates the child. `treeKill` mirrors installer.js killProcessTree — on
1486
+ * Windows a shell-wrapped pnpm is a GRANDCHILD of cmd.exe, so killing the
1487
+ * wrapper leaves the real pnpm running and still writing; taskkill /T /F takes
1488
+ * the whole tree. Either way the promise still settles on 'close', never on
1489
+ * the abort itself: the caller must not get control back (and start deleting
1490
+ * the probe directory) while a pnpm process is still writing into it.
1491
+ *
1492
+ * 'close' bounds the WRITING, not the operating system's bookkeeping. It
1493
+ * arrives the instant taskkill signals the wrapper, while Windows still holds
1494
+ * the probe directory — it is the killed pnpm's cwd — for a moment longer, so
1495
+ * an rmSync right here still hits EPERM. That last stretch belongs to
1496
+ * cleanupProbeDir's retries, not to this promise.
1497
+ */
1498
+ function spawnCapture(command, args, options, onOutput, { signal, treeKill = false } = {}) {
1464
1499
  return new Promise((resolvePromise) => {
1500
+ if (signal?.aborted === true) {
1501
+ // 已经取消了就一个字节都别做:spawn 之后再杀,probe 目录里会留下半棵
1502
+ // 依赖树,pnpm store 也已经被写过。
1503
+ resolvePromise({ exitCode: 1, output: "", aborted: true });
1504
+ return;
1505
+ }
1465
1506
  let child;
1466
1507
  const chunks = [];
1467
1508
  const push = (value) => {
@@ -1480,13 +1521,31 @@ function spawnCapture(command, args, options, onOutput) {
1480
1521
  resolvePromise({ exitCode: 1, output: "", error });
1481
1522
  return;
1482
1523
  }
1524
+ let aborted = false;
1525
+ const onAbort = () => {
1526
+ aborted = true;
1527
+ if (treeKill === true && process.platform === "win32" && typeof child.pid === "number") {
1528
+ try {
1529
+ spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, timeout: 10000 });
1530
+ } catch { /* taskkill 不在 PATH 上——退回下面的普通 kill */ }
1531
+ }
1532
+ try { child.kill(); } catch { /* already gone */ }
1533
+ };
1534
+ signal?.addEventListener("abort", onAbort, { once: true });
1535
+ const settle = (result) => {
1536
+ signal?.removeEventListener("abort", onAbort);
1537
+ resolvePromise({ ...result, aborted });
1538
+ };
1483
1539
  child.stdout?.on("data", push);
1484
1540
  child.stderr?.on("data", push);
1485
- child.on("error", (error) => resolvePromise({ exitCode: 1, output: chunks.join(""), error }));
1486
- child.on("close", (exitCode) => resolvePromise({ exitCode: exitCode ?? 1, output: chunks.join("") }));
1541
+ child.on("error", (error) => settle({ exitCode: 1, output: chunks.join(""), error }));
1542
+ child.on("close", (exitCode) => settle({ exitCode: exitCode ?? 1, output: chunks.join("") }));
1487
1543
  });
1488
1544
  }
1489
1545
 
1546
+ /** Exported only so the self-test can drive the cancel path with a real child. */
1547
+ export const _spawnCaptureForTests = spawnCapture;
1548
+
1490
1549
  // The install spec is eventually handed to `pnpm add` — through a cmd shell on
1491
1550
  // Windows, where Node joins the args with spaces and does not per-argument
1492
1551
  // quote. It must therefore be validated at the exported preflight boundary too,
@@ -1521,8 +1580,14 @@ function probeAddArgs(spec) {
1521
1580
  /**
1522
1581
  * Install a candidate into a disposable directory with every lifecycle script
1523
1582
  * disabled, then inspect its actual package manifest and patch files.
1583
+ *
1584
+ * `signal` cancels the probe. Cancellation is reported by THROWING an
1585
+ * AbortError, never as a report: a report is a verdict about the candidate,
1586
+ * and the caller caches and acts on it. "The user pressed cancel" is not a
1587
+ * verdict — folding it into a blocked report would pin a fabricated blocker
1588
+ * into the preflight cache for the rest of its TTL.
1524
1589
  */
1525
- export async function preflightInstall({ profileDir, spec, onOutput }) {
1590
+ export async function preflightInstall({ profileDir, spec, onOutput, signal }) {
1526
1591
  try {
1527
1592
  assertSafeSpec(spec);
1528
1593
  } catch (error) {
@@ -1536,6 +1601,7 @@ export async function preflightInstall({ profileDir, spec, onOutput }) {
1536
1601
  }
1537
1602
  const probeDir = mkdtempSync(join(tmpdir(), "dsh-plugin-guard-"));
1538
1603
  try {
1604
+ if (signal?.aborted === true) throw abortError(`preflight of ${spec} was cancelled before it started`);
1539
1605
  writeFileSync(join(probeDir, "package.json"), JSON.stringify({ name: "dsh-plugin-guard-probe", private: true }, undefined, 2) + "\n");
1540
1606
  writeFileSync(join(probeDir, "pnpm-workspace.yaml"), "packages:\n - .\n\nnodeLinker: hoisted\n");
1541
1607
  // Reuse the profile's registry/auth settings for the probe. The file may
@@ -1545,7 +1611,18 @@ export async function preflightInstall({ profileDir, spec, onOutput }) {
1545
1611
  if (existsSync(profileNpmrc)) copyFileSync(profileNpmrc, join(probeDir, ".npmrc"));
1546
1612
  onOutput?.(`[dsh-plugin-guard] probing ${spec} with install scripts disabled\n`);
1547
1613
  const plan = pnpmSpawnPlan();
1548
- const result = await spawnCapture(plan.command, probeAddArgs(spec), { cwd: probeDir, env: process.env, shell: plan.shell }, onOutput);
1614
+ const result = await spawnCapture(
1615
+ plan.command,
1616
+ probeAddArgs(spec),
1617
+ { cwd: probeDir, env: process.env, shell: plan.shell },
1618
+ onOutput,
1619
+ { signal, treeKill: plan.treeKill },
1620
+ );
1621
+ // 取消先判:被杀掉的 pnpm 退出码非 0,不先分流就会被报成
1622
+ // "无法在隔离环境解析插件" —— 一条纯属捏造的阻断结论。
1623
+ if (result.aborted === true || signal?.aborted === true) {
1624
+ throw abortError(`preflight of ${spec} was cancelled`);
1625
+ }
1549
1626
  if (result.exitCode !== 0) {
1550
1627
  return {
1551
1628
  ok: false,
@@ -1570,6 +1647,10 @@ export async function preflightInstall({ profileDir, spec, onOutput }) {
1570
1647
  if (candidatePath === undefined) throw new Error(`installed package ${names[0]} has no resolvable package.json`);
1571
1648
  return inspectCandidate({ profileDir, candidateManifestPath: candidatePath, spec });
1572
1649
  } catch (error) {
1650
+ // 取消必须先于这个兜底放行。兜底存在的意义是「任何意外都变成一份可读的
1651
+ // 阻断报告」,而取消不是意外,是用户的指令;混进来就成了一条会被缓存、
1652
+ // 会被当成候选包问题展示的假结论。
1653
+ if (isAbortError(error)) throw error;
1573
1654
  return {
1574
1655
  ok: false,
1575
1656
  verdict: "blocked",
@@ -1580,7 +1661,41 @@ export async function preflightInstall({ profileDir, spec, onOutput }) {
1580
1661
  } finally {
1581
1662
  // probeDir is created by mkdtemp directly under the system temp folder;
1582
1663
  // it never contains user-authored files.
1583
- rmSync(probeDir, { recursive: true, force: true });
1664
+ cleanupProbeDir(probeDir, onOutput);
1665
+ }
1666
+ }
1667
+
1668
+ /**
1669
+ * Delete the throwaway probe directory — WITHOUT ever changing the outcome of
1670
+ * the preflight that created it.
1671
+ *
1672
+ * Two things bite here, and they compound:
1673
+ *
1674
+ * 1. On Windows `'close'` arrives the instant taskkill /T /F signals the
1675
+ * wrapper, but the killed pnpm and its children release their file
1676
+ * handles a moment later. rmSync straight after a cancel therefore hits
1677
+ * EPERM on a directory that is about to become deletable. `maxRetries`
1678
+ * (Node retries exactly EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM) rides that
1679
+ * out; the delay backs off linearly, so 10 × 100ms is ~5s worst case.
1680
+ *
1681
+ * 2. This runs in a `finally`, and an exception thrown from `finally`
1682
+ * REPLACES the one already propagating. So a failed cleanup used to
1683
+ * swallow the AbortError of a cancelled preflight and surface as
1684
+ * `failed: EPERM \\?\C:\...\dsh-plugin-guard-XXXX` — the user pressed
1685
+ * cancel and got an unreadable path error about a temp directory they
1686
+ * never knew existed. Leaking a temp dir the OS will reap anyway is far
1687
+ * cheaper than misreporting why the job ended, so the throw stops here.
1688
+ *
1689
+ * `remove` is a test seam. The real EPERM comes from an OS race — the probe
1690
+ * directory is the killed pnpm's cwd, and Windows keeps it locked until that
1691
+ * process is truly gone — which cannot be reproduced reliably in a fixture.
1692
+ * The fixture therefore pins OUR error handling, not the kernel's timing.
1693
+ */
1694
+ function cleanupProbeDir(probeDir, onOutput, remove = rmSync) {
1695
+ try {
1696
+ remove(probeDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
1697
+ } catch (error) {
1698
+ onOutput?.(`[dsh-plugin-guard] could not remove probe directory ${probeDir} (${error?.code ?? error?.message}) — leaving it for the OS to reclaim\n`);
1584
1699
  }
1585
1700
  }
1586
1701
 
@@ -4575,6 +4690,94 @@ async function selfTest() {
4575
4690
  }
4576
4691
  }
4577
4692
 
4693
+ // ── 取消传播(spawnCapture / preflightInstall)─────────────────────────
4694
+ //
4695
+ // 预检是 market_install 里唯一真正耗时的一段(隔离目录探装)。它此前完全
4696
+ // 不可取消:按下 job_kill 只是标记了记录,pnpm 照跑到底。这三条钉的是
4697
+ // 取消语义本身——不 spawn、等 close、以及「取消不是一份阻断报告」。
4698
+ {
4699
+ const marker = join(root, "child-ran.txt");
4700
+ const writeMarker = `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran")`;
4701
+
4702
+ // 1) 已经取消 → 一个子进程都不该起。
4703
+ const preAborted = await _spawnCaptureForTests(
4704
+ process.execPath,
4705
+ ["-e", writeMarker],
4706
+ { env: process.env },
4707
+ undefined,
4708
+ { signal: AbortSignal.abort() },
4709
+ );
4710
+ if (preAborted.aborted !== true) throw new Error("已 abort 的 signal 必须标记 aborted");
4711
+ if (existsSync(marker)) throw new Error("已 abort 的 signal 仍然 spawn 了子进程");
4712
+ console.log("PASS 取消:已 abort 的 signal 不 spawn 子进程");
4713
+
4714
+ // 2) 飞行中取消 → 杀掉子进程,但仍然等到 'close' 才 resolve。
4715
+ // (resolve 早于进程退出,调用方就会在 pnpm 还在写的时候删 probe 目录。)
4716
+ const controller = new AbortController();
4717
+ const startedAt = Date.now();
4718
+ let closedAt;
4719
+ const running = _spawnCaptureForTests(
4720
+ process.execPath,
4721
+ ["-e", `process.on("exit", () => {}); setTimeout(() => { ${writeMarker} }, 30000)`],
4722
+ { env: process.env },
4723
+ undefined,
4724
+ { signal: controller.signal, treeKill: false },
4725
+ ).then((result) => { closedAt = Date.now(); return result; });
4726
+ await new Promise((r) => setTimeout(r, 150));
4727
+ controller.abort();
4728
+ const killed = await running;
4729
+ const elapsed = closedAt - startedAt;
4730
+ if (killed.aborted !== true) throw new Error("飞行中取消必须标记 aborted");
4731
+ if (elapsed > 20000) throw new Error(`取消后应立即终止子进程,实测等了 ${elapsed}ms`);
4732
+ if (existsSync(marker)) throw new Error("取消后子进程仍然跑完了它的工作");
4733
+ console.log(`PASS 取消:飞行中取消终止子进程并等到 close 才 resolve(${elapsed}ms)`);
4734
+
4735
+ // 3) preflightInstall 取消 → 抛 AbortError,而不是返回一份 blocked 报告。
4736
+ // 兜底 catch 会把任何异常变成「预检执行失败」的阻断结论;那份结论会
4737
+ // 被上层缓存、展示成候选包的问题,而用户只是按了取消。
4738
+ let cancelOutcome = "<resolved>";
4739
+ try {
4740
+ const report = await preflightInstall({ profileDir, spec: "any-package", signal: AbortSignal.abort() });
4741
+ cancelOutcome = `report:${report.verdict}`;
4742
+ } catch (error) {
4743
+ cancelOutcome = isAbortError(error) ? "abort" : `error:${error?.name}`;
4744
+ }
4745
+ if (cancelOutcome !== "abort") throw new Error(`preflightInstall 取消必须上抛 AbortError,实得 ${cancelOutcome}`);
4746
+ console.log("PASS 取消:preflightInstall 上抛 AbortError(不伪造阻断报告)");
4747
+
4748
+ // 4) 探针目录删不掉时,清理不许改变结局。
4749
+ // 实测踩到的:取消后 taskkill /T /F 一发,wrapper 的 'close' 立刻到,
4750
+ // 但探针目录是那个被强杀的 pnpm 的 cwd,Windows 会一直锁着它,
4751
+ // finally 里的 rmSync 撞 EPERM —— 而 finally 抛出的异常会顶掉正在
4752
+ // 上抛的 AbortError,于是「用户按了取消」在 job 里显示成一条看不懂的
4753
+ // EPERM 临时目录路径。
4754
+ // 这里注入一个必抛的 remove:真实 EPERM 靠的是 OS 时序,fixture 复现
4755
+ // 不稳定(Node 的 openSync 带 FILE_SHARE_DELETE,占着句柄照样能删),
4756
+ // 而要钉住的本来就是我们的错误处理,不是内核的时机。
4757
+ const cleanupDir = mkdtempSync(join(tmpdir(), "dsh-guard-cleanup-"));
4758
+ let cleanupThrew;
4759
+ let cleanupReport = "";
4760
+ try {
4761
+ cleanupProbeDir(cleanupDir, (text) => { cleanupReport += text; }, () => {
4762
+ const error = new Error(`EPERM, Permission denied: ${cleanupDir}`);
4763
+ error.code = "EPERM";
4764
+ throw error;
4765
+ });
4766
+ } catch (error) {
4767
+ cleanupThrew = error;
4768
+ }
4769
+ if (cleanupThrew !== undefined) {
4770
+ throw new Error(`cleanupProbeDir 抛了 ${cleanupThrew?.code ?? cleanupThrew?.message}——它跑在 finally 里,会顶掉 AbortError`);
4771
+ }
4772
+ if (!cleanupReport.includes("could not remove probe directory")) {
4773
+ throw new Error("清理失败必须被报告出来,而不是静默吞掉");
4774
+ }
4775
+ // 真能删的时候要真的删掉——上面那条别把清理本身测没了。
4776
+ cleanupProbeDir(cleanupDir, undefined);
4777
+ if (existsSync(cleanupDir)) throw new Error("cleanupProbeDir 未能删除可删除的目录");
4778
+ console.log("PASS 取消:探针目录清理失败不改变结局(且可删时确实删掉)");
4779
+ }
4780
+
4578
4781
  console.log("PASS conflict scan and snapshot/pending/rollback fixtures");
4579
4782
  } finally {
4580
4783
  rmSync(root, { recursive: true, force: true });